diff --git a/.gitignore b/.gitignore index db6280652..98a65bcaa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,40 +1,40 @@ -*.class -*.jar -*.war -*.ear -.classpath -.project -.settings -.gradle -build -*.iml -*.ipr -*.iws -*.swp -*.DS_Store -.idea -.vscode -catalina.base_IS_UNDEFINED/ -keystore.jks -bin/ -.lock -fineract-provider/out/ -fineract-provider/config/swagger/config.json -fineract-provider/config/swagger/fineract-input.yaml -licenses -**/.asciidoctor/images/* -**/.asciidoctor/diagram/* -**/images/diag-*.svg - -fineract-provider/src/main/generated/ - -**/out/ -gradleExp/ - -.run/ -.java-version - -.windsurf/ - -# Application logs +*.class +*.jar +*.war +*.ear +.classpath +.project +.settings +.gradle +build +*.iml +*.ipr +*.iws +*.swp +*.DS_Store +.idea +.vscode +catalina.base_IS_UNDEFINED/ +keystore.jks +bin/ +.lock +fineract-provider/out/ +fineract-provider/config/swagger/config.json +fineract-provider/config/swagger/fineract-input.yaml +licenses +**/.asciidoctor/images/* +**/.asciidoctor/diagram/* +**/images/diag-*.svg + +fineract-provider/src/main/generated/ + +**/out/ +gradleExp/ + +.run/ +.java-version + +.windsurf/ + +# Application logs fineract-provider/logs/ \ No newline at end of file diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/JournalEntryMapper.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/JournalEntryMapper.java index 7abb384c7..89cd5e681 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/JournalEntryMapper.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/JournalEntryMapper.java @@ -18,7 +18,6 @@ */ package org.apache.fineract.accounting.journalentry; -import java.time.OffsetDateTime; import org.apache.fineract.accounting.glaccount.domain.GLAccountType; import org.apache.fineract.accounting.journalentry.data.JournalEntryData; import org.apache.fineract.accounting.journalentry.domain.JournalEntry; diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingHelper.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingHelper.java index 059ac4845..6d48c3433 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingHelper.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingHelper.java @@ -277,14 +277,14 @@ public class ProductToGLAccountMappingHelper { } final JsonArray chargeToIncomeAccountMappingArray = this.fromApiJsonHelper.extractJsonArrayNamed(arrayFragmentName, element); - /** + /* * Variable stores a map representation of charges (key) and their associated income Id's (value) extracted from * the passed in Jsoncommand - **/ + */ final Map inputChargeToIncomeAccountMap = new HashMap<>(); - /*** + /* * Variable stores all charges which have already been mapped to Income Accounts in the system - **/ + */ final Set existingCharges = new HashSet<>(); if (chargeToIncomeAccountMappingArray != null) { if (changes != null) { @@ -301,13 +301,13 @@ public class ProductToGLAccountMappingHelper { // If input map is empty, delete all existing mappings if (inputChargeToIncomeAccountMap.size() == 0) { this.accountMappingRepository.deleteAll(existingChargeToIncomeAccountMappings); - } /** + } /* * Else,
* update existing mappings OR
* delete old mappings (which are already present, but not passed in as a part of Jsoncommand)
* Create new mappings for charges that are passed in as a part of the Jsoncommand but not already present * - **/ + */ else { for (final ProductToGLAccountMapping chargeToIncomeAccountMapping : existingChargeToIncomeAccountMappings) { final Long currentCharge = chargeToIncomeAccountMapping.getCharge().getId(); @@ -356,14 +356,14 @@ public class ProductToGLAccountMappingHelper { .findAllPaymentTypeToFundSourceMappings(productId, portfolioProductType.getValue()); final JsonArray paymentChannelMappingArray = this.fromApiJsonHelper .extractJsonArrayNamed(LoanProductAccountingParams.PAYMENT_CHANNEL_FUND_SOURCE_MAPPING.getValue(), element); - /** + /* * Variable stores a map representation of Payment channels (key) and their fund sources (value) extracted from * the passed in Jsoncommand - **/ + */ final Map inputPaymentChannelFundSourceMap = new HashMap<>(); - /*** + /* * Variable stores all payment types which have already been mapped to Fund Sources in the system - **/ + */ final Set existingPaymentTypes = new HashSet<>(); if (paymentChannelMappingArray != null) { if (changes != null) { @@ -381,14 +381,14 @@ public class ProductToGLAccountMappingHelper { // If input map is empty, delete all existing mappings if (inputPaymentChannelFundSourceMap.isEmpty()) { this.accountMappingRepository.deleteAll(existingPaymentChannelToFundSourceMappings); - } /** + } /* * Else,
* update existing mappings OR
* delete old mappings (which re already present, but not passed in as a part of Jsoncommand)
* Create new mappings for payment types that are passed in as a part of the Jsoncommand but not already * present * - **/ + */ else { for (final ProductToGLAccountMapping existingPaymentChannelToFundSourceMapping : existingPaymentChannelToFundSourceMappings) { final Long currentPaymentChannelId = existingPaymentChannelToFundSourceMapping.getPaymentType().getId(); @@ -584,9 +584,9 @@ public class ProductToGLAccountMappingHelper { // based on input condition GLAccount glAccount; - /** + /* * Both CASH and Accrual placeholders have the same value for income from Interest and penalties - **/ + */ CashAccountsForLoan placeHolderAccountType; if (isPenalty) { glAccount = getAccountByIdAndType(LoanProductAccountingParams.INCOME_ACCOUNT_ID.getValue(), GLAccountType.INCOME, diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/api/TellerApiResource.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/api/TellerApiResource.java index 59e79b9fc..684937c3c 100644 --- a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/api/TellerApiResource.java +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/api/TellerApiResource.java @@ -181,12 +181,12 @@ public class TellerApiResource { @Consumes({ MediaType.APPLICATION_JSON }) @Produces(MediaType.APPLICATION_JSON) @Operation(summary = "Create Cashiers", description = """ - Mandatory Fields:\s + Mandatory Fields: Cashier/staff, Fromm Date, To Date, Full Day or From time and To time - Optional Fields:\s + Optional Fields: Description/Notes""") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = TellerApiResourceSwagger.PostTellersTellerIdCashiersRequest.class))) @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = TellerApiResourceSwagger.PostTellersTellerIdCashiersResponse.class))) diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index 685cacb8c..5e2c31402 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -640,6 +640,14 @@ public class CommandWrapperBuilder { return this; } + public CommandWrapperBuilder createUserAgreement() { + this.actionName = "CREATE"; + this.entityName = "USERAGREEMENT"; + this.entityId = null; + this.href = "/useragreements"; + return this; + } + public CommandWrapperBuilder createOffice() { this.actionName = "CREATE"; this.entityName = "OFFICE"; diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/documentmanagement/adapter/EntitySignatureIdAdapter.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/documentmanagement/adapter/EntitySignatureIdAdapter.java new file mode 100644 index 000000000..4f6ec710a --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/documentmanagement/adapter/EntitySignatureIdAdapter.java @@ -0,0 +1,56 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.documentmanagement.adapter; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Optional; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Decouples the entity "current signature" pointer from the generic document service, the same + * way {@link EntityImageIdAdapter} decouples the "current photo" pointer — lets + * DocumentWritePlatformServiceImpl (fineract-document module) give single-slot upsert semantics to + * signature uploads without depending on portfolio-module entities directly. + */ +public interface EntitySignatureIdAdapter { + + /** @param documentName the m_document.name of the document being created/removed */ + boolean accept(String entityType, String documentName); + + Optional get(Long entityId); + + Optional set(Long entityId, Long signatureId); + + @Builder + @Data + @NoArgsConstructor + @AllArgsConstructor + class SignatureIdResult implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private Long id; + private String displayName; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/CfcfrmsSimulatorLog.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/CfcfrmsSimulatorLog.java index 2408432df..ba18f26e6 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/CfcfrmsSimulatorLog.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/CfcfrmsSimulatorLog.java @@ -23,6 +23,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.Table; import java.time.OffsetDateTime; import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** * Simulator-side audit log for CFCFRMS (I4C/MHA) events. @@ -69,7 +70,7 @@ public class CfcfrmsSimulatorLog extends AbstractAuditableWithUTCDateTimeCustom< this.eventType = eventType; this.payload = payload; this.responsePayload = responsePayload; - this.eventTimestamp = OffsetDateTime.now(); + this.eventTimestamp = DateUtils.getOffsetDateTimeOfTenant(); } public CfcfrmsSimulatorLog(final String cfcfrmsRef, final String ticketId, final String bankIfsc, diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintAuditLog.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintAuditLog.java index 6dc8ca92e..c8cc2860f 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintAuditLog.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/cfcfrms/domain/ComplaintAuditLog.java @@ -23,6 +23,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.Table; import java.time.OffsetDateTime; import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** * Audit trail for every status change on a CFCFRMS complaint ticket. @@ -54,7 +55,7 @@ public class ComplaintAuditLog extends AbstractAuditableWithUTCDateTimeCustom get(Long entityId) { + return repository.findById(entityId).filter(client -> nonNull(client.getSignatureId())) + .map(client -> SignatureIdResult.builder().id(client.getSignatureId()).displayName(client.getDisplayName()).build()); + } + + @Override + @Transactional + public Optional set(Long entityId, Long signatureId) { + final var result = get(entityId); + + if (signatureId == null) { + repository.removeSignatureId(entityId); + } else { + repository.updateByIdAndSignatureId(entityId, signatureId); + } + + return result; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java index 599ff372a..eabe2eb9a 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java @@ -63,6 +63,7 @@ public class ClientApiConstants { public static final String CLIENT_CLASSIFICATION = "ClientClassification"; public static final String OCCUPATION_CATEGORY = "OccupationCategory"; + public static final String PWD_CATEGORY = "PwdCategory"; public static final String OCCUPATION = "Occupation"; public static final String CLIENT_NON_PERSON_CONSTITUTION = "Constitution"; @@ -96,6 +97,15 @@ public class ClientApiConstants { public static final String mobileNoParamName = "mobileNo"; public static final String alternateMobileNoParamName = "alternateMobileNo"; public static final String emailAddressParamName = "emailAddress"; + // Dual-OTP contact-change verification (see ClientContactOtpService) — required only when + // mobileNo or emailAddress is being changed via PUT /clients/{id}. Reference an already + // VERIFIED m_otp_log row for the OLD destination (current DB value) and the NEW destination + // (incoming value) respectively. Field-specific (not a single generic pair) so mobileNo and + // emailAddress can both change in the same request without ambiguity. + public static final String mobileOldOtpIdParamName = "mobileOldOtpId"; + public static final String mobileNewOtpIdParamName = "mobileNewOtpId"; + public static final String emailOldOtpIdParamName = "emailOldOtpId"; + public static final String emailNewOtpIdParamName = "emailNewOtpId"; public static final String firstnameParamName = "firstname"; public static final String middlenameParamName = "middlename"; public static final String lastnameParamName = "lastname"; @@ -138,7 +148,13 @@ public class ClientApiConstants { public static final String religionParamName = "religion"; public static final String socialCategoryParamName = "socialCategory"; public static final String monthlyIncomeParamName = "monthlyIncome"; + public static final String annualIncomeParamName = "annualIncome"; public static final String annualTurnoverParamName = "annualTurnover"; + public static final String monthlyTurnoverParamName = "monthlyTurnover"; + public static final String isSeniorCitizenParamName = "isSeniorCitizen"; + public static final String isPwdParamName = "isPwd"; + public static final String pwdCategoryIdParamName = "pwdCategoryId"; + public static final String pwdCategoryParamName = "pwdCategory"; public static final String agricultureIncomeParamName = "agricultureIncome"; public static final String nonAgricultureIncomeParamName = "nonAgricultureIncome"; public static final String ageYearsParamName = "ageYears"; @@ -248,8 +264,9 @@ public class ClientApiConstants { staffOptionsParamName, dateOfBirthParamName, genderParamName, clientTypeParamName, clientClassificationParamName, salutationParamName, occupationCategoryParamName, occupationParamName, maritalStatusParamName, educationLevelParamName, degreeQualificationParamName, - religionParamName, socialCategoryParamName, monthlyIncomeParamName, - annualTurnoverParamName, agricultureIncomeParamName, nonAgricultureIncomeParamName, + religionParamName, socialCategoryParamName, monthlyIncomeParamName, annualIncomeParamName, + annualTurnoverParamName, monthlyTurnoverParamName, agricultureIncomeParamName, nonAgricultureIncomeParamName, + isSeniorCitizenParamName, isPwdParamName, pwdCategoryParamName, ageYearsParamName, ageMonthsParamName, ageDaysParamName, nationalityIdParamName, isMinorParamName, guardianClientIdParamName, guardianClientDisplayNameParamName, guardianRelationParamName, dipVerifiedParamName, dipLookupLogIdParamName, dipTimestampParamName, diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientData.java index c6a691408..452120d95 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientData.java @@ -82,7 +82,13 @@ public final class ClientData implements Comparable, Serializable { private CodeValueData religion; private CodeValueData socialCategory; private BigDecimal monthlyIncome; + private BigDecimal annualIncome; private BigDecimal annualTurnover; + private BigDecimal monthlyTurnover; + private Boolean isSeniorCitizen; + private Boolean isPwd; + private CodeValueData pwdCategory; + private Collection pwdCategoryOptions; private BigDecimal agricultureIncome; private BigDecimal nonAgricultureIncome; private Integer ageYears; @@ -400,13 +406,19 @@ public final class ClientData implements Comparable, Serializable { data.degreeQualificationOptions = templateData.degreeQualificationOptions; data.religionOptions = templateData.religionOptions; data.socialCategoryOptions = templateData.socialCategoryOptions; + data.pwdCategoryOptions = templateData.pwdCategoryOptions; data.maritalStatus = clientData.maritalStatus; data.educationLevel = clientData.educationLevel; data.degreeQualification = clientData.degreeQualification; data.religion = clientData.religion; data.socialCategory = clientData.socialCategory; data.monthlyIncome = clientData.monthlyIncome; + data.annualIncome = clientData.annualIncome; data.annualTurnover = clientData.annualTurnover; + data.monthlyTurnover = clientData.monthlyTurnover; + data.isSeniorCitizen = clientData.isSeniorCitizen; + data.isPwd = clientData.isPwd; + data.pwdCategory = clientData.pwdCategory; data.agricultureIncome = clientData.agricultureIncome; data.nonAgricultureIncome = clientData.nonAgricultureIncome; data.ageYears = clientData.ageYears; @@ -456,13 +468,19 @@ public final class ClientData implements Comparable, Serializable { data.degreeQualificationOptions = clientData.degreeQualificationOptions; data.religionOptions = clientData.religionOptions; data.socialCategoryOptions = clientData.socialCategoryOptions; + data.pwdCategoryOptions = clientData.pwdCategoryOptions; data.maritalStatus = clientData.maritalStatus; data.educationLevel = clientData.educationLevel; data.degreeQualification = clientData.degreeQualification; data.religion = clientData.religion; data.socialCategory = clientData.socialCategory; data.monthlyIncome = clientData.monthlyIncome; + data.annualIncome = clientData.annualIncome; data.annualTurnover = clientData.annualTurnover; + data.monthlyTurnover = clientData.monthlyTurnover; + data.isSeniorCitizen = clientData.isSeniorCitizen; + data.isPwd = clientData.isPwd; + data.pwdCategory = clientData.pwdCategory; data.agricultureIncome = clientData.agricultureIncome; data.nonAgricultureIncome = clientData.nonAgricultureIncome; data.ageYears = clientData.ageYears; @@ -509,7 +527,12 @@ public final class ClientData implements Comparable, Serializable { data.religion = clientData.religion; data.socialCategory = clientData.socialCategory; data.monthlyIncome = clientData.monthlyIncome; + data.annualIncome = clientData.annualIncome; data.annualTurnover = clientData.annualTurnover; + data.monthlyTurnover = clientData.monthlyTurnover; + data.isSeniorCitizen = clientData.isSeniorCitizen; + data.isPwd = clientData.isPwd; + data.pwdCategory = clientData.pwdCategory; data.agricultureIncome = clientData.agricultureIncome; data.nonAgricultureIncome = clientData.nonAgricultureIncome; data.ageYears = clientData.ageYears; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/Client.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/Client.java index 8692c6098..a96c6629a 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/Client.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/Client.java @@ -77,6 +77,9 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom { @Column(name = "image_id") private Long imageId; + @Column(name = "signature_id") + private Long signatureId; + @Column(name = "status_enum", nullable = false) private Integer status; @@ -231,9 +234,25 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom { @Column(name = "monthly_income") private java.math.BigDecimal monthlyIncome; + @Column(name = "annual_income") + private java.math.BigDecimal annualIncome; + @Column(name = "annual_turnover") private java.math.BigDecimal annualTurnover; + @Column(name = "monthly_turnover") + private java.math.BigDecimal monthlyTurnover; + + @Column(name = "is_senior_citizen") + private Boolean isSeniorCitizen; + + @Column(name = "is_pwd") + private Boolean isPwd; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "pwd_category_cv_id") + private CodeValue pwdCategory; + @Column(name = "agriculture_income") private java.math.BigDecimal agricultureIncome; @@ -832,6 +851,14 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom { return this.socialCategory != null ? this.socialCategory.getId() : null; } + public Long pwdCategoryId() { + return this.pwdCategory != null ? this.pwdCategory.getId() : null; + } + + public void updatePwdCategory(CodeValue pwdCategory) { + this.pwdCategory = pwdCategory; + } + public void updateSocialCategory(CodeValue socialCategory) { this.socialCategory = socialCategory; } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientForm60.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientForm60.java index 79f952d2f..22f4357f0 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientForm60.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientForm60.java @@ -29,6 +29,7 @@ import lombok.Getter; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** * Form 60 declaration as per Rule 114B of Income Tax Rules, 1962. @@ -87,7 +88,7 @@ public class ClientForm60 extends AbstractAuditableWithUTCDateTimeCustom { form.estimatedTotalIncome = command.bigDecimalValueOfParameterNamed("estimatedTotalIncome"); form.reasonForNoPan = StringUtils.defaultIfEmpty(command.stringValueOfParameterNamed("reasonForNoPan"), null); form.declaration = command.booleanPrimitiveValueOfParameterNamed("declaration"); - form.submittedOn = LocalDate.now(); + form.submittedOn = DateUtils.getLocalDateOfTenant(); return form; } } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java index ff286a6cd..b9cb8cd05 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java @@ -32,6 +32,7 @@ import java.util.Map; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** * Represents a client's KYC (Know Your Customer) record as mandated by the Reserve Bank of India @@ -260,7 +261,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { this.gstinVerified = false; this.cinVerified = false; this.dinVerified = false; - this.initiatedOn = LocalDate.now(); + this.initiatedOn = DateUtils.getLocalDateOfTenant(); } public static ClientKyc initiateKyc(final Client client, final JsonCommand command) { @@ -456,13 +457,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { public void markCkycUploaded(final String ckycNumber, final String batchId) { this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null); this.ckycBatchId = StringUtils.defaultIfEmpty(batchId, null); - this.ckycUploadedOn = LocalDate.now(); + this.ckycUploadedOn = DateUtils.getLocalDateOfTenant(); } /** Called after successful 3rd-party verification of all required documents. */ public void markVerified(final String ckycNumber) { this.kycStatus = ClientKycStatus.VERIFIED.getValue(); - this.verifiedOn = LocalDate.now(); + this.verifiedOn = DateUtils.getLocalDateOfTenant(); this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null); final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory); @@ -474,14 +475,14 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { /** Called when verification fails. */ public void markRejected(final String remarks) { this.kycStatus = ClientKycStatus.REJECTED.getValue(); - this.rejectedOn = LocalDate.now(); + this.rejectedOn = DateUtils.getLocalDateOfTenant(); this.remarks = StringUtils.defaultIfEmpty(remarks, this.remarks); } public void markPanVerified(final String fullName, final String firstName, final String lastName, final String aadhaarSeedingStatus, final String category, final String status) { this.panVerified = true; - this.panVerifiedOn = LocalDate.now(); + this.panVerifiedOn = DateUtils.getLocalDateOfTenant(); this.panFullName = StringUtils.defaultIfEmpty(fullName, null); this.panFirstName = StringUtils.defaultIfEmpty(firstName, null); this.panLastName = StringUtils.defaultIfEmpty(lastName, null); @@ -507,28 +508,28 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { public void markAadhaarVerified() { this.aadhaarVerified = true; - this.aadhaarVerifiedOn = LocalDate.now(); + this.aadhaarVerifiedOn = DateUtils.getLocalDateOfTenant(); } public void markGstinVerified() { this.gstinVerified = true; - this.gstinVerifiedOn = LocalDate.now(); + this.gstinVerifiedOn = DateUtils.getLocalDateOfTenant(); } public void markCinVerified() { this.cinVerified = true; - this.cinVerifiedOn = LocalDate.now(); + this.cinVerifiedOn = DateUtils.getLocalDateOfTenant(); } public void markDinVerified() { this.dinVerified = true; - this.dinVerifiedOn = LocalDate.now(); + this.dinVerifiedOn = DateUtils.getLocalDateOfTenant(); } public void markDinVerified(final String dinNumber, final String mcaResponseJson) { this.dinNumber = dinNumber; this.dinVerified = true; - this.dinVerifiedOn = LocalDate.now(); + this.dinVerifiedOn = DateUtils.getLocalDateOfTenant(); this.isDirector = true; this.dinMcaResponse = mcaResponseJson; } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRepository.java index 47b662553..fee2a4bf1 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRepository.java @@ -55,6 +55,14 @@ public interface ClientRepository extends JpaRepository, JpaSpecif @Query("UPDATE Client client SET client.imageId = null WHERE client.id = :clientId") void removeImageId(@Param("clientId") Long clientId); + @Modifying + @Query("UPDATE Client client SET client.signatureId = :signatureId WHERE client.id = :clientId") + void updateByIdAndSignatureId(@Param("clientId") Long clientId, @Param("signatureId") Long signatureId); + + @Modifying + @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. diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRule.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRule.java new file mode 100644 index 000000000..3c1dce647 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRule.java @@ -0,0 +1,138 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import org.apache.fineract.infrastructure.codes.domain.CodeValue; + +@Entity +@Table(name = "m_client_risk_rule") +public class ClientRiskRule { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "rule_name", nullable = false, length = 100) + private String ruleName; + + @Column(name = "legal_form_id") + private Integer legalFormId; + + @ManyToOne + @JoinColumn(name = "occupation_category_cv_id") + private CodeValue occupationCategory; + + @ManyToOne + @JoinColumn(name = "occupation_cv_id") + private CodeValue occupation; + + @Column(name = "min_monthly_income", precision = 19, scale = 6) + private BigDecimal minMonthlyIncome; + + @Column(name = "max_monthly_income", precision = 19, scale = 6) + private BigDecimal maxMonthlyIncome; + + @Column(name = "min_annual_income", precision = 19, scale = 6) + private BigDecimal minAnnualIncome; + + @Column(name = "max_annual_income", precision = 19, scale = 6) + private BigDecimal maxAnnualIncome; + + @Column(name = "min_annual_turnover", precision = 19, scale = 6) + private BigDecimal minAnnualTurnover; + + @Column(name = "max_annual_turnover", precision = 19, scale = 6) + private BigDecimal maxAnnualTurnover; + + @Column(name = "risk_category", nullable = false) + private Integer riskCategory; + + @Column(name = "reason", nullable = false, length = 255) + private String reason; + + @Column(name = "order_position", nullable = false) + private Integer orderPosition; + + protected ClientRiskRule() {} + + public Long getId() { + return id; + } + + public String getRuleName() { + return ruleName; + } + + public Integer getLegalFormId() { + return legalFormId; + } + + public CodeValue getOccupationCategory() { + return occupationCategory; + } + + public CodeValue getOccupation() { + return occupation; + } + + public BigDecimal getMinMonthlyIncome() { + return minMonthlyIncome; + } + + public BigDecimal getMaxMonthlyIncome() { + return maxMonthlyIncome; + } + + public BigDecimal getMinAnnualIncome() { + return minAnnualIncome; + } + + public BigDecimal getMaxAnnualIncome() { + return maxAnnualIncome; + } + + public BigDecimal getMinAnnualTurnover() { + return minAnnualTurnover; + } + + public BigDecimal getMaxAnnualTurnover() { + return maxAnnualTurnover; + } + + public Integer getRiskCategory() { + return riskCategory; + } + + public String getReason() { + return reason; + } + + public Integer getOrderPosition() { + return orderPosition; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRuleRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRuleRepository.java new file mode 100644 index 000000000..46eb97f48 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRuleRepository.java @@ -0,0 +1,29 @@ +/** + * 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; + +public interface ClientRiskRuleRepository extends JpaRepository { + + @Query("SELECT r FROM ClientRiskRule r ORDER BY r.orderPosition ASC") + List findAllOrderedByOrderPosition(); +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/currentaccount/data/CurrentAccountPendingEditData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/currentaccount/data/CurrentAccountPendingEditData.java new file mode 100644 index 000000000..ebdeb4f43 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/currentaccount/data/CurrentAccountPendingEditData.java @@ -0,0 +1,54 @@ +/** + * 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.currentaccount.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Map; +import lombok.Getter; +import lombok.Setter; + +/** + * Read-side view of a single pending current account Nominee/Digital-Feature edit + * (m_current_account_pending_edit), for the Approvals -> Pending Current Account Edits screen. + */ +@Getter +@Setter +public class CurrentAccountPendingEditData implements Serializable { + + private Long id; + private Long currentAccountId; + private String accountNo; + private Long makerId; + private String makerUsername; + private Long checkerId; + private String checkerUsername; + private LocalDateTime createdOn; + private LocalDateTime checkedOn; + private String status; + private String rejectReason; + private BigDecimal holdAmount; + private boolean facilityConfigSubmitted; + private boolean nomineeSubmitted; + private Map proposedFacilityConfig; + private Map proposedNominee; + + public CurrentAccountPendingEditData() {} +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/mobilelookup/domain/MobileLookupLog.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/mobilelookup/domain/MobileLookupLog.java index 51f01e935..b1a705ae2 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/mobilelookup/domain/MobileLookupLog.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/mobilelookup/domain/MobileLookupLog.java @@ -24,6 +24,7 @@ import jakarta.persistence.Table; import java.time.OffsetDateTime; import lombok.Getter; import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** * Activity log for each DIP / MNRL mobile number lookup request. @@ -78,6 +79,6 @@ public class MobileLookupLog extends AbstractAuditableWithUTCDateTimeCustom findListLastLoadedOn(); } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLog.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLog.java index daf0c8570..b92741d41 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLog.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLog.java @@ -23,6 +23,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.Table; import java.time.OffsetDateTime; import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** * Activity log for each sanctions screening request. @@ -73,7 +74,7 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto this.screeningSource = screeningSource; this.clientId = clientId; this.ipAddress = ipAddress; - this.screenedOn = OffsetDateTime.now(); + this.screenedOn = DateUtils.getOffsetDateTimeOfTenant(); } // ── Accessors ───────────────────────────────────────────────────────────── diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountPendingEditData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountPendingEditData.java new file mode 100644 index 000000000..b9c36d373 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountPendingEditData.java @@ -0,0 +1,55 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Map; +import lombok.Getter; +import lombok.Setter; + +/** + * Read-side view of a single pending savings account Nominee/Digital-Feature edit + * (m_savings_account_pending_edit), for the Approvals -> Pending Savings Account Edits screen. + */ +@Getter +@Setter +public class SavingsAccountPendingEditData implements Serializable { + + private Long id; + private Long savingsAccountId; + private String accountNo; + private Long makerId; + private String makerUsername; + private Long checkerId; + private String checkerUsername; + private LocalDateTime createdOn; + private LocalDateTime checkedOn; + private String status; + private String rejectReason; + private BigDecimal holdAmount; + private boolean facilityConfigSubmitted; + private boolean nomineeSubmitted; + private Map proposedFacilityConfig; + private Map proposedNominee; + private String depositAccountType; + + public SavingsAccountPendingEditData() {} +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/search/data/SearchConditions.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/search/data/SearchConditions.java index 633ebe854..ac917556a 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/search/data/SearchConditions.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/search/data/SearchConditions.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.portfolio.search.data; +import java.util.Locale; import org.apache.fineract.portfolio.search.SearchConstants.SearchSupportedResources; public class SearchConditions { @@ -37,17 +38,17 @@ public class SearchConditions { this.searchResource = searchResource; this.exactMatch = exactMatch; this.clientSearch = null == searchResource - || searchResource.toLowerCase().contains(SearchSupportedResources.CLIENTS.name().toLowerCase()); + || searchResource.toLowerCase(Locale.ROOT).contains(SearchSupportedResources.CLIENTS.name().toLowerCase(Locale.ROOT)); this.groupSearch = null == searchResource - || searchResource.toLowerCase().contains(SearchSupportedResources.GROUPS.name().toLowerCase()); + || searchResource.toLowerCase(Locale.ROOT).contains(SearchSupportedResources.GROUPS.name().toLowerCase(Locale.ROOT)); this.loanSeach = null == searchResource - || searchResource.toLowerCase().contains(SearchSupportedResources.LOANS.name().toLowerCase()); + || searchResource.toLowerCase(Locale.ROOT).contains(SearchSupportedResources.LOANS.name().toLowerCase(Locale.ROOT)); this.savingSeach = null == searchResource - || searchResource.toLowerCase().contains(SearchSupportedResources.SAVINGS.name().toLowerCase()); + || searchResource.toLowerCase(Locale.ROOT).contains(SearchSupportedResources.SAVINGS.name().toLowerCase(Locale.ROOT)); this.shareSeach = null == searchResource - || searchResource.toLowerCase().contains(SearchSupportedResources.SHARES.name().toLowerCase()); + || searchResource.toLowerCase(Locale.ROOT).contains(SearchSupportedResources.SHARES.name().toLowerCase(Locale.ROOT)); this.clientIdentifierSearch = null == searchResource - || searchResource.toLowerCase().contains(SearchSupportedResources.CLIENTIDENTIFIERS.name().toLowerCase()); + || searchResource.toLowerCase(Locale.ROOT).contains(SearchSupportedResources.CLIENTIDENTIFIERS.name().toLowerCase(Locale.ROOT)); } public SearchConditions(final String searchQueryParam, final String searchResource, final Boolean clientSearch, diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/search/service/SearchUtil.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/search/service/SearchUtil.java index 0107308eb..2a4a862ca 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/search/service/SearchUtil.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/search/service/SearchUtil.java @@ -321,6 +321,6 @@ public class SearchUtil { public String camelToSnake(final String camelStr) { return camelStr == null ? null - : camelStr.replaceAll("([A-Z]+)([A-Z][a-z])", "$1_$2").replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase(); + : camelStr.replaceAll("([A-Z]+)([A-Z][a-z])", "$1_$2").replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase(Locale.ROOT); } } diff --git a/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreement.java b/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreement.java new file mode 100644 index 000000000..fe41f9ec6 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreement.java @@ -0,0 +1,92 @@ +/** + * 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.useradministration.agreement.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.useradministration.domain.AppUser; + +/** + * A versioned, admin-manageable "user agreement" (bank policy/warning) that every user must accept right after + * logging in. Only one row may be {@link #active} at a time; enforced transactionally by the write service, not a + * DB constraint. + */ +@Entity +@Table(name = "m_user_agreement", uniqueConstraints = { @UniqueConstraint(columnNames = "version", name = "uq_user_agreement_version") }) +@Getter +@Setter +@NoArgsConstructor +public class UserAgreement extends AbstractPersistableCustom { + + @Column(name = "version", nullable = false) + private Integer version; + + @Column(name = "title", length = 200, nullable = false) + private String title; + + @Column(name = "body", nullable = false) + private String body; + + @Column(name = "is_active", nullable = false) + private boolean active; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "created_by", nullable = false) + private AppUser createdBy; + + @Column(name = "created_on", nullable = false, updatable = false) + private OffsetDateTime createdOn; + + @PrePersist + private void prePersist() { + if (createdOn == null) { + createdOn = OffsetDateTime.now(ZoneOffset.UTC); + } + } + + public static UserAgreement create(final Integer version, final String title, final String body, final AppUser createdBy) { + final UserAgreement agreement = new UserAgreement(); + agreement.version = version; + agreement.title = title; + agreement.body = body; + agreement.createdBy = createdBy; + agreement.active = true; + return agreement; + } + + public void activate() { + this.active = true; + } + + public void deactivate() { + this.active = false; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreementAcceptance.java b/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreementAcceptance.java new file mode 100644 index 000000000..1c0934bb4 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreementAcceptance.java @@ -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.useradministration.agreement.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.useradministration.domain.AppUser; + +/** + * Audit-proof record of a user accepting a given version of the {@link UserAgreement} right after logging in. + * {@link #agreementVersion} is a denormalized snapshot of {@code UserAgreement.version} at acceptance time, so the + * acceptance record survives future version bumps unaffected. + */ +@Entity +@Table(name = "m_user_agreement_acceptance", uniqueConstraints = { + @UniqueConstraint(columnNames = { "appuser_id", "agreement_id" }, name = "uq_user_agreement_acceptance") }) +@Getter +@Setter +@NoArgsConstructor +public class UserAgreementAcceptance extends AbstractPersistableCustom { + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "appuser_id", nullable = false) + private AppUser appUser; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "agreement_id", nullable = false) + private UserAgreement agreement; + + @Column(name = "agreement_version", nullable = false) + private Integer agreementVersion; + + @Column(name = "accepted_on", nullable = false, updatable = false) + private OffsetDateTime acceptedOn; + + @Column(name = "ip_address", length = 45) + private String ipAddress; + + @Column(name = "user_agent", length = 500) + private String userAgent; + + @PrePersist + private void prePersist() { + if (acceptedOn == null) { + acceptedOn = OffsetDateTime.now(ZoneOffset.UTC); + } + } + + public static UserAgreementAcceptance create(final AppUser appUser, final UserAgreement agreement, final String ipAddress, + final String userAgent) { + final UserAgreementAcceptance acceptance = new UserAgreementAcceptance(); + acceptance.appUser = appUser; + acceptance.agreement = agreement; + acceptance.agreementVersion = agreement.getVersion(); + acceptance.ipAddress = ipAddress; + acceptance.userAgent = userAgent; + return acceptance; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreementAcceptanceRepository.java b/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreementAcceptanceRepository.java new file mode 100644 index 000000000..343547d3e --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreementAcceptanceRepository.java @@ -0,0 +1,26 @@ +/** + * 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.useradministration.agreement.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserAgreementAcceptanceRepository extends JpaRepository { + + boolean existsByAppUser_IdAndAgreement_Id(Long appUserId, Long agreementId); +} diff --git a/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreementRepository.java b/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreementRepository.java new file mode 100644 index 000000000..54dc13431 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreementRepository.java @@ -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.useradministration.agreement.domain; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserAgreementRepository extends JpaRepository { + + Optional findByActiveTrue(); + + List findAllByOrderByVersionDesc(); +} diff --git a/fineract-core/src/main/java/org/apache/fineract/useradministration/dsc/domain/BankUserDscRepository.java b/fineract-core/src/main/java/org/apache/fineract/useradministration/dsc/domain/BankUserDscRepository.java index 71be2b92a..24ab5ee40 100644 --- a/fineract-core/src/main/java/org/apache/fineract/useradministration/dsc/domain/BankUserDscRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/useradministration/dsc/domain/BankUserDscRepository.java @@ -39,7 +39,7 @@ public interface BankUserDscRepository extends JpaRepository /** * Login gate: returns true if the user has at least one usable DSC mapping that's also not - * past its notAfter date. The {@code dscExpiryDate >= today} clause prevents 2FA-gating a + * past its notAfter date. The {@code dscExpiryDate >= today} clause prevents 2FA-gating a * user who's enrolled but whose cert silently expired between log-ins — they'd otherwise be * stuck in a verification loop with a cert the helper would sign with but the server would * accept (the verify side filters too, but the gate is what kicks the user into the DSC flow diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountFacilityConfig.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountFacilityConfig.java index 50ba4164f..c91a00cac 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountFacilityConfig.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountFacilityConfig.java @@ -99,4 +99,31 @@ public class CurrentAccountFacilityConfig extends AbstractPersistableCustom { + + Optional findByCurrentAccountId(Long currentAccountId); +} diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountPendingEdit.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountPendingEdit.java new file mode 100644 index 000000000..9631ee749 --- /dev/null +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountPendingEdit.java @@ -0,0 +1,597 @@ +/** + * 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.currentaccount.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.useradministration.domain.AppUser; + +/** + * Holds a pending edit request for an ACTIVE current account's Nominee + Digital & Account Features. + * Presence of a row here with {@code status = PENDING} (enforced via the unique {@link #pendingLockKey}) is itself + * the lock - nothing is applied to the real {@code m_current_account_facility_config} / {@code m_client_nominee} + * rows until a checker approves (see CurrentAccountEditWritePlatformServiceJpaRepositoryImpl). Mirrors + * {@code SavingsAccountPendingEdit} but additionally carries a full point-in-time "wide snapshot" of every business + * column on {@link CurrentAccount} at submission time, for audit/UI display purposes only (not used to drive + * approve/reject logic, which reads the live account). + */ +@Entity +@Table(name = "m_current_account_pending_edit") +@Getter +@Setter +public class CurrentAccountPendingEdit extends AbstractPersistableCustom { + + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_APPROVED = "APPROVED"; + public static final String STATUS_REJECTED = "REJECTED"; + + public static final String NOMINEE_OP_ADD = "ADD"; + public static final String NOMINEE_OP_UPDATE = "UPDATE"; + public static final String NOMINEE_OP_DELETE = "DELETE"; + + @ManyToOne + @JoinColumn(name = "current_account_id", nullable = false) + private CurrentAccount currentAccount; + + @ManyToOne + @JoinColumn(name = "createdby_id", nullable = false) + private AppUser maker; + + @ManyToOne + @JoinColumn(name = "lastmodifiedby_id") + private AppUser checker; + + @Column(name = "created_date", nullable = false) + private LocalDateTime createdOn; + + @Column(name = "lastmodified_date") + private LocalDateTime checkedOn; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @Column(name = "reject_reason", length = 500) + private String rejectReason; + + /** "CURRENT_ACCOUNT_EDIT:" while PENDING, NULL otherwise - the real DB-level lock. */ + @Column(name = "pending_lock_key", length = 40) + private String pendingLockKey; + + /** Links to the audit-only m_portfolio_command_source row created for this edit's submit/approve/reject. */ + @Column(name = "command_source_id") + private Long commandSourceId; + + @Column(name = "hold_transaction_id") + private Long holdTransactionId; + + @Column(name = "hold_amount", precision = 19, scale = 6) + private BigDecimal holdAmount; + + // ── Digital & Account Features (same names as m_current_account_facility_config) ── + @Column(name = "facility_config_submitted", nullable = false) + private boolean facilityConfigSubmitted = false; + + @Column(name = "atm_facility") + private Boolean atmFacility; + + @Column(name = "atm_withdrawal_facility") + private Boolean atmWithdrawalFacility; + + @Column(name = "upi_facility") + private Boolean upiFacility; + + @Column(name = "pos_transactions_facility") + private Boolean posTransactionsFacility; + + @Column(name = "cheque_book") + private Boolean chequeBook; + + @Column(name = "imps_facility") + private Boolean impsFacility; + + @Column(name = "sms_alerts") + private Boolean smsAlerts; + + @Column(name = "bbps_facility") + private Boolean bbpsFacility; + + @Column(name = "insurance_cover") + private Boolean insuranceCover; + + @Column(name = "bepg_facility") + private Boolean bepgFacility; + + @Column(name = "neft_facility") + private Boolean neftFacility; + + @Column(name = "rtgs_facility") + private Boolean rtgsFacility; + + @Column(name = "inter_bank_total_cap_facility") + private Boolean interBankTotalCapFacility; + + @Column(name = "withdrawal_facility") + private Boolean withdrawalFacility; + + @Column(name = "deposit_facility_kyc") + private Boolean depositFacilityKyc; + + @Column(name = "virtual_debit_card") + private Boolean virtualDebitCard; + + @Column(name = "internet_banking") + private Boolean internetBanking; + + @Column(name = "mobile_banking") + private Boolean mobileBanking; + + // ── Nominee change - one per request (add / update / delete) ── + @Column(name = "nominee_submitted", nullable = false) + private boolean nomineeSubmitted = false; + + @Column(name = "nominee_operation", length = 10) + private String nomineeOperation; + + @Column(name = "nominee_id") + private Long nomineeId; + + @Column(name = "nominee_client_id") + private Long nomineeClientId; + + @Column(name = "relationship_cv_id") + private Long relationshipCvId; + + @Column(name = "percentage_share", precision = 5, scale = 2) + private BigDecimal percentageShare; + + @Column(name = "nominee_is_active") + private Boolean nomineeIsActive; + + @Column(name = "nominee_is_minor") + private Boolean nomineeIsMinor; + + @Column(name = "guardian_client_id") + private Long guardianClientId; + + @Column(name = "guardian_relationship_cv_id") + private Long guardianRelationshipCvId; + + @Column(name = "effective_date") + private LocalDate effectiveDate; + + @Column(name = "nominee_remarks", length = 500) + private String nomineeRemarks; + + // ── Wide point-in-time snapshot of every business column on CurrentAccount at submission time ── + // (audit/UI display only - approve()/reject() logic never reads these, it reads the live account) + @Column(name = "account_no", length = 20) + private String snapshotAccountNo; + + @Column(name = "external_id", length = 100) + private String snapshotExternalId; + + @Column(name = "client_id") + private Long snapshotClientId; + + @Column(name = "product_id") + private Long snapshotProductId; + + @Column(name = "office_id") + private Long snapshotOfficeId; + + @Column(name = "staff_id") + private Long snapshotStaffId; + + @Column(name = "status_enum") + private Integer snapshotStatusEnum; + + @Column(name = "sub_status_enum") + private Integer snapshotSubStatusEnum; + + @Column(name = "account_type_enum") + private Integer snapshotAccountTypeEnum; + + @Column(name = "submittedon_date") + private LocalDate snapshotSubmittedOnDate; + + @Column(name = "approvedon_date") + private LocalDate snapshotApprovedOnDate; + + @Column(name = "activatedon_date") + private LocalDate snapshotActivatedOnDate; + + @Column(name = "rejectedon_date") + private LocalDate snapshotRejectedOnDate; + + @Column(name = "withdrawnon_date") + private LocalDate snapshotWithdrawnOnDate; + + @Column(name = "closedon_date") + private LocalDate snapshotClosedOnDate; + + @Column(name = "submittedon_userid") + private Long snapshotSubmittedOnUserId; + + @Column(name = "approvedon_userid") + private Long snapshotApprovedOnUserId; + + @Column(name = "activatedon_userid") + private Long snapshotActivatedOnUserId; + + @Column(name = "rejectedon_userid") + private Long snapshotRejectedOnUserId; + + @Column(name = "withdrawnon_userid") + private Long snapshotWithdrawnOnUserId; + + @Column(name = "closedon_userid") + private Long snapshotClosedOnUserId; + + @Column(name = "currency_code", length = 3) + private String snapshotCurrencyCode; + + @Column(name = "currency_digits") + private Integer snapshotCurrencyDigits; + + @Column(name = "currency_multiplesof") + private Integer snapshotCurrencyMultiplesOf; + + @Column(name = "nominal_annual_interest_rate", precision = 19, scale = 6) + private BigDecimal snapshotNominalAnnualInterestRate; + + @Column(name = "interest_compounding_period_enum") + private Integer snapshotInterestCompoundingPeriodEnum; + + @Column(name = "interest_posting_period_enum") + private Integer snapshotInterestPostingPeriodEnum; + + @Column(name = "interest_calculation_type_enum") + private Integer snapshotInterestCalculationTypeEnum; + + @Column(name = "interest_calculation_days_in_year_type_enum") + private Integer snapshotInterestCalculationDaysInYearTypeEnum; + + @Column(name = "min_balance_for_interest_calculation", precision = 19, scale = 6) + private BigDecimal snapshotMinBalanceForInterestCalculation; + + @Column(name = "start_interest_calculation_date") + private LocalDate snapshotStartInterestCalculationDate; + + @Column(name = "accrued_till_date") + private LocalDate snapshotAccruedTillDate; + + @Column(name = "last_interest_calculation_date") + private LocalDate snapshotLastInterestCalculationDate; + + @Column(name = "last_closed_business_date") + private LocalDate snapshotLastClosedBusinessDate; + + @Column(name = "withhold_tax") + private Boolean snapshotWithholdTax; + + @Column(name = "min_required_opening_balance", precision = 19, scale = 6) + private BigDecimal snapshotMinRequiredOpeningBalance; + + @Column(name = "lockin_period_frequency") + private Integer snapshotLockinPeriodFrequency; + + @Column(name = "lockin_period_frequency_enum") + private Integer snapshotLockinPeriodFrequencyEnum; + + @Column(name = "lockedin_until_date_derived") + private LocalDate snapshotLockedInUntilDate; + + @Column(name = "enforce_min_required_balance") + private Boolean snapshotEnforceMinRequiredBalance; + + @Column(name = "min_required_balance", precision = 19, scale = 6) + private BigDecimal snapshotMinRequiredBalance; + + @Column(name = "allow_overdraft") + private Boolean snapshotAllowOverdraft; + + @Column(name = "overdraft_limit", precision = 19, scale = 6) + private BigDecimal snapshotOverdraftLimit; + + @Column(name = "nominal_annual_interest_rate_overdraft", precision = 19, scale = 6) + private BigDecimal snapshotNominalAnnualInterestRateOverdraft; + + @Column(name = "min_overdraft_for_interest_calculation", precision = 19, scale = 6) + private BigDecimal snapshotMinOverdraftForInterestCalculation; + + @Column(name = "is_lien_allowed") + private Boolean snapshotLienAllowed; + + @Column(name = "max_allowed_lien_limit", precision = 19, scale = 6) + private BigDecimal snapshotMaxAllowedLienLimit; + + @Column(name = "on_hold_funds_derived", precision = 19, scale = 6) + private BigDecimal snapshotOnHoldFundsDerived; + + @Column(name = "account_balance_derived", precision = 19, scale = 6) + private BigDecimal snapshotAccountBalanceDerived; + + @Column(name = "total_deposits_derived", precision = 19, scale = 6) + private BigDecimal snapshotTotalDepositsDerived; + + @Column(name = "total_withdrawals_derived", precision = 19, scale = 6) + private BigDecimal snapshotTotalWithdrawalsDerived; + + @Column(name = "total_interest_earned_derived", precision = 19, scale = 6) + private BigDecimal snapshotTotalInterestEarnedDerived; + + @Column(name = "total_interest_posted_derived", precision = 19, scale = 6) + private BigDecimal snapshotTotalInterestPostedDerived; + + @Column(name = "total_overdraft_interest_derived", precision = 19, scale = 6) + private BigDecimal snapshotTotalOverdraftInterestDerived; + + @Column(name = "total_withhold_tax_derived", precision = 19, scale = 6) + private BigDecimal snapshotTotalWithholdTaxDerived; + + @Column(name = "total_savings_amount_on_hold", precision = 19, scale = 6) + private BigDecimal snapshotTotalSavingsAmountOnHold; + + @Column(name = "withdrawal_fee_for_transfer") + private Boolean snapshotWithdrawalFeeForTransfer; + + @Column(name = "reason_for_block", length = 500) + private String snapshotReasonForBlock; + + @Column(name = "msme") + private Boolean snapshotMsme; + + @Column(name = "auto_debit_facility") + private Boolean snapshotAutoDebitFacility; + + @Column(name = "balance_swap_option") + private Boolean snapshotBalanceSwapOption; + + @Column(name = "establishment_date") + private LocalDate snapshotEstablishmentDate; + + @Column(name = "start_operation_date") + private LocalDate snapshotStartOperationDate; + + @Column(name = "is_non_individual_client") + private Boolean snapshotNonIndividualClient; + + @Column(name = "signatory_rights", length = 100) + private String snapshotSignatoryRights; + + @Column(name = "cbs_account_type", length = 100) + private String snapshotCbsAccountType; + + @Column(name = "customer_segment", length = 100) + private String snapshotCustomerSegment; + + @Column(name = "account_status", length = 50) + private String snapshotAccountStatus; + + @Column(name = "mode_of_operation", length = 50) + private String snapshotModeOfOperation; + + @Column(name = "special_category", length = 100) + private String snapshotSpecialCategory; + + @Column(name = "introducer_client_id") + private Long snapshotIntroducerClientId; + + @Column(name = "introducer_name", length = 200) + private String snapshotIntroducerName; + + @Column(name = "introducer_cif_number", length = 50) + private String snapshotIntroducerCifNumber; + + @Column(name = "nominee_data", columnDefinition = "TEXT") + private String snapshotNomineeData; + + @Column(name = "interest_calculate_selector") + private Integer snapshotInterestCalculateSelector; + + @Column(name = "average_period", length = 20) + private String snapshotAveragePeriod; + + @Column(name = "auto_post") + private Boolean snapshotAutoPost; + + @Column(name = "frequency_fix_day") + private Integer snapshotFrequencyFixDay; + + @Column(name = "enable_lockin_period") + private Boolean snapshotEnableLockinPeriod; + + @Column(name = "balance_frequency", length = 50) + private String snapshotBalanceFrequency; + + @Column(name = "avg_balance_calc_period", length = 10) + private String snapshotAvgBalanceCalcPeriod; + + @Column(name = "account_closed_zero_balance_in_day_closing") + private Boolean snapshotAccountClosedZeroBalanceInDayClosing; + + @Column(name = "valid_for_period", length = 10) + private String snapshotValidForPeriod; + + @Column(name = "od_account_closed_zero_balance_in_day_closing") + private Boolean snapshotOdAccountClosedZeroBalanceInDayClosing; + + @Column(name = "is_linking_enable") + private Boolean snapshotShareLinkingEnabled; + + @Column(name = "terminate_true") + private Boolean snapshotTerminateTrue; + + protected CurrentAccountPendingEdit() {} + + public CurrentAccountPendingEdit(final CurrentAccount currentAccount, final AppUser maker) { + this.currentAccount = currentAccount; + this.maker = maker; + this.createdOn = DateUtils.getLocalDateTimeOfTenant(); + this.status = STATUS_PENDING; + this.pendingLockKey = lockKeyFor(currentAccount.getId()); + } + + public static String lockKeyFor(final Long currentAccountId) { + return "CURRENT_ACCOUNT_EDIT:" + currentAccountId; + } + + /** + * Builds a new pending-edit row and copies every business column off the live {@code account} onto this + * request's wide snapshot fields, for audit/UI display purposes only. + */ + public static CurrentAccountPendingEdit snapshotOf(final CurrentAccount account, final AppUser maker) { + final CurrentAccountPendingEdit p = new CurrentAccountPendingEdit(account, maker); + + p.snapshotAccountNo = account.getAccountNo(); + p.snapshotExternalId = account.getExternalId(); + p.snapshotClientId = account.getClient() != null ? account.getClient().getId() : null; + p.snapshotProductId = account.getProductId(); + p.snapshotOfficeId = account.getOffice() != null ? account.getOffice().getId() : null; + p.snapshotStaffId = account.getStaff() != null ? account.getStaff().getId() : null; + p.snapshotStatusEnum = account.getStatusEnum(); + p.snapshotSubStatusEnum = account.getSubStatusEnum(); + p.snapshotAccountTypeEnum = account.getAccountTypeEnum(); + + p.snapshotSubmittedOnDate = account.getSubmittedOnDate(); + p.snapshotApprovedOnDate = account.getApprovedOnDate(); + p.snapshotActivatedOnDate = account.getActivatedOnDate(); + p.snapshotRejectedOnDate = account.getRejectedOnDate(); + p.snapshotWithdrawnOnDate = account.getWithdrawnOnDate(); + p.snapshotClosedOnDate = account.getClosedOnDate(); + + p.snapshotSubmittedOnUserId = account.getSubmittedOnByUser() != null ? account.getSubmittedOnByUser().getId() : null; + p.snapshotApprovedOnUserId = account.getApprovedOnByUser() != null ? account.getApprovedOnByUser().getId() : null; + p.snapshotActivatedOnUserId = account.getActivatedOnByUser() != null ? account.getActivatedOnByUser().getId() : null; + p.snapshotRejectedOnUserId = account.getRejectedOnByUser() != null ? account.getRejectedOnByUser().getId() : null; + p.snapshotWithdrawnOnUserId = account.getWithdrawnOnByUser() != null ? account.getWithdrawnOnByUser().getId() : null; + p.snapshotClosedOnUserId = account.getClosedOnByUser() != null ? account.getClosedOnByUser().getId() : null; + + p.snapshotCurrencyCode = account.getCurrencyCode(); + p.snapshotCurrencyDigits = account.getCurrencyDigits(); + p.snapshotCurrencyMultiplesOf = account.getCurrencyMultiplesOf(); + + p.snapshotNominalAnnualInterestRate = account.getNominalAnnualInterestRate(); + p.snapshotInterestCompoundingPeriodEnum = account.getInterestCompoundingPeriodEnum(); + p.snapshotInterestPostingPeriodEnum = account.getInterestPostingPeriodEnum(); + p.snapshotInterestCalculationTypeEnum = account.getInterestCalculationTypeEnum(); + p.snapshotInterestCalculationDaysInYearTypeEnum = account.getInterestCalculationDaysInYearTypeEnum(); + p.snapshotMinBalanceForInterestCalculation = account.getMinBalanceForInterestCalculation(); + p.snapshotStartInterestCalculationDate = account.getStartInterestCalculationDate(); + p.snapshotAccruedTillDate = account.getAccruedTillDate(); + p.snapshotLastInterestCalculationDate = account.getLastInterestCalculationDate(); + p.snapshotLastClosedBusinessDate = account.getLastClosedBusinessDate(); + + p.snapshotWithholdTax = account.isWithholdTax(); + + p.snapshotMinRequiredOpeningBalance = account.getMinRequiredOpeningBalance(); + p.snapshotLockinPeriodFrequency = account.getLockinPeriodFrequency(); + p.snapshotLockinPeriodFrequencyEnum = account.getLockinPeriodFrequencyEnum(); + p.snapshotLockedInUntilDate = account.getLockedInUntilDate(); + p.snapshotEnforceMinRequiredBalance = account.isEnforceMinRequiredBalance(); + p.snapshotMinRequiredBalance = account.getMinRequiredBalance(); + + p.snapshotAllowOverdraft = account.isAllowOverdraft(); + p.snapshotOverdraftLimit = account.getOverdraftLimit(); + p.snapshotNominalAnnualInterestRateOverdraft = account.getNominalAnnualInterestRateOverdraft(); + p.snapshotMinOverdraftForInterestCalculation = account.getMinOverdraftForInterestCalculation(); + + p.snapshotLienAllowed = account.isLienAllowed(); + p.snapshotMaxAllowedLienLimit = account.getMaxAllowedLienLimit(); + p.snapshotOnHoldFundsDerived = account.getOnHoldFundsDerived(); + + p.snapshotAccountBalanceDerived = account.getAccountBalanceDerived(); + p.snapshotTotalDepositsDerived = account.getTotalDepositsDerived(); + p.snapshotTotalWithdrawalsDerived = account.getTotalWithdrawalsDerived(); + p.snapshotTotalInterestEarnedDerived = account.getTotalInterestEarnedDerived(); + p.snapshotTotalInterestPostedDerived = account.getTotalInterestPostedDerived(); + p.snapshotTotalOverdraftInterestDerived = account.getTotalOverdraftInterestDerived(); + p.snapshotTotalWithholdTaxDerived = account.getTotalWithholdTaxDerived(); + p.snapshotTotalSavingsAmountOnHold = account.getTotalSavingsAmountOnHold(); + + p.snapshotWithdrawalFeeForTransfer = account.isWithdrawalFeeForTransfer(); + p.snapshotReasonForBlock = account.getReasonForBlock(); + + p.snapshotMsme = account.isMsme(); + p.snapshotAutoDebitFacility = account.isAutoDebitFacility(); + p.snapshotBalanceSwapOption = account.isBalanceSwapOption(); + p.snapshotEstablishmentDate = account.getEstablishmentDate(); + p.snapshotStartOperationDate = account.getStartOperationDate(); + p.snapshotNonIndividualClient = account.isNonIndividualClient(); + p.snapshotSignatoryRights = account.getSignatoryRights(); + + p.snapshotCbsAccountType = account.getCbsAccountType(); + p.snapshotCustomerSegment = account.getCustomerSegment(); + p.snapshotAccountStatus = account.getAccountStatus(); + p.snapshotModeOfOperation = account.getModeOfOperation(); + p.snapshotSpecialCategory = account.getSpecialCategory(); + + p.snapshotIntroducerClientId = account.getIntroducerClientId(); + p.snapshotIntroducerName = account.getIntroducerName(); + p.snapshotIntroducerCifNumber = account.getIntroducerCifNumber(); + + p.snapshotNomineeData = account.getNomineeData(); + + p.snapshotInterestCalculateSelector = account.getInterestCalculateSelector(); + p.snapshotAveragePeriod = account.getAveragePeriod(); + p.snapshotAutoPost = account.isAutoPost(); + p.snapshotFrequencyFixDay = account.getFrequencyFixDay(); + p.snapshotEnableLockinPeriod = account.isEnableLockinPeriod(); + p.snapshotBalanceFrequency = account.getBalanceFrequency(); + p.snapshotAvgBalanceCalcPeriod = account.getAvgBalanceCalcPeriod(); + + p.snapshotAccountClosedZeroBalanceInDayClosing = account.getAccountClosedZeroBalanceInDayClosing(); + p.snapshotValidForPeriod = account.getValidForPeriod(); + p.snapshotOdAccountClosedZeroBalanceInDayClosing = account.isOdAccountClosedZeroBalanceInDayClosing(); + + p.snapshotShareLinkingEnabled = account.isShareLinkingEnabled(); + p.snapshotTerminateTrue = account.isTerminateTrue(); + + return p; + } + + public boolean isPending() { + return STATUS_PENDING.equals(this.status); + } + + public void markApproved(final AppUser checker) { + this.status = STATUS_APPROVED; + this.checker = checker; + this.checkedOn = DateUtils.getLocalDateTimeOfTenant(); + this.pendingLockKey = null; + } + + public void markRejected(final AppUser checker, final String reason) { + this.status = STATUS_REJECTED; + this.checker = checker; + this.checkedOn = DateUtils.getLocalDateTimeOfTenant(); + this.rejectReason = reason; + this.pendingLockKey = null; + } +} diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountPendingEditRepository.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountPendingEditRepository.java new file mode 100644 index 000000000..a1756d3f8 --- /dev/null +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountPendingEditRepository.java @@ -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.currentaccount.domain; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CurrentAccountPendingEditRepository extends JpaRepository { + + Optional findByCurrentAccountIdAndStatus(Long currentAccountId, String status); + + List findByStatus(String status); +} diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountEditInsufficientBalanceException.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountEditInsufficientBalanceException.java new file mode 100644 index 000000000..53a08df7b --- /dev/null +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountEditInsufficientBalanceException.java @@ -0,0 +1,37 @@ +/** + * 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.currentaccount.exception; + +import java.math.BigDecimal; +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +/** + * Thrown at edit-submission time when the account's available balance cannot cover the charge for + * the digital/account feature(s) the maker is requesting to enable. + */ +public class CurrentAccountEditInsufficientBalanceException extends AbstractPlatformDomainRuleException { + + public CurrentAccountEditInsufficientBalanceException(final Long currentAccountId, final BigDecimal availableBalance, + final BigDecimal requiredAmount) { + super("error.msg.currentaccount.edit.insufficient.balance", + "Account balance is insufficient for the requested feature(s). Available balance: " + availableBalance + + ", required: " + requiredAmount, + currentAccountId, availableBalance, requiredAmount); + } +} diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountEditPendingApprovalException.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountEditPendingApprovalException.java new file mode 100644 index 000000000..eae3edd73 --- /dev/null +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountEditPendingApprovalException.java @@ -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.currentaccount.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class CurrentAccountEditPendingApprovalException extends AbstractPlatformDomainRuleException { + + public CurrentAccountEditPendingApprovalException(final Long currentAccountId) { + super("error.msg.currentaccount.edit.pending.approval.exists", + "Current account with identifier " + currentAccountId + " already has a Nominee/Digital-Feature edit pending approval", + currentAccountId); + } +} diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountEditSelfApprovalNotAllowedException.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountEditSelfApprovalNotAllowedException.java new file mode 100644 index 000000000..0b6d06053 --- /dev/null +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountEditSelfApprovalNotAllowedException.java @@ -0,0 +1,31 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.currentaccount.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class CurrentAccountEditSelfApprovalNotAllowedException extends AbstractPlatformDomainRuleException { + + public CurrentAccountEditSelfApprovalNotAllowedException(final Long currentAccountId) { + super("error.msg.currentaccount.edit.self.approval.not.allowed", + "The maker of the pending Nominee/Digital-Feature edit for current account " + currentAccountId + + " cannot also approve or reject it", + currentAccountId); + } +} diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountNoPendingEditException.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountNoPendingEditException.java new file mode 100644 index 000000000..9e342d9be --- /dev/null +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountNoPendingEditException.java @@ -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.currentaccount.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class CurrentAccountNoPendingEditException extends AbstractPlatformDomainRuleException { + + public CurrentAccountNoPendingEditException(final Long currentAccountId) { + super("error.msg.currentaccount.no.pending.edit", + "Current account with identifier " + currentAccountId + " has no Nominee/Digital-Feature edit awaiting approval", + currentAccountId); + } +} diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountEditWritePlatformService.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountEditWritePlatformService.java new file mode 100644 index 000000000..281377a01 --- /dev/null +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountEditWritePlatformService.java @@ -0,0 +1,37 @@ +/** + * 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.currentaccount.service; + +import java.util.Collection; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.currentaccount.data.CurrentAccountPendingEditData; + +public interface CurrentAccountEditWritePlatformService { + + CommandProcessingResult submitEditForApproval(Long currentAccountId, JsonCommand command); + + CommandProcessingResult approveEdit(Long currentAccountId); + + CommandProcessingResult rejectEdit(Long currentAccountId, String rejectReason); + + CurrentAccountPendingEditData retrievePendingEdit(Long currentAccountId); + + Collection retrieveAllPendingEdits(); +} diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountNumberGenerator.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountNumberGenerator.java index ca585310b..df8be57cd 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountNumberGenerator.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountNumberGenerator.java @@ -51,7 +51,9 @@ public class CurrentAccountNumberGenerator { if (val > maxVal) { maxVal = val; } - } catch (NumberFormatException ignored) {} + } catch (NumberFormatException ignored) { + // non-numeric suffix - skip when computing the max existing sequence + } } } diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountWritePlatformService.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountWritePlatformService.java index 2556aaf46..e8364e913 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountWritePlatformService.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountWritePlatformService.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.portfolio.currentaccount.service; +import java.util.Set; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; @@ -42,4 +43,13 @@ public interface CurrentAccountWritePlatformService { CommandProcessingResult deleteApplication(Long accountId); CommandProcessingResult terminateAccount(Long accountId, JsonCommand command); + + /** + * Resolves each of the given (newly-enabled) facility types to its mapped charge via + * {@code m_facility_charge_mapping}, creates a {@code CurrentAccountCharge} for it (skipping any facility type + * for which the account already has a paid, non-waived charge for the same mapped {@code Charge}), collects it + * from the account's running balance as of today, and posts the corresponding GL journal entries. Reuses the + * same private charge-creation-and-collection helper that {@code activate()} uses for activation-time charges. + */ + void applyAndCollectFacilityCharges(Long currentAccountId, Set newlyEnabledFacilityTypes); } diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/data/CurrentProductDataValidator.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/data/CurrentProductDataValidator.java index d91ff0219..8d09d6b64 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/data/CurrentProductDataValidator.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/data/CurrentProductDataValidator.java @@ -54,7 +54,6 @@ import com.google.gson.JsonElement; import com.google.gson.reflect.TypeToken; import java.lang.reflect.Type; import java.math.BigDecimal; -import java.time.MonthDay; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; @@ -76,7 +75,6 @@ import org.apache.fineract.portfolio.savings.DepositAccountType; import org.apache.fineract.portfolio.savings.SavingsApiConstants; import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType; import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType; -import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType; import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType; import org.apache.fineract.portfolio.currentproduct.domain.CurrentProduct; import org.apache.fineract.portfolio.savings.data.SavingsProductAccountingDataValidator; diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProduct.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProduct.java index 69d33752b..947885750 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProduct.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProduct.java @@ -56,18 +56,11 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountT import static org.apache.fineract.portfolio.savings.SavingsApiConstants.customerSegmentParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.productCodeParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.glAccountIdParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.effectiveDateParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.effectiveToParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.endDateParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.introducerRequiredParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shareLinkingEnabledParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominationFacilityParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.dormancyAlertParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.interestCalculateSelectorParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.averagePeriodParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoPostParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.frequencyFixDayParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.lastInterestDateParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.enableLockinPeriodParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.balanceSwapOptionParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.msmeParamName; diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductDepositLimitKyc.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductDepositLimitKyc.java index ae7dc9ab8..279fa49e2 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductDepositLimitKyc.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductDepositLimitKyc.java @@ -26,6 +26,7 @@ import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import java.time.LocalDateTime; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Entity @Table(name = "m_current_product_deposit_limit_kyc") @@ -57,12 +58,12 @@ public class CurrentProductDepositLimitKyc extends AbstractPersistableCustom private LocalDateTime createdDate; protected CurrentProductFacilityLimit() { - this.createdDate = LocalDateTime.now(); } private CurrentProductFacilityLimit(CurrentProduct currentProduct, String limitType, @@ -62,6 +62,7 @@ public class CurrentProductFacilityLimit extends AbstractPersistableCustom this.generalLimit = generalLimit; this.minorAccountLimit = minorAccountLimit; this.smallSavingLimit = smallSavingLimit; + this.createdDate = DateUtils.getLocalDateTimeOfTenant(); } public static CurrentProductFacilityLimit create(CurrentProduct currentProduct, String limitType, diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductReadPlatformServiceImpl.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductReadPlatformServiceImpl.java index 08aca6829..e3625015b 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductReadPlatformServiceImpl.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductReadPlatformServiceImpl.java @@ -19,13 +19,11 @@ package org.apache.fineract.portfolio.currentproduct.service; import com.google.gson.Gson; -import com.google.gson.JsonObject; import com.google.gson.reflect.TypeToken; import java.lang.reflect.Type; import java.math.BigDecimal; import java.sql.ResultSet; import java.sql.SQLException; -import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; diff --git a/fineract-document/src/main/java/org/apache/fineract/infrastructure/contentstore/service/FileContentStoreService.java b/fineract-document/src/main/java/org/apache/fineract/infrastructure/contentstore/service/FileContentStoreService.java index 1f293764b..5fa86b242 100644 --- a/fineract-document/src/main/java/org/apache/fineract/infrastructure/contentstore/service/FileContentStoreService.java +++ b/fineract-document/src/main/java/org/apache/fineract/infrastructure/contentstore/service/FileContentStoreService.java @@ -95,23 +95,24 @@ public class FileContentStoreService implements ContentStoreService { final var target = Files.exists(getPath(safePath, false)) ? getPath(safePath, false) : getPath(safePath, true); - try (var in = is) { - IOUtils.copy(in, new FileOutputStream(target.toString())); + try (var in = is; var out = new FileOutputStream(target.toString())) { + IOUtils.copy(in, out); } catch (Exception e) { throw new ContentStoreException(e); } final var relativePath = getRelativePath(target); - try { - postUploadContentPolicy - .check(ContentPolicyContext.builder().path(path).inputStream(getInputStream(target)).mimeType(mimeType).build()); + try (var in = getInputStream(target)) { + postUploadContentPolicy.check(ContentPolicyContext.builder().path(path).inputStream(in).mimeType(mimeType).build()); } catch (ContentPolicyException e) { log.warn("Delete file because it didn't comply with the post-upload policy check: {} - {}", target, e.getMessage()); delete(relativePath.toString()); throw e; + } catch (IOException e) { + throw new ContentStoreException(e); } return relativePath.toString(); diff --git a/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/service/DocumentWritePlatformServiceImpl.java b/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/service/DocumentWritePlatformServiceImpl.java index 576781d6c..8775f4acd 100644 --- a/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/service/DocumentWritePlatformServiceImpl.java +++ b/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/service/DocumentWritePlatformServiceImpl.java @@ -21,6 +21,7 @@ package org.apache.fineract.infrastructure.documentmanagement.service; import static java.util.Objects.requireNonNull; import jakarta.validation.Valid; +import java.util.List; import java.util.Objects; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -31,6 +32,7 @@ import org.apache.commons.lang3.Strings; import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService; import org.apache.fineract.infrastructure.core.config.FineractProperties; import org.apache.fineract.infrastructure.core.exception.ErrorHandler; +import org.apache.fineract.infrastructure.documentmanagement.adapter.EntitySignatureIdAdapter; import org.apache.fineract.infrastructure.documentmanagement.data.DocumentCreateRequest; import org.apache.fineract.infrastructure.documentmanagement.data.DocumentCreateResponse; import org.apache.fineract.infrastructure.documentmanagement.data.DocumentDeleteRequest; @@ -59,6 +61,7 @@ public class DocumentWritePlatformServiceImpl implements DocumentWritePlatformSe private final ContentStoreService storeService; private final BusinessEventNotifierService businessEventNotifierService; private final FineractProperties properties; + private final List signatureIdAdapters; @Transactional @Override @@ -74,13 +77,30 @@ public class DocumentWritePlatformServiceImpl implements DocumentWritePlatformSe path = storeService.upload(path, request.getStream(), request.getType()); - final var doc = new Document().setParentEntityType(request.getEntityType()).setParentEntityId(request.getEntityId()) - .setName(Optional.ofNullable(request.getName()).orElse(request.getFileName())).setFileName(request.getFileName()) - .setSize(request.getSize()).setType(request.getType()).setDescription(request.getDescription()).setLocation(path) - .setStorageType(storeService.getType().getValue()); + final var docName = Optional.ofNullable(request.getName()).orElse(request.getFileName()); + + // Some document "slots" (e.g. a client's signature) are single-slot, mirroring + // m_client.image_id: re-upload should replace the existing row in place rather than + // leave the old one behind as an orphan whose ordering readers then disagree about. + // Adapters recognize their own (entityType, name) convention and own the pointer. + final var signatureAdapter = signatureIdAdapters.stream() + .filter(adapter -> adapter.accept(request.getEntityType(), docName)).findFirst(); + + final var doc = signatureAdapter.flatMap(adapter -> adapter.get(request.getEntityId())) + .flatMap(result -> documentRepository.findById(result.getId())).map(existing -> { + // replacing an existing single-slot document: drop the old file, reuse the row + storeService.delete(existing.getLocation()); + return existing; + }).orElseGet(Document::new); + + doc.setParentEntityType(request.getEntityType()).setParentEntityId(request.getEntityId()).setName(docName) + .setFileName(request.getFileName()).setSize(request.getSize()).setType(request.getType()) + .setDescription(request.getDescription()).setLocation(path).setStorageType(storeService.getType().getValue()); documentRepository.save(doc); + signatureAdapter.ifPresent(adapter -> adapter.set(request.getEntityId(), doc.getId())); + businessEventNotifierService.notifyPostBusinessEvent(new DocumentCreatedBusinessEvent(documentMapper.map(doc))); return DocumentCreateResponse.builder().resourceId(doc.getId()).resourceIdentifier(request.getEntityType()).build(); diff --git a/fineract-provider/logs/bootrun.out b/fineract-provider/logs/bootrun.out deleted file mode 100644 index aa8c18ef6..000000000 --- a/fineract-provider/logs/bootrun.out +++ /dev/null @@ -1,975 +0,0 @@ -> Task :buildSrc:extractPluginRequests UP-TO-DATE -> Task :buildSrc:generatePluginAdapters UP-TO-DATE -> Task :buildSrc:compileJava UP-TO-DATE -> Task :buildSrc:compileGroovy UP-TO-DATE -> Task :buildSrc:compileGroovyPlugins UP-TO-DATE -> Task :buildSrc:pluginDescriptors UP-TO-DATE -> Task :buildSrc:processResources UP-TO-DATE -> Task :buildSrc:classes UP-TO-DATE -> Task :buildSrc:jar UP-TO-DATE - -> Configure project : -matching ref: COMMIT - 7dea88d30f72365945bed2160d2ba72dccdc80b7 - ref configuration: COMMIT - pattern: null - version: ${describe.tag.version.major}.${describe.tag.version.minor.next}.0-SNAPSHOT - describeTagPattern: .*(\d+\.\d+\.\d+).* - describeTagFirstParent: false - -project version: 1.15.0-SNAPSHOT - -> Task :spotlessInternalRegisterDependencies UP-TO-DATE -> Task :fineract-current-product:processResources NO-SOURCE -> Task :fineract-mix:processResources NO-SOURCE -> Task :fineract-rates:processResources UP-TO-DATE -> Task :fineract-core:processResources UP-TO-DATE -> Task :fineract-cob:processResources UP-TO-DATE -> Task :fineract-branch:processResources UP-TO-DATE -> Task :fineract-security:processResources NO-SOURCE -> Task :fineract-loan-origination:processResources UP-TO-DATE -> Task :fineract-savings:processResources UP-TO-DATE -> Task :fineract-accounting:processResources UP-TO-DATE -> Task :fineract-report:processResources UP-TO-DATE -> Task :fineract-charge:processResources UP-TO-DATE -> Task :fineract-investor:processResources UP-TO-DATE -> Task :fineract-loan:processResources UP-TO-DATE -> Task :fineract-client:processResources UP-TO-DATE -> Task :fineract-progressive-loan:processResources UP-TO-DATE -> Task :fineract-working-capital-loan:processResources UP-TO-DATE -> Task :fineract-document:processResources NO-SOURCE -> Task :fineract-tax:processResources UP-TO-DATE -> Task :fineract-avro-schemas:preprocessAvroSchemas UP-TO-DATE -> Task :fineract-validation:compileJava UP-TO-DATE -> Task :fineract-command:compileJava UP-TO-DATE -> Task :fineract-validation:processResources UP-TO-DATE -> Task :fineract-validation:classes UP-TO-DATE -> Task :fineract-command:processResources UP-TO-DATE -> Task :fineract-command:classes UP-TO-DATE -> Task :fineract-command:jar UP-TO-DATE -> Task :fineract-validation:jar UP-TO-DATE -> Task :fineract-avro-schemas:spotlessJson UP-TO-DATE -> Task :fineract-avro-schemas:spotlessJsonApply UP-TO-DATE -> Task :fineract-avro-schemas:buildJavaSdk UP-TO-DATE -> Task :fineract-avro-schemas:compileJava UP-TO-DATE -> Task :fineract-avro-schemas:processResources UP-TO-DATE -> Task :fineract-avro-schemas:classes UP-TO-DATE -> Task :fineract-avro-schemas:jar UP-TO-DATE -> Task :fineract-working-capital-loan:compileJava UP-TO-DATE -> Task :fineract-working-capital-loan:classes UP-TO-DATE -> Task :fineract-working-capital-loan:jar UP-TO-DATE -> Task :fineract-core:compileJava UP-TO-DATE -> Task :fineract-core:classes UP-TO-DATE -> Task :fineract-core:jar UP-TO-DATE -> Task :fineract-mix:compileJava UP-TO-DATE -> Task :fineract-tax:compileJava UP-TO-DATE -> Task :fineract-mix:classes UP-TO-DATE -> Task :fineract-report:compileJava UP-TO-DATE -> Task :fineract-tax:classes UP-TO-DATE -> Task :fineract-mix:jar UP-TO-DATE -> Task :fineract-rates:compileJava UP-TO-DATE -> Task :fineract-report:classes UP-TO-DATE -> Task :fineract-cob:compileJava UP-TO-DATE -> Task :fineract-rates:classes UP-TO-DATE -> Task :fineract-cob:classes UP-TO-DATE -> Task :fineract-tax:jar UP-TO-DATE -> Task :fineract-report:jar UP-TO-DATE -> Task :fineract-cob:jar UP-TO-DATE -> Task :fineract-rates:jar UP-TO-DATE -> Task :fineract-document:compileJava UP-TO-DATE -> Task :fineract-document:classes UP-TO-DATE -> Task :fineract-security:compileJava UP-TO-DATE -> Task :fineract-document:jar UP-TO-DATE -> Task :fineract-security:classes UP-TO-DATE -> Task :fineract-security:jar UP-TO-DATE -> Task :fineract-charge:compileJava UP-TO-DATE -> Task :fineract-charge:classes UP-TO-DATE -> Task :fineract-charge:jar UP-TO-DATE -> Task :fineract-accounting:compileJava UP-TO-DATE -> Task :fineract-accounting:classes UP-TO-DATE -> Task :fineract-accounting:jar UP-TO-DATE -> Task :fineract-branch:compileJava UP-TO-DATE -> Task :fineract-branch:classes UP-TO-DATE -> Task :fineract-loan:compileJava UP-TO-DATE -> Task :fineract-loan:classes UP-TO-DATE -> Task :fineract-branch:jar UP-TO-DATE -> Task :fineract-loan:jar UP-TO-DATE -> Task :fineract-savings:compileJava UP-TO-DATE -> Task :fineract-savings:classes UP-TO-DATE -> Task :fineract-savings:jar UP-TO-DATE -> Task :fineract-loan-origination:compileJava UP-TO-DATE -> Task :fineract-investor:compileJava UP-TO-DATE -> Task :fineract-progressive-loan:compileJava UP-TO-DATE -> Task :fineract-loan-origination:classes UP-TO-DATE -> Task :fineract-progressive-loan:classes UP-TO-DATE -> Task :fineract-investor:classes UP-TO-DATE -> Task :fineract-progressive-loan:jar UP-TO-DATE -> Task :fineract-loan-origination:jar UP-TO-DATE -> Task :fineract-investor:jar UP-TO-DATE -> Task :fineract-current-product:compileJava UP-TO-DATE -> Task :fineract-current-product:classes UP-TO-DATE -> Task :fineract-current-product:jar UP-TO-DATE -> Task :fineract-provider:generateGitProperties -> Task :fineract-provider:compileJava UP-TO-DATE -> Task :fineract-provider:generateRebelMain UP-TO-DATE -> Task :fineract-provider:processResources -> Task :fineract-provider:classes -> Task :fineract-provider:compileTestJava UP-TO-DATE -> Task :fineract-provider:prepareInputYaml UP-TO-DATE -> Task :fineract-provider:processTestResources UP-TO-DATE -> Task :fineract-provider:testClasses UP-TO-DATE -> Task :fineract-provider:resolve -> Task :fineract-provider:resolveMainClassName - -> Task :fineract-provider:bootRun -19:17:05,132 |-WARN in ch.qos.logback.core.joran.action.ConversionRuleAction - [converterClass] attribute is deprecated and replaced by [class]. See element [conversionRule] near line 30 - - _ _ _____ _ _ - / \ _ __ __ _ ___| |__ ___ | ___(_)_ __ ___ _ __ __ _ ___| |_ - / _ \ | '_ \ / _` |/ __| '_ \ / _ \ | |_ | | '_ \ / _ \ '__/ _` |/ __| __| - / ___ \| |_) | (_| | (__| | | | __/ | _| | | | | | __/ | | (_| | (__| |_ -/_/ \_\ .__/ \__,_|\___|_| |_|\___| |_| |_|_| |_|\___|_| \__,_|\___|\__| - |_| - -(c) 2015-2024 Apache Fineract (https://fineract.apache.org) - -Powered by Spring Boot 3.5.6 - - -2026-05-29 19:17:05.618 - INFO 7760  [ no-tenant] --- [ main] org.apache.fineract.ServerApplication  : Starting ServerApplication using Java 21.0.9 with PID 7760 (C:\Users\adity\Music\CBS\fineract\fineract-provider\build\classes\java\main started by adity in C:\Users\adity\Music\CBS\fineract\fineract-provider) -2026-05-29 19:17:05.622 - INFO 7760  [ no-tenant] --- [ main] org.apache.fineract.ServerApplication  : No active profile set, falling back to 1 default profile: "default" -2026-05-29 19:17:10.836 - INFO 7760  [ no-tenant] --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Multiple Spring Data modules found, entering strict repository configuration mode -2026-05-29 19:17:10.836 - INFO 7760  [ no-tenant] --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode. -2026-05-29 19:17:11.794 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JPA - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.documentmanagement.domain.DocumentRepository; If you want this repository to be a JPA repository, consider annotating your entities with one of these annotations: jakarta.persistence.Entity, jakarta.persistence.MappedSuperclass (preferred), or consider extending one of the following types with your repository: org.springframework.data.jpa.repository.JpaRepository -2026-05-29 19:17:11.797 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JPA - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.documentmanagement.domain.ImageRepository; If you want this repository to be a JPA repository, consider annotating your entities with one of these annotations: jakarta.persistence.Entity, jakarta.persistence.MappedSuperclass (preferred), or consider extending one of the following types with your repository: org.springframework.data.jpa.repository.JpaRepository -2026-05-29 19:17:11.811 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JPA - Could not safely identify store assignment for repository candidate interface org.apache.fineract.mix.domain.MixReportXBRLNamespaceRepository; If you want this repository to be a JPA repository, consider annotating your entities with one of these annotations: jakarta.persistence.Entity, jakarta.persistence.MappedSuperclass (preferred), or consider extending one of the following types with your repository: org.springframework.data.jpa.repository.JpaRepository -2026-05-29 19:17:11.813 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JPA - Could not safely identify store assignment for repository candidate interface org.apache.fineract.mix.domain.MixTaxonomyMappingRepository; If you want this repository to be a JPA repository, consider annotating your entities with one of these annotations: jakarta.persistence.Entity, jakarta.persistence.MappedSuperclass (preferred), or consider extending one of the following types with your repository: org.springframework.data.jpa.repository.JpaRepository -2026-05-29 19:17:11.815 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JPA - Could not safely identify store assignment for repository candidate interface org.apache.fineract.mix.domain.MixTaxonomyRepository; If you want this repository to be a JPA repository, consider annotating your entities with one of these annotations: jakarta.persistence.Entity, jakarta.persistence.MappedSuperclass (preferred), or consider extending one of the following types with your repository: org.springframework.data.jpa.repository.JpaRepository -2026-05-29 19:17:11.887 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JPA - Could not safely identify store assignment for repository candidate interface org.apache.fineract.command.persistence.domain.CommandRepository; If you want this repository to be a JPA repository, consider annotating your entities with one of these annotations: jakarta.persistence.Entity, jakarta.persistence.MappedSuperclass (preferred), or consider extending one of the following types with your repository: org.springframework.data.jpa.repository.JpaRepository -2026-05-29 19:17:13.120 - INFO 7760  [ no-tenant] --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 2267 ms. Found 224 JPA repository interfaces. -2026-05-29 19:17:13.130 - INFO 7760  [ no-tenant] --- [ main] o.s.b.f.s.DefaultListableBeanFactory  : Overriding bean definition for bean 'jpaAuditingHandler' with a different definition: replacing [Root bean: class=org.springframework.data.auditing.AuditingHandler; scope=; abstract=false; lazyInit=null; autowireMode=2; dependencyCheck=0; autowireCandidate=true; primary=false; fallback=false; factoryBeanName=null; factoryMethodName=from; initMethodNames=null; destroyMethodNames=null] with [Root bean: class=org.apache.fineract.infrastructure.core.auditing.CustomAuditingHandler; scope=prototype; abstract=false; lazyInit=null; autowireMode=0; dependencyCheck=0; autowireCandidate=true; primary=false; fallback=false; factoryBeanName=null; factoryMethodName=null; initMethodNames=null; destroyMethodNames=null] -2026-05-29 19:17:13.204 - INFO 7760  [ no-tenant] --- [ main] o.s.b.f.s.DefaultListableBeanFactory  : Overriding bean definition for bean 'transactionManager' with a different definition: replacing [Root bean: class=null; scope=; abstract=false; lazyInit=null; autowireMode=3; dependencyCheck=0; autowireCandidate=true; primary=false; fallback=false; factoryBeanName=JPAConfig; factoryMethodName=transactionManager; initMethodNames=null; destroyMethodNames=[(inferred)]; defined in class path resource [org/apache/fineract/infrastructure/core/config/jpa/JPAConfig.class]] with [Root bean: class=null; scope=; abstract=false; lazyInit=null; autowireMode=3; dependencyCheck=0; autowireCandidate=true; primary=false; fallback=false; factoryBeanName=scheduledJobRunnerConfig; factoryMethodName=transactionManager; initMethodNames=null; destroyMethodNames=[(inferred)]; defined in class path resource [org/apache/fineract/infrastructure/jobs/ScheduledJobRunnerConfig.class]] -2026-05-29 19:17:13.206 - INFO 7760  [ no-tenant] --- [ main] o.s.b.c.c.annotation.BatchRegistrar  : Bean jobRepository already defined in the application context, skipping the registration of a jobRepository -2026-05-29 19:17:13.206 - INFO 7760  [ no-tenant] --- [ main] o.s.b.c.c.annotation.BatchRegistrar  : Bean jobExplorer already defined in the application context, skipping the registration of a jobExplorer -2026-05-29 19:17:13.206 - INFO 7760  [ no-tenant] --- [ main] o.s.b.c.c.annotation.BatchRegistrar  : Bean jobLauncher already defined in the application context, skipping the registration of a jobLauncher -2026-05-29 19:17:13.208 - INFO 7760  [ no-tenant] --- [ main] o.s.b.c.c.annotation.BatchRegistrar  : Finished Spring Batch infrastructure beans configuration in 3 ms. -2026-05-29 19:17:14.107 - INFO 7760  [ no-tenant] --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Multiple Spring Data modules found, entering strict repository configuration mode -2026-05-29 19:17:14.108 - INFO 7760  [ no-tenant] --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JDBC repositories in DEFAULT mode. -2026-05-29 19:17:14.408 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.accounting.provisioning.domain.ProvisioningEntryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.409 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.adhocquery.domain.AdHocRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.409 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.cob.domain.LoanAccountLockRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.410 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormatRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.410 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.bulkimport.domain.ImportDocumentRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.410 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.campaigns.email.domain.EmailCampaignRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.410 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.campaigns.email.domain.EmailConfigurationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.410 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.campaigns.email.domain.EmailMessageRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.410 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.campaigns.sms.domain.SmsCampaignRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.410 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.configuration.domain.ExternalServicesPropertiesRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.412 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.creditbureau.domain.CreditBureauConfigurationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.412 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.creditbureau.domain.CreditBureauLoanProductMappingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.412 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.creditbureau.domain.CreditBureauRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.413 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.creditbureau.domain.CreditReportRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.413 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.creditbureau.domain.OrganisationCreditBureauRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.413 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.creditbureau.domain.TokenRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.414 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.dataqueries.domain.EntityDatatableChecksRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.414 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.dataqueries.domain.RegisteredDatatableRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.414 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.dataqueries.domain.ReportParameterRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.414 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.dataqueries.domain.ReportParameterUsageRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.414 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.dataqueries.domain.ReportRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.415 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.entityaccess.domain.FineractEntityAccessRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.415 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.entityaccess.domain.FineractEntityRelationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.415 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.entityaccess.domain.FineractEntityToEntityMappingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.415 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.hooks.domain.HookConfigurationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.415 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.hooks.domain.HookRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.416 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.hooks.domain.HookTemplateRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.416 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.imps.domain.ImpsTransactionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.416 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.jobs.domain.JobParameterRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.416 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.jobs.domain.ScheduledJobDetailRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.417 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.jobs.domain.ScheduledJobRunHistoryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.417 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.jobs.domain.SchedulerDetailRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.417 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.jobs.service.aggregationjob.domain.JournalEntryAggregationTrackingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.417 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.jobs.service.aggregationjob.domain.JournalEntrySummaryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.417 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.reportmailingjob.domain.ReportMailingJobConfigurationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.419 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.reportmailingjob.domain.ReportMailingJobRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.419 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.reportmailingjob.domain.ReportMailingJobRunHistoryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.420 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.security.otp.domain.OtpLogRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.421 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.sms.domain.SmsMessageRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.421 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.survey.domain.LikelihoodRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.421 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.interoperation.domain.InteropIdentifierRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.421 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.notification.domain.NotificationMapperRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.422 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.notification.domain.NotificationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.422 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.holiday.domain.HolidayRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.422 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.office.domain.OfficeTransactionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.423 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.provisioning.domain.ProvisioningCategoryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.423 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.provisioning.domain.ProvisioningCriteriaDefinitionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.423 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.provisioning.domain.ProvisioningCriteriaRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.424 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.staff.domain.StaffRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.424 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.workingdays.domain.WorkingDaysRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.424 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.account.domain.AccountAssociationsRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.425 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.425 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.account.domain.AccountTransferRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.426 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.account.domain.StandingInstructionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.426 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.address.domain.AddressRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.426 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.address.domain.FieldConfigurationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.427 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.calendar.domain.CalendarHistoryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.427 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.calendar.domain.CalendarInstanceRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.427 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.calendar.domain.CalendarRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.427 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.ckyc.domain.CkycBatchRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.427 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.domain.ClientAddressRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.427 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.domain.ClientChargeRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.428 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.domain.ClientFamilyMembersRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.428 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.domain.ClientNonPersonRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.428 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.domain.ClientTransactionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.428 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.domain.ClientTransferDetailsRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.429 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.collateralmanagement.domain.ClientCollateralManagementRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.430 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.collateralmanagement.domain.CollateralManagementDomainRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.430 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.collateral.domain.LoanCollateralRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.431 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.fundtransfer.domain.BankIfscRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.431 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.fundtransfer.domain.CustomerTransferLimitRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.431 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.fundtransfer.domain.FundTransferEventRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.432 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.fundtransfer.domain.FundTransferRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.432 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.fundtransfer.domain.TransferLimitConfigRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.432 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.fundtransfer.domain.VerificationTokenRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.432 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.fund.domain.FundRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.432 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.group.domain.GroupLevelRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.432 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.group.domain.GroupRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.433 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.group.domain.GroupRoleRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.433 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.guarantor.domain.GuarantorFundingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.433 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.guarantor.domain.GuarantorFundingTransactionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.433 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.guarantor.domain.GuarantorRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.433 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanproduct.productmix.domain.ProductMixRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.434 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.meeting.attendance.domain.ClientAttendanceRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.434 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.meeting.domain.MeetingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.434 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.note.domain.NoteRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.434 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.rate.domain.RateRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.434 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecksRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.434 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransactionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.434 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.FdBaseInterestRateEffectiveRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.435 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.FdBaseInterestRateSlabHistoryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.438 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.FixedDepositAccountRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.438 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.FixedDepositProductRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.438 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.RdBaseInterestRateEffectiveRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.439 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.RdBaseInterestRateSlabHistoryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.439 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.RdInterestRateEffectiveRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.439 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.RdInterestRateSlabHistoryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.439 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.RecurringDepositAccountRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.439 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.self.account.domain.SelfBeneficiariesTPTRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.439 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.self.device.domain.DeviceRegistrationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.439 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.self.pockets.domain.PocketAccountMappingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.440 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.self.pockets.domain.PocketRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.440 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.self.registration.domain.SelfServiceRegistrationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.440 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.440 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.440 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.shareproducts.domain.ShareProductDividentPayOutDetailsRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.441 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.shareproducts.domain.ShareProductRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.441 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.spm.domain.LookupTableRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.442 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.spm.domain.ScorecardRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.444 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.spm.domain.SurveyRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.444 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.template.domain.TemplateRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.445 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.useradministration.domain.AppUserPreviousPasswordRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.445 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.useradministration.domain.AppUserRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.446 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.useradministration.domain.PasswordValidationPolicyRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.446 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.useradministration.domain.PermissionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.446 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.useradministration.domain.RoleRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.446 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.investor.domain.ExternalAssetOwnerJournalEntryMappingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.446 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.investor.domain.ExternalAssetOwnerLoanProductAttributesRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.447 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.investor.domain.ExternalAssetOwnerRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.447 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.investor.domain.ExternalAssetOwnerTransferJournalEntryMappingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.447 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.investor.domain.ExternalAssetOwnerTransferLoanMappingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.447 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.investor.domain.ExternalAssetOwnerTransferRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.448 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorMappingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.448 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.448 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.delinquency.domain.DelinquencyBucketMappingsRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.449 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.delinquency.domain.DelinquencyBucketRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.449 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.delinquency.domain.DelinquencyRangeRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.449 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyActionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.450 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyTagHistoryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.450 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.delinquency.domain.LoanInstallmentDelinquencyTagRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.450 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.GLIMAccountInfoRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.450 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanAccrualActivityRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.450 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanAmortizationAllocationMappingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.450 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanApprovedAmountHistoryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.450 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidByRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.452 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanChargeRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.452 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanCollateralManagementRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.452 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanDisbursementDetailsRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.452 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallmentRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.452 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.452 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanStatusChangeHistoryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.453 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.453 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.453 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMappingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.453 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgingParameterRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.453 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanRepaymentScheduleHistoryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.454 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.rescheduleloan.domain.LoanRescheduleRequestRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.454 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.rescheduleloan.domain.LoanTermVariationsRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.454 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanproduct.domain.LoanProductRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.454 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.455 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.455 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.currentproduct.domain.CurrentProductDepositLimitKycRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.455 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityConfigRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.455 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityLimitRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.455 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.455 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.455 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartSlabRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.456 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.456 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.GSIMRepositoy; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.456 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.RecurringDepositProductRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.456 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.SavingsAccountChargeRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.456 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.SavingsAccountFacilityConfigRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.456 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.457 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.457 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.SavingsProductDepositLimitKycRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.457 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityConfigRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.457 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityLimitRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.457 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.SavingsProductRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.457 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.cob.domain.BatchBusinessStepRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.457 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.teller.domain.CashierRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.457 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.teller.domain.CashierTransactionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.458 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.teller.domain.TellerRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.458 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.teller.domain.TellerTransactionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.458 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.accounting.closure.domain.GLClosureRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.458 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.458 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.accounting.glaccount.domain.TrialBalanceRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.458 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.459 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.459 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.accounting.rule.domain.AccountingRuleRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.459 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.floatingrates.domain.FloatingRateRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.459 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.charge.domain.ChargeRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.462 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.tax.domain.TaxComponentRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.462 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.tax.domain.TaxGroupRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.462 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.security.domain.TFAccessTokenRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.462 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.security.domain.TwoFactorConfigurationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.464 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.accounting.glaccount.domain.GLAccountRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.466 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.commands.domain.CommandSourceRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.466 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.businessdate.domain.BusinessDateRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.466 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.cache.domain.PlatformCacheRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.466 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.codes.domain.CodeRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.466 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.codes.domain.CodeValueRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.466 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.configuration.domain.GlobalConfigurationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.467 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.rbifeed.domain.RbiFeedItemRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.467 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.monetary.domain.ApplicationCurrencyRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.467 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.monetary.domain.OrganisationCurrencyRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.467 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.office.domain.OfficeRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.467 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.cfcfrms.domain.CfcfrmsSimulatorLogRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.467 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.cfcfrms.domain.ComplaintAuditLogRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.468 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.cfcfrms.domain.ComplaintTicketRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.468 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.domain.ClientForm60Repository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.470 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.domain.ClientIdentifierRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.470 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.domain.ClientKycRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.471 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.domain.ClientNomineeRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.471 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.domain.ClientRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.471 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.entityclassification.domain.ClientEouRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.471 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.entityclassification.domain.ClientImportExportRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.471 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.entityclassification.domain.ClientMsmeRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.471 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.entityclassification.domain.ClientMudraBorrowerRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.472 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.entityclassification.domain.ClientNonPrioritySectorRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.472 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.entityclassification.domain.ClientPrioritySectorRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.472 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.entityclassification.domain.ClientStartupIndiaRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.472 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLogRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.472 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetailRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.472 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.paymenttype.domain.PaymentTypeRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.472 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.sanctions.domain.SanctionsEntryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.473 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.473 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.473 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.useradministration.dsc.domain.DscChallengeRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.473 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.768 - INFO 7760  [ no-tenant] --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 657 ms. Found 6 JDBC repository interfaces. -2026-05-29 19:17:16.886 - INFO 7760  [ no-tenant] --- [ main] faultConfiguringBeanFactoryPostProcessor : No bean named 'errorChannel' has been explicitly defined. Therefore, a default PublishSubscribeChannel will be created. -2026-05-29 19:17:16.895 - INFO 7760  [ no-tenant] --- [ main] faultConfiguringBeanFactoryPostProcessor : No bean named 'integrationHeaderChannelRegistry' has been explicitly defined. Therefore, a default DefaultHeaderChannelRegistry will be created. -2026-05-29 19:17:18.104 - WARN 7760  [ no-tenant] --- [ main] trationDelegate$BeanPostProcessorChecker : Bean 'fineract-org.apache.fineract.infrastructure.core.config.FineractProperties' of type [org.apache.fineract.infrastructure.core.config.FineractProperties] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). Is this bean getting eagerly injected/applied to a currently created BeanPostProcessor [samplingBeanPostProcessor]? Check the corresponding BeanPostProcessor declaration and its dependencies/advisors. If this bean does not have to be post-processed, declare it with ROLE_INFRASTRUCTURE. -2026-05-29 19:17:18.117 - WARN 7760  [ no-tenant] --- [ main] trationDelegate$BeanPostProcessorChecker : Bean 'cacheConfig' of type [org.apache.fineract.infrastructure.core.config.cache.CacheConfig$$SpringCGLIB$$0] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). Is this bean getting eagerly injected/applied to a currently created BeanPostProcessor [samplingBeanPostProcessor]? Check the corresponding BeanPostProcessor declaration and its dependencies/advisors. If this bean does not have to be post-processed, declare it with ROLE_INFRASTRUCTURE. -2026-05-29 19:17:18.335 - INFO 7760  [ no-tenant] --- [ main] org.reflections.Reflections  : Reflections took 7 ms to scan 1 urls, producing 0 keys and 0 values -2026-05-29 19:17:18.339 - WARN 7760  [ no-tenant] --- [ main] o.a.f.i.core.config.cache.CacheConfig  : The following cache configurations are defined but cache does not exists: [userTFAccessToken] -2026-05-29 19:17:18.343 - WARN 7760  [ no-tenant] --- [ main] trationDelegate$BeanPostProcessorChecker : Bean 'ehCacheManager' of type [org.springframework.cache.jcache.JCacheCacheManager] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). Is this bean getting eagerly injected/applied to a currently created BeanPostProcessor [samplingBeanPostProcessor]? Check the corresponding BeanPostProcessor declaration and its dependencies/advisors. If this bean does not have to be post-processed, declare it with ROLE_INFRASTRUCTURE. -2026-05-29 19:17:18.354 - WARN 7760  [ no-tenant] --- [ main] trationDelegate$BeanPostProcessorChecker : Bean 'defaultCacheManager' of type [org.apache.fineract.infrastructure.core.config.cache.TransactionBoundCacheManager] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). Is this bean getting eagerly injected/applied to a currently created BeanPostProcessor [samplingBeanPostProcessor]? Check the corresponding BeanPostProcessor declaration and its dependencies/advisors. If this bean does not have to be post-processed, declare it with ROLE_INFRASTRUCTURE. -2026-05-29 19:17:18.360 - WARN 7760  [ no-tenant] --- [ main] trationDelegate$BeanPostProcessorChecker : Bean 'runtimeDelegatingCacheManager' of type [org.apache.fineract.infrastructure.cache.service.RuntimeDelegatingCacheManager] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). Is this bean getting eagerly injected/applied to a currently created BeanPostProcessor [samplingBeanPostProcessor]? Check the corresponding BeanPostProcessor declaration and its dependencies/advisors. If this bean does not have to be post-processed, declare it with ROLE_INFRASTRUCTURE. -2026-05-29 19:17:18.364 - WARN 7760  [ no-tenant] --- [ main] trationDelegate$BeanPostProcessorChecker : Bean 'platformCacheConfiguration' of type [org.apache.fineract.infrastructure.cache.PlatformCacheConfiguration$$SpringCGLIB$$0] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). Is this bean getting eagerly injected/applied to a currently created BeanPostProcessor [samplingBeanPostProcessor]? Check the corresponding BeanPostProcessor declaration and its dependencies/advisors. If this bean does not have to be post-processed, declare it with ROLE_INFRASTRUCTURE. -2026-05-29 19:17:18.407 - WARN 7760  [ no-tenant] --- [ main] trationDelegate$BeanPostProcessorChecker : Bean 'samplingConfiguration' of type [org.apache.fineract.infrastructure.core.diagnostics.performance.sampling.core.SamplingConfiguration] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). Is this bean getting eagerly injected/applied to a currently created BeanPostProcessor [samplingBeanPostProcessor]? Check the corresponding BeanPostProcessor declaration and its dependencies/advisors. If this bean does not have to be post-processed, declare it with ROLE_INFRASTRUCTURE. -2026-05-29 19:17:18.415 - WARN 7760  [ no-tenant] --- [ main] trationDelegate$BeanPostProcessorChecker : Bean 'samplingServiceFactory' of type [org.apache.fineract.infrastructure.core.diagnostics.performance.sampling.core.SamplingServiceFactory] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). Is this bean getting eagerly injected/applied to a currently created BeanPostProcessor [samplingBeanPostProcessor]? Check the corresponding BeanPostProcessor declaration and its dependencies/advisors. If this bean does not have to be post-processed, declare it with ROLE_INFRASTRUCTURE. -2026-05-29 19:17:19.255 - INFO 7760  [ no-tenant] --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port 8443 (https) -2026-05-29 19:17:19.278 - INFO 7760  [ no-tenant] --- [ main] o.apache.catalina.core.StandardService  : Starting service [Tomcat] -2026-05-29 19:17:19.279 - INFO 7760  [ no-tenant] --- [ main] o.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/10.1.49] -2026-05-29 19:17:19.328 - INFO 7760  [ no-tenant] --- [ main] o.a.c.c.C.[.[.[/fineract-provider]  : Initializing Spring embedded WebApplicationContext -2026-05-29 19:17:19.329 - INFO 7760  [ no-tenant] --- [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 13673 ms -2026-05-29 19:17:19.577 - INFO 7760  [ no-tenant] --- [ main] com.zaxxer.hikari.HikariDataSource  : HikariPool-1 - Starting... -2026-05-29 19:17:19.860 - INFO 7760  [ no-tenant] --- [ main] com.zaxxer.hikari.pool.HikariPool  : HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@5917b984 -2026-05-29 19:17:19.861 - INFO 7760  [ no-tenant] --- [ main] com.zaxxer.hikari.HikariDataSource  : HikariPool-1 - Start completed. -2026-05-29 19:17:20.050 - INFO 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : Upgrading tenant store DB at localhost:5432 -2026-05-29 19:17:20.050 - INFO 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : - fineract.tenant.username: postgres -2026-05-29 19:17:20.050 - INFO 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : - fineract.tenant.password: **** -2026-05-29 19:17:20.050 - INFO 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : - fineract.tenant.parameters: -2026-05-29 19:17:20.050 - INFO 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : - fineract.tenant.timezone: Asia/Kolkata -2026-05-29 19:17:20.050 - INFO 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : - fineract.tenant.description: Default Demo Tenant -2026-05-29 19:17:20.050 - INFO 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : - fineract.tenant.identifier: default -2026-05-29 19:17:20.050 - INFO 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : - fineract.tenant.name: fineract_default -2026-05-29 19:17:20.050 - INFO 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : - liquibase.analytics.enabled: false -2026-05-29 19:17:25.487 - INFO 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : Tenant store upgrade finished -2026-05-29 19:17:25.487 - INFO 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : Upgrading all tenants -2026-05-29 19:17:25.501 - INFO 7760  [ default] --- [lTaskExecutor-1] a.f.i.c.s.m.TenantDatabaseUpgradeService : Upgrade for tenant default has started -2026-05-29 19:17:25.842 - INFO 7760  [ default] --- [lTaskExecutor-1] com.zaxxer.hikari.HikariDataSource  : HikariPool-2 - Starting... -2026-05-29 19:17:25.946 - INFO 7760  [ default] --- [lTaskExecutor-1] com.zaxxer.hikari.pool.HikariPool  : HikariPool-2 - Added connection org.postgresql.jdbc.PgConnection@625805c1 -2026-05-29 19:17:25.946 - INFO 7760  [ default] --- [lTaskExecutor-1] com.zaxxer.hikari.HikariDataSource  : HikariPool-2 - Start completed. -2026-05-29 19:17:31.700 -ERROR 7760  [ default] --- [lTaskExecutor-1] liquibase.changelog  : ChangeSet db/changelog/tenant/parts/0309_phase3_jobs_and_recall_permission.xml::0309_imps_deemed_reconcile_job::fineract encountered an exception. - -liquibase.exception.DatabaseException: ERROR: null value in column "short_name" of relation "job" violates not-null constraint - Detail: Failing row contains (45, IMPS DEEMED Reconciliation, IMPS DEEMED Reconciliation, 0/30 * * * * ?, 2026-05-29 19:17:31.688453, 5, null, null, null, IMPS DEEMED ReconciliationDetail1 _ DEFAULT, null, f, f, t, 0, f, 1, t, null). [Failed SQL: (0) INSERT INTO public.job (name, display_name, cron_expression, create_time, task_priority, group_name, previous_run_start_time, job_key, initializing_errorlog, is_active, currently_running, updates_allowed, scheduler_group, is_misfired, node_id, is_mismatched_job) VALUES ('IMPS DEEMED Reconciliation', 'IMPS DEEMED Reconciliation', '0/30 * * * * ?', NOW(), 5, NULL, NULL, 'IMPS DEEMED ReconciliationDetail1 _ DEFAULT', NULL, FALSE, FALSE, TRUE, 0, FALSE, 1, TRUE)] - at liquibase.executor.jvm.JdbcExecutor$ExecuteStatementCallback.doInStatement(JdbcExecutor.java:520) - at liquibase.executor.jvm.JdbcExecutor.execute(JdbcExecutor.java:85) - at liquibase.executor.jvm.JdbcExecutor.execute(JdbcExecutor.java:188) - at liquibase.executor.AbstractExecutor.execute(AbstractExecutor.java:148) - at liquibase.database.AbstractJdbcDatabase.executeStatements(AbstractJdbcDatabase.java:1198) - at liquibase.changelog.ChangeSet.execute(ChangeSet.java:816) - at liquibase.changelog.visitor.UpdateVisitor.executeAcceptedChange(UpdateVisitor.java:127) - at liquibase.changelog.visitor.UpdateVisitor.visit(UpdateVisitor.java:71) - at liquibase.changelog.ChangeLogIterator.lambda$run$0(ChangeLogIterator.java:133) - at liquibase.Scope.lambda$child$0(Scope.java:216) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:215) - at liquibase.Scope.child(Scope.java:194) - at liquibase.changelog.ChangeLogIterator.lambda$run$1(ChangeLogIterator.java:122) - at liquibase.Scope.lambda$child$0(Scope.java:216) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:215) - at liquibase.Scope.child(Scope.java:194) - at liquibase.Scope.child(Scope.java:282) - at liquibase.Scope.child(Scope.java:286) - at liquibase.changelog.ChangeLogIterator.run(ChangeLogIterator.java:91) - at liquibase.command.core.AbstractUpdateCommandStep.lambda$run$0(AbstractUpdateCommandStep.java:114) - at liquibase.Scope.lambda$child$0(Scope.java:216) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:215) - at liquibase.Scope.child(Scope.java:194) - at liquibase.command.core.AbstractUpdateCommandStep.run(AbstractUpdateCommandStep.java:112) - at liquibase.command.core.UpdateCommandStep.run(UpdateCommandStep.java:100) - at liquibase.command.CommandScope.lambda$execute$6(CommandScope.java:263) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:201) - at liquibase.command.CommandScope.execute(CommandScope.java:251) - at liquibase.Liquibase.lambda$update$0(Liquibase.java:216) - at liquibase.Scope.lambda$child$0(Scope.java:216) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:215) - at liquibase.Scope.child(Scope.java:194) - at liquibase.Liquibase.runInScope(Liquibase.java:1366) - at liquibase.Liquibase.update(Liquibase.java:205) - at liquibase.Liquibase.update(Liquibase.java:188) - at liquibase.integration.spring.SpringLiquibase.performUpdate(SpringLiquibase.java:327) - at liquibase.integration.spring.SpringLiquibase.lambda$afterPropertiesSet$0(SpringLiquibase.java:279) - at liquibase.Scope.lambda$child$0(Scope.java:216) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:215) - at liquibase.Scope.child(Scope.java:194) - at liquibase.integration.spring.SpringLiquibase.afterPropertiesSet(SpringLiquibase.java:272) - at org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseUpgradeService.upgradeIndividualTenant(TenantDatabaseUpgradeService.java:205) - at org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseUpgradeService.lambda$upgradeIndividualTenants$1(TenantDatabaseUpgradeService.java:146) - at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317) - at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144) - at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642) - at java.base/java.lang.Thread.run(Thread.java:1583) -Caused by: org.postgresql.util.PSQLException: ERROR: null value in column "short_name" of relation "job" violates not-null constraint - Detail: Failing row contains (45, IMPS DEEMED Reconciliation, IMPS DEEMED Reconciliation, 0/30 * * * * ?, 2026-05-29 19:17:31.688453, 5, null, null, null, IMPS DEEMED ReconciliationDetail1 _ DEFAULT, null, f, f, t, 0, f, 1, t, null). - at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2736) - at org.postgresql.core.v3.QueryExecutorImpl.processResults(QueryExecutorImpl.java:2421) - at org.postgresql.core.v3.QueryExecutorImpl.execute(QueryExecutorImpl.java:372) - at org.postgresql.jdbc.PgStatement.executeInternal(PgStatement.java:525) - at org.postgresql.jdbc.PgStatement.execute(PgStatement.java:435) - at org.postgresql.jdbc.PgStatement.executeWithFlags(PgStatement.java:357) - at org.postgresql.jdbc.PgStatement.executeCachedSql(PgStatement.java:342) - at org.postgresql.jdbc.PgStatement.executeWithFlags(PgStatement.java:318) - at org.postgresql.jdbc.PgStatement.execute(PgStatement.java:313) - at com.zaxxer.hikari.pool.ProxyStatement.execute(ProxyStatement.java:95) - at com.zaxxer.hikari.pool.HikariProxyStatement.execute(HikariProxyStatement.java) - at liquibase.executor.jvm.JdbcExecutor$ExecuteStatementCallback.doInStatement(JdbcExecutor.java:512) - ... 52 common frames omitted - -2026-05-29 19:17:31.748 - INFO 7760  [ default] --- [lTaskExecutor-1] com.zaxxer.hikari.HikariDataSource  : HikariPool-2 - Shutdown initiated... -2026-05-29 19:17:31.750 - INFO 7760  [ default] --- [lTaskExecutor-1] com.zaxxer.hikari.HikariDataSource  : HikariPool-2 - Shutdown completed. -2026-05-29 19:17:31.752 -ERROR 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : Exception: - -java.util.concurrent.ExecutionException: java.lang.RuntimeException: Exception while upgrading tenant default - at java.base/java.util.concurrent.FutureTask.report(FutureTask.java:122) - at java.base/java.util.concurrent.FutureTask.get(FutureTask.java:191) - at org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseUpgradeService.upgradeIndividualTenants(TenantDatabaseUpgradeService.java:155) - at org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseUpgradeService.afterPropertiesSet(TenantDatabaseUpgradeService.java:93) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1873) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1822) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:607) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:315) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveReference(BeanDefinitionValueResolver.java:365) - at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveValueIfNecessary(BeanDefinitionValueResolver.java:135) - at org.springframework.beans.factory.support.ConstructorResolver.resolveConstructorArguments(ConstructorResolver.java:691) - at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:513) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.instantiateUsingFactoryMethod(AbstractAutowireCapableBeanFactory.java:1375) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1205) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveReference(BeanDefinitionValueResolver.java:365) - at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveValueIfNecessary(BeanDefinitionValueResolver.java:135) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.applyPropertyValues(AbstractAutowireCapableBeanFactory.java:1725) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.populateBean(AbstractAutowireCapableBeanFactory.java:1474) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:606) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1698) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1643) - at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:913) - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:791) - at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1395) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1232) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1760) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1643) - at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:913) - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:791) - at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1395) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1232) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1760) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1643) - at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:913) - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:791) - at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1395) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1232) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBeansWithAnnotation(DefaultListableBeanFactory.java:796) - at org.springframework.context.support.AbstractApplicationContext.getBeansWithAnnotation(AbstractApplicationContext.java:1440) - at org.apache.fineract.infrastructure.core.jersey.JerseyConfig.setup(JerseyConfig.java:58) - at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) - at java.base/java.lang.reflect.Method.invoke(Method.java:580) - at org.springframework.beans.factory.annotation.InitDestroyAnnotationBeanPostProcessor$LifecycleMethod.invoke(InitDestroyAnnotationBeanPostProcessor.java:457) - at org.springframework.beans.factory.annotation.InitDestroyAnnotationBeanPostProcessor$LifecycleMetadata.invokeInitMethods(InitDestroyAnnotationBeanPostProcessor.java:401) - at org.springframework.beans.factory.annotation.InitDestroyAnnotationBeanPostProcessor.postProcessBeforeInitialization(InitDestroyAnnotationBeanPostProcessor.java:219) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.applyBeanPostProcessorsBeforeInitialization(AbstractAutowireCapableBeanFactory.java:429) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1818) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:607) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1760) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1643) - at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:913) - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:791) - at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1395) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1232) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:413) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.instantiateUsingFactoryMethod(AbstractAutowireCapableBeanFactory.java:1375) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1205) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:207) - at org.springframework.boot.web.servlet.ServletContextInitializerBeans.getOrderedBeansOfType(ServletContextInitializerBeans.java:230) - at org.springframework.boot.web.servlet.ServletContextInitializerBeans.getOrderedBeansOfType(ServletContextInitializerBeans.java:221) - at org.springframework.boot.web.servlet.ServletContextInitializerBeans.addServletContextInitializerBeans(ServletContextInitializerBeans.java:106) - at org.springframework.boot.web.servlet.ServletContextInitializerBeans.(ServletContextInitializerBeans.java:95) - at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.getServletContextInitializerBeans(ServletWebServerApplicationContext.java:271) - at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.selfInitialize(ServletWebServerApplicationContext.java:245) - at org.springframework.boot.web.embedded.tomcat.TomcatStarter.onStartup(TomcatStarter.java:52) - at org.apache.catalina.core.StandardContext.startInternal(StandardContext.java:4452) - at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) - at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1201) - at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1191) - at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317) - at org.apache.tomcat.util.threads.InlineExecutorService.execute(InlineExecutorService.java:81) - at java.base/java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:145) - at org.apache.catalina.core.ContainerBase.startInternal(ContainerBase.java:747) - at org.apache.catalina.core.StandardHost.startInternal(StandardHost.java:770) - at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) - at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1201) - at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1191) - at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317) - at org.apache.tomcat.util.threads.InlineExecutorService.execute(InlineExecutorService.java:81) - at java.base/java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:145) - at org.apache.catalina.core.ContainerBase.startInternal(ContainerBase.java:747) - at org.apache.catalina.core.StandardEngine.startInternal(StandardEngine.java:201) - at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) - at org.apache.catalina.core.StandardService.startInternal(StandardService.java:410) - at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) - at org.apache.catalina.core.StandardServer.startInternal(StandardServer.java:868) - at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) - at org.apache.catalina.startup.Tomcat.start(Tomcat.java:436) - at org.springframework.boot.web.embedded.tomcat.TomcatWebServer.initialize(TomcatWebServer.java:128) - at org.springframework.boot.web.embedded.tomcat.TomcatWebServer.(TomcatWebServer.java:107) - at org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory.getTomcatWebServer(TomcatServletWebServerFactory.java:517) - at org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory.getWebServer(TomcatServletWebServerFactory.java:219) - at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.createWebServer(ServletWebServerApplicationContext.java:193) - at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.onRefresh(ServletWebServerApplicationContext.java:167) - at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:621) - at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:146) - at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:752) - at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:439) - at org.springframework.boot.SpringApplication.run(SpringApplication.java:318) - at org.springframework.boot.builder.SpringApplicationBuilder.run(SpringApplicationBuilder.java:149) - at org.apache.fineract.ServerApplication.main(ServerApplication.java:57) -Caused by: java.lang.RuntimeException: Exception while upgrading tenant default - at org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseUpgradeService.upgradeIndividualTenant(TenantDatabaseUpgradeService.java:208) - at org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseUpgradeService.lambda$upgradeIndividualTenants$1(TenantDatabaseUpgradeService.java:146) - at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317) - at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144) - at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642) - at java.base/java.lang.Thread.run(Thread.java:1583) -Caused by: liquibase.exception.LiquibaseException: liquibase.exception.CommandExecutionException: liquibase.exception.LiquibaseException: liquibase.exception.MigrationFailedException: Migration failed for changeset db/changelog/tenant/parts/0309_phase3_jobs_and_recall_permission.xml::0309_imps_deemed_reconcile_job::fineract: - Reason: liquibase.exception.DatabaseException: ERROR: null value in column "short_name" of relation "job" violates not-null constraint - Detail: Failing row contains (45, IMPS DEEMED Reconciliation, IMPS DEEMED Reconciliation, 0/30 * * * * ?, 2026-05-29 19:17:31.688453, 5, null, null, null, IMPS DEEMED ReconciliationDetail1 _ DEFAULT, null, f, f, t, 0, f, 1, t, null). [Failed SQL: (0) INSERT INTO public.job (name, display_name, cron_expression, create_time, task_priority, group_name, previous_run_start_time, job_key, initializing_errorlog, is_active, currently_running, updates_allowed, scheduler_group, is_misfired, node_id, is_mismatched_job) VALUES ('IMPS DEEMED Reconciliation', 'IMPS DEEMED Reconciliation', '0/30 * * * * ?', NOW(), 5, NULL, NULL, 'IMPS DEEMED ReconciliationDetail1 _ DEFAULT', NULL, FALSE, FALSE, TRUE, 0, FALSE, 1, TRUE)] - at liquibase.integration.spring.SpringLiquibase.afterPropertiesSet(SpringLiquibase.java:289) - at org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseUpgradeService.upgradeIndividualTenant(TenantDatabaseUpgradeService.java:205) - ... 5 common frames omitted -Caused by: liquibase.exception.CommandExecutionException: liquibase.exception.LiquibaseException: liquibase.exception.MigrationFailedException: Migration failed for changeset db/changelog/tenant/parts/0309_phase3_jobs_and_recall_permission.xml::0309_imps_deemed_reconcile_job::fineract: - Reason: liquibase.exception.DatabaseException: ERROR: null value in column "short_name" of relation "job" violates not-null constraint - Detail: Failing row contains (45, IMPS DEEMED Reconciliation, IMPS DEEMED Reconciliation, 0/30 * * * * ?, 2026-05-29 19:17:31.688453, 5, null, null, null, IMPS DEEMED ReconciliationDetail1 _ DEFAULT, null, f, f, t, 0, f, 1, t, null). [Failed SQL: (0) INSERT INTO public.job (name, display_name, cron_expression, create_time, task_priority, group_name, previous_run_start_time, job_key, initializing_errorlog, is_active, currently_running, updates_allowed, scheduler_group, is_misfired, node_id, is_mismatched_job) VALUES ('IMPS DEEMED Reconciliation', 'IMPS DEEMED Reconciliation', '0/30 * * * * ?', NOW(), 5, NULL, NULL, 'IMPS DEEMED ReconciliationDetail1 _ DEFAULT', NULL, FALSE, FALSE, TRUE, 0, FALSE, 1, TRUE)] - at liquibase.command.CommandScope.lambda$execute$6(CommandScope.java:310) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:201) - at liquibase.command.CommandScope.execute(CommandScope.java:251) - at liquibase.Liquibase.lambda$update$0(Liquibase.java:216) - at liquibase.Scope.lambda$child$0(Scope.java:216) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:215) - at liquibase.Scope.child(Scope.java:194) - at liquibase.Liquibase.runInScope(Liquibase.java:1366) - at liquibase.Liquibase.update(Liquibase.java:205) - at liquibase.Liquibase.update(Liquibase.java:188) - at liquibase.integration.spring.SpringLiquibase.performUpdate(SpringLiquibase.java:327) - at liquibase.integration.spring.SpringLiquibase.lambda$afterPropertiesSet$0(SpringLiquibase.java:279) - at liquibase.Scope.lambda$child$0(Scope.java:216) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:215) - at liquibase.Scope.child(Scope.java:194) - at liquibase.integration.spring.SpringLiquibase.afterPropertiesSet(SpringLiquibase.java:272) - ... 6 common frames omitted -Caused by: liquibase.exception.LiquibaseException: liquibase.exception.MigrationFailedException: Migration failed for changeset db/changelog/tenant/parts/0309_phase3_jobs_and_recall_permission.xml::0309_imps_deemed_reconcile_job::fineract: - Reason: liquibase.exception.DatabaseException: ERROR: null value in column "short_name" of relation "job" violates not-null constraint - Detail: Failing row contains (45, IMPS DEEMED Reconciliation, IMPS DEEMED Reconciliation, 0/30 * * * * ?, 2026-05-29 19:17:31.688453, 5, null, null, null, IMPS DEEMED ReconciliationDetail1 _ DEFAULT, null, f, f, t, 0, f, 1, t, null). [Failed SQL: (0) INSERT INTO public.job (name, display_name, cron_expression, create_time, task_priority, group_name, previous_run_start_time, job_key, initializing_errorlog, is_active, currently_running, updates_allowed, scheduler_group, is_misfired, node_id, is_mismatched_job) VALUES ('IMPS DEEMED Reconciliation', 'IMPS DEEMED Reconciliation', '0/30 * * * * ?', NOW(), 5, NULL, NULL, 'IMPS DEEMED ReconciliationDetail1 _ DEFAULT', NULL, FALSE, FALSE, TRUE, 0, FALSE, 1, TRUE)] - at liquibase.changelog.ChangeLogIterator.run(ChangeLogIterator.java:155) - at liquibase.command.core.AbstractUpdateCommandStep.lambda$run$0(AbstractUpdateCommandStep.java:114) - at liquibase.Scope.lambda$child$0(Scope.java:216) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:215) - at liquibase.Scope.child(Scope.java:194) - at liquibase.command.core.AbstractUpdateCommandStep.run(AbstractUpdateCommandStep.java:112) - at liquibase.command.core.UpdateCommandStep.run(UpdateCommandStep.java:100) - at liquibase.command.CommandScope.lambda$execute$6(CommandScope.java:263) - ... 24 common frames omitted -Caused by: liquibase.exception.MigrationFailedException: Migration failed for changeset db/changelog/tenant/parts/0309_phase3_jobs_and_recall_permission.xml::0309_imps_deemed_reconcile_job::fineract: - Reason: liquibase.exception.DatabaseException: ERROR: null value in column "short_name" of relation "job" violates not-null constraint - Detail: Failing row contains (45, IMPS DEEMED Reconciliation, IMPS DEEMED Reconciliation, 0/30 * * * * ?, 2026-05-29 19:17:31.688453, 5, null, null, null, IMPS DEEMED ReconciliationDetail1 _ DEFAULT, null, f, f, t, 0, f, 1, t, null). [Failed SQL: (0) INSERT INTO public.job (name, display_name, cron_expression, create_time, task_priority, group_name, previous_run_start_time, job_key, initializing_errorlog, is_active, currently_running, updates_allowed, scheduler_group, is_misfired, node_id, is_mismatched_job) VALUES ('IMPS DEEMED Reconciliation', 'IMPS DEEMED Reconciliation', '0/30 * * * * ?', NOW(), 5, NULL, NULL, 'IMPS DEEMED ReconciliationDetail1 _ DEFAULT', NULL, FALSE, FALSE, TRUE, 0, FALSE, 1, TRUE)] - at liquibase.changelog.ChangeSet.execute(ChangeSet.java:873) - at liquibase.changelog.visitor.UpdateVisitor.executeAcceptedChange(UpdateVisitor.java:127) - at liquibase.changelog.visitor.UpdateVisitor.visit(UpdateVisitor.java:71) - at liquibase.changelog.ChangeLogIterator.lambda$run$0(ChangeLogIterator.java:133) - at liquibase.Scope.lambda$child$0(Scope.java:216) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:215) - at liquibase.Scope.child(Scope.java:194) - at liquibase.changelog.ChangeLogIterator.lambda$run$1(ChangeLogIterator.java:122) - at liquibase.Scope.lambda$child$0(Scope.java:216) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:215) - at liquibase.Scope.child(Scope.java:194) - at liquibase.Scope.child(Scope.java:282) - at liquibase.Scope.child(Scope.java:286) - at liquibase.changelog.ChangeLogIterator.run(ChangeLogIterator.java:91) - ... 32 common frames omitted -Caused by: liquibase.exception.DatabaseException: ERROR: null value in column "short_name" of relation "job" violates not-null constraint - Detail: Failing row contains (45, IMPS DEEMED Reconciliation, IMPS DEEMED Reconciliation, 0/30 * * * * ?, 2026-05-29 19:17:31.688453, 5, null, null, null, IMPS DEEMED ReconciliationDetail1 _ DEFAULT, null, f, f, t, 0, f, 1, t, null). [Failed SQL: (0) INSERT INTO public.job (name, display_name, cron_expression, create_time, task_priority, group_name, previous_run_start_time, job_key, initializing_errorlog, is_active, currently_running, updates_allowed, scheduler_group, is_misfired, node_id, is_mismatched_job) VALUES ('IMPS DEEMED Reconciliation', 'IMPS DEEMED Reconciliation', '0/30 * * * * ?', NOW(), 5, NULL, NULL, 'IMPS DEEMED ReconciliationDetail1 _ DEFAULT', NULL, FALSE, FALSE, TRUE, 0, FALSE, 1, TRUE)] - at liquibase.executor.jvm.JdbcExecutor$ExecuteStatementCallback.doInStatement(JdbcExecutor.java:520) - at liquibase.executor.jvm.JdbcExecutor.execute(JdbcExecutor.java:85) - at liquibase.executor.jvm.JdbcExecutor.execute(JdbcExecutor.java:188) - at liquibase.executor.AbstractExecutor.execute(AbstractExecutor.java:148) - at liquibase.database.AbstractJdbcDatabase.executeStatements(AbstractJdbcDatabase.java:1198) - at liquibase.changelog.ChangeSet.execute(ChangeSet.java:816) - ... 47 common frames omitted -Caused by: org.postgresql.util.PSQLException: ERROR: null value in column "short_name" of relation "job" violates not-null constraint - Detail: Failing row contains (45, IMPS DEEMED Reconciliation, IMPS DEEMED Reconciliation, 0/30 * * * * ?, 2026-05-29 19:17:31.688453, 5, null, null, null, IMPS DEEMED ReconciliationDetail1 _ DEFAULT, null, f, f, t, 0, f, 1, t, null). - at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2736) - at org.postgresql.core.v3.QueryExecutorImpl.processResults(QueryExecutorImpl.java:2421) - at org.postgresql.core.v3.QueryExecutorImpl.execute(QueryExecutorImpl.java:372) - at org.postgresql.jdbc.PgStatement.executeInternal(PgStatement.java:525) - at org.postgresql.jdbc.PgStatement.execute(PgStatement.java:435) - at org.postgresql.jdbc.PgStatement.executeWithFlags(PgStatement.java:357) - at org.postgresql.jdbc.PgStatement.executeCachedSql(PgStatement.java:342) - at org.postgresql.jdbc.PgStatement.executeWithFlags(PgStatement.java:318) - at org.postgresql.jdbc.PgStatement.execute(PgStatement.java:313) - at com.zaxxer.hikari.pool.ProxyStatement.execute(ProxyStatement.java:95) - at com.zaxxer.hikari.pool.HikariProxyStatement.execute(HikariProxyStatement.java) - at liquibase.executor.jvm.JdbcExecutor$ExecuteStatementCallback.doInStatement(JdbcExecutor.java:512) - ... 52 common frames omitted - -2026-05-29 19:17:31.761 -ERROR 7760  [ no-tenant] --- [ main] o.s.b.web.embedded.tomcat.TomcatStarter  : Error starting Tomcat context. Exception: org.springframework.beans.factory.UnsatisfiedDependencyException. Message: Error creating bean with name 'org.springframework.boot.autoconfigure.jersey.JerseyAutoConfiguration': Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'jerseyConfig': Invocation of init method failed -2026-05-29 19:17:31.812 - INFO 7760  [ no-tenant] --- [ main] o.apache.catalina.core.StandardService  : Stopping service [Tomcat] -2026-05-29 19:17:31.817 - WARN 7760  [ no-tenant] --- [ main] o.a.c.loader.WebappClassLoaderBase  : The web application [fineract-provider] appears to have started a thread named [HikariPool-1:housekeeper] but has failed to stop it. This is very likely to create a memory leak. Stack trace of thread: - java.base/jdk.internal.misc.Unsafe.park(Native Method) - java.base/java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:269) - java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(AbstractQueuedSynchronizer.java:1797) - java.base/java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(ScheduledThreadPoolExecutor.java:1182) - java.base/java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(ScheduledThreadPoolExecutor.java:899) - java.base/java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1070) - java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1130) - java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642) - java.base/java.lang.Thread.run(Thread.java:1583) -2026-05-29 19:17:31.820 - WARN 7760  [ no-tenant] --- [ main] ConfigServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.context.ApplicationContextException: Unable to start web server -2026-05-29 19:17:31.820 - INFO 7760  [ no-tenant] --- [ main] com.zaxxer.hikari.HikariDataSource  : HikariPool-1 - Shutdown initiated... -2026-05-29 19:17:31.822 - INFO 7760  [ no-tenant] --- [ main] com.zaxxer.hikari.HikariDataSource  : HikariPool-1 - Shutdown completed. -2026-05-29 19:17:31.847 - INFO 7760  [ no-tenant] --- [ main] .s.b.a.l.ConditionEvaluationReportLogger : - -Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled. -2026-05-29 19:17:31.875 -ERROR 7760  [ no-tenant] --- [ main] o.s.boot.SpringApplication  : Application run failed - -org.springframework.context.ApplicationContextException: Unable to start web server - at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.onRefresh(ServletWebServerApplicationContext.java:170) - at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:621) - at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:146) - at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:752) - at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:439) - at org.springframework.boot.SpringApplication.run(SpringApplication.java:318) - at org.springframework.boot.builder.SpringApplicationBuilder.run(SpringApplicationBuilder.java:149) - at org.apache.fineract.ServerApplication.main(ServerApplication.java:57) -Caused by: org.springframework.boot.web.server.WebServerException: Unable to start embedded Tomcat - at org.springframework.boot.web.embedded.tomcat.TomcatWebServer.initialize(TomcatWebServer.java:147) - at org.springframework.boot.web.embedded.tomcat.TomcatWebServer.(TomcatWebServer.java:107) - at org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory.getTomcatWebServer(TomcatServletWebServerFactory.java:517) - at org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory.getWebServer(TomcatServletWebServerFactory.java:219) - at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.createWebServer(ServletWebServerApplicationContext.java:193) - at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.onRefresh(ServletWebServerApplicationContext.java:167) - ... 7 common frames omitted -Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'org.springframework.boot.autoconfigure.jersey.JerseyAutoConfiguration': Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'jerseyConfig': Invocation of init method failed - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:804) - at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1395) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1232) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:413) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.instantiateUsingFactoryMethod(AbstractAutowireCapableBeanFactory.java:1375) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1205) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:207) - at org.springframework.boot.web.servlet.ServletContextInitializerBeans.getOrderedBeansOfType(ServletContextInitializerBeans.java:230) - at org.springframework.boot.web.servlet.ServletContextInitializerBeans.getOrderedBeansOfType(ServletContextInitializerBeans.java:221) - at org.springframework.boot.web.servlet.ServletContextInitializerBeans.addServletContextInitializerBeans(ServletContextInitializerBeans.java:106) - at org.springframework.boot.web.servlet.ServletContextInitializerBeans.(ServletContextInitializerBeans.java:95) - at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.getServletContextInitializerBeans(ServletWebServerApplicationContext.java:271) - at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.selfInitialize(ServletWebServerApplicationContext.java:245) - at org.springframework.boot.web.embedded.tomcat.TomcatStarter.onStartup(TomcatStarter.java:52) - at org.apache.catalina.core.StandardContext.startInternal(StandardContext.java:4452) - at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) - at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1201) - at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1191) - at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317) - at org.apache.tomcat.util.threads.InlineExecutorService.execute(InlineExecutorService.java:81) - at java.base/java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:145) - at org.apache.catalina.core.ContainerBase.startInternal(ContainerBase.java:747) - at org.apache.catalina.core.StandardHost.startInternal(StandardHost.java:770) - at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) - at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1201) - at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1191) - at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317) - at org.apache.tomcat.util.threads.InlineExecutorService.execute(InlineExecutorService.java:81) - at java.base/java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:145) - at org.apache.catalina.core.ContainerBase.startInternal(ContainerBase.java:747) - at org.apache.catalina.core.StandardEngine.startInternal(StandardEngine.java:201) - at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) - at org.apache.catalina.core.StandardService.startInternal(StandardService.java:410) - at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) - at org.apache.catalina.core.StandardServer.startInternal(StandardServer.java:868) - at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) - at org.apache.catalina.startup.Tomcat.start(Tomcat.java:436) - at org.springframework.boot.web.embedded.tomcat.TomcatWebServer.initialize(TomcatWebServer.java:128) - ... 12 common frames omitted -Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jerseyConfig': Invocation of init method failed - at org.springframework.beans.factory.annotation.InitDestroyAnnotationBeanPostProcessor.postProcessBeforeInitialization(InitDestroyAnnotationBeanPostProcessor.java:222) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.applyBeanPostProcessorsBeforeInitialization(AbstractAutowireCapableBeanFactory.java:429) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1818) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:607) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1760) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1643) - at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:913) - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:791) - ... 61 common frames omitted -Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'journalEntriesApiResource' defined in file [C:\Users\adity\Music\CBS\fineract\fineract-provider\build\classes\java\main\org\apache\fineract\accounting\journalentry\api\JournalEntriesApiResource.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'springSecurityPlatformSecurityContext' defined in URL [jar:file:/C:/Users/adity/Music/CBS/fineract/fineract-security/build/libs/fineract-security-1.15.0-SNAPSHOT.jar!/org/apache/fineract/infrastructure/security/service/SpringSecurityPlatformSecurityContext.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'configurationDomainServiceJpa' defined in file [C:\Users\adity\Music\CBS\fineract\fineract-provider\build\classes\java\main\org\apache\fineract\infrastructure\configuration\domain\ConfigurationDomainServiceJpa.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'permissionRepository' defined in org.apache.fineract.useradministration.domain.PermissionRepository defined in @EnableJpaRepositories declared on JPAConfig: Cannot resolve reference to bean 'jpaSharedEM_entityManagerFactory' while setting bean property 'entityManager' - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:804) - at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1395) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1232) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBeansWithAnnotation(DefaultListableBeanFactory.java:796) - at org.springframework.context.support.AbstractApplicationContext.getBeansWithAnnotation(AbstractApplicationContext.java:1440) - at org.apache.fineract.infrastructure.core.jersey.JerseyConfig.setup(JerseyConfig.java:58) - at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) - at java.base/java.lang.reflect.Method.invoke(Method.java:580) - at org.springframework.beans.factory.annotation.InitDestroyAnnotationBeanPostProcessor$LifecycleMethod.invoke(InitDestroyAnnotationBeanPostProcessor.java:457) - at org.springframework.beans.factory.annotation.InitDestroyAnnotationBeanPostProcessor$LifecycleMetadata.invokeInitMethods(InitDestroyAnnotationBeanPostProcessor.java:401) - at org.springframework.beans.factory.annotation.InitDestroyAnnotationBeanPostProcessor.postProcessBeforeInitialization(InitDestroyAnnotationBeanPostProcessor.java:219) - ... 74 common frames omitted -Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'springSecurityPlatformSecurityContext' defined in URL [jar:file:/C:/Users/adity/Music/CBS/fineract/fineract-security/build/libs/fineract-security-1.15.0-SNAPSHOT.jar!/org/apache/fineract/infrastructure/security/service/SpringSecurityPlatformSecurityContext.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'configurationDomainServiceJpa' defined in file [C:\Users\adity\Music\CBS\fineract\fineract-provider\build\classes\java\main\org\apache\fineract\infrastructure\configuration\domain\ConfigurationDomainServiceJpa.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'permissionRepository' defined in org.apache.fineract.useradministration.domain.PermissionRepository defined in @EnableJpaRepositories declared on JPAConfig: Cannot resolve reference to bean 'jpaSharedEM_entityManagerFactory' while setting bean property 'entityManager' - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:804) - at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1395) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1232) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1760) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1643) - at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:913) - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:791) - ... 91 common frames omitted -Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'configurationDomainServiceJpa' defined in file [C:\Users\adity\Music\CBS\fineract\fineract-provider\build\classes\java\main\org\apache\fineract\infrastructure\configuration\domain\ConfigurationDomainServiceJpa.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'permissionRepository' defined in org.apache.fineract.useradministration.domain.PermissionRepository defined in @EnableJpaRepositories declared on JPAConfig: Cannot resolve reference to bean 'jpaSharedEM_entityManagerFactory' while setting bean property 'entityManager' - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:804) - at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1395) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1232) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1760) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1643) - at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:913) - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:791) - ... 105 common frames omitted -Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'permissionRepository' defined in org.apache.fineract.useradministration.domain.PermissionRepository defined in @EnableJpaRepositories declared on JPAConfig: Cannot resolve reference to bean 'jpaSharedEM_entityManagerFactory' while setting bean property 'entityManager' - at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveReference(BeanDefinitionValueResolver.java:377) - at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveValueIfNecessary(BeanDefinitionValueResolver.java:135) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.applyPropertyValues(AbstractAutowireCapableBeanFactory.java:1725) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.populateBean(AbstractAutowireCapableBeanFactory.java:1474) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:606) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1698) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1643) - at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:913) - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:791) - ... 119 common frames omitted -Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jpaSharedEM_entityManagerFactory': Cannot resolve reference to bean 'entityManagerFactory' while setting constructor argument - at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveReference(BeanDefinitionValueResolver.java:377) - at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveValueIfNecessary(BeanDefinitionValueResolver.java:135) - at org.springframework.beans.factory.support.ConstructorResolver.resolveConstructorArguments(ConstructorResolver.java:691) - at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:513) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.instantiateUsingFactoryMethod(AbstractAutowireCapableBeanFactory.java:1375) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1205) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveReference(BeanDefinitionValueResolver.java:365) - ... 132 common frames omitted -Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'tenantDatabaseUpgradeService' defined in file [C:\Users\adity\Music\CBS\fineract\fineract-provider\build\classes\java\main\org\apache\fineract\infrastructure\core\service\migration\TenantDatabaseUpgradeService.class]: Tenant upgrades had exceptions - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1826) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:607) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:315) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveReference(BeanDefinitionValueResolver.java:365) - ... 144 common frames omitted -Caused by: java.lang.RuntimeException: Tenant upgrades had exceptions - at org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseUpgradeService.upgradeIndividualTenants(TenantDatabaseUpgradeService.java:167) - at org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseUpgradeService.afterPropertiesSet(TenantDatabaseUpgradeService.java:93) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1873) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1822) - ... 153 common frames omitted - - -> Task :fineract-provider:bootRun FAILED - -[Incubating] Problems report is available at: file:///C:/Users/adity/Music/CBS/fineract/build/reports/problems/problems-report.html - -FAILURE: Build failed with an exception. - -* What went wrong: -Execution failed for task ':fineract-provider:bootRun'. -> Process 'command 'C:\Program Files\Eclipse Adoptium\jdk-21.0.9.10-hotspot\bin\java.exe'' finished with non-zero exit value 1 - -* Try: -> Run with --stacktrace option to get the stack trace. -> Run with --info or --debug option to get more log output. -> Get more help at https://help.gradle.org. - -Deprecated Gradle features were used in this build, making it incompatible with Gradle 9.0. - -You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins. - -For more on this, please refer to https://docs.gradle.org/8.14.3/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation. - -BUILD FAILED in 1m 30s -83 actionable tasks: 5 executed, 78 up-to-date -Resource usage collection shutdown not awaited diff --git a/fineract-provider/logs/bootrun_output.log b/fineract-provider/logs/bootrun_output.log deleted file mode 100644 index a6cdc98da..000000000 --- a/fineract-provider/logs/bootrun_output.log +++ /dev/null @@ -1,18 +0,0 @@ -> Task :buildSrc:extractPluginRequests UP-TO-DATE -> Task :buildSrc:generatePluginAdapters UP-TO-DATE -> Task :buildSrc:compileJava UP-TO-DATE -> Task :buildSrc:compileGroovy UP-TO-DATE -> Task :buildSrc:compileGroovyPlugins UP-TO-DATE -> Task :buildSrc:pluginDescriptors UP-TO-DATE -> Task :buildSrc:processResources UP-TO-DATE -> Task :buildSrc:classes UP-TO-DATE -> Task :buildSrc:jar UP-TO-DATE - -> Configure project : -matching ref: COMMIT - d7821c7824067a96d31c4c031ea538fc5e4107fc - ref configuration: COMMIT - pattern: null - version: ${describe.tag.version.major}.${describe.tag.version.minor.next}.0-SNAPSHOT - describeTagPattern: .*(\d+\.\d+\.\d+).* - describeTagFirstParent: false - -project version: 0.1.0-SNAPSHOT diff --git a/fineract-provider/logs/devrun.out b/fineract-provider/logs/devrun.out deleted file mode 100644 index f259f210d..000000000 Binary files a/fineract-provider/logs/devrun.out and /dev/null differ diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/api/JournalEntriesApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/api/JournalEntriesApiResource.java index d49026418..9521a44d3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/api/JournalEntriesApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/api/JournalEntriesApiResource.java @@ -75,7 +75,7 @@ import org.springframework.stereotype.Component; @Path("/v1/journalentries") @Component @Tag(name = "Journal Entries", description = """ - A journal entry refers to the logging of transactions against general ledger accounts. A journal entry may consist of several line items, each of which is either a "debit" or a "credit". The total amount of the debits must equal the total amount of the credits or the journal entry is said to be "unbalanced"\s + A journal entry refers to the logging of transactions against general ledger accounts. A journal entry may consist of several line items, each of which is either a "debit" or a "credit". The total amount of the debits must equal the total amount of the credits or the journal entry is said to be "unbalanced" A journal entry directly changes the account balances on the general ledger""") @RequiredArgsConstructor diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/email/service/EmailTemplateServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/email/service/EmailTemplateServiceImpl.java index cacf4b0ed..c7fa18e22 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/email/service/EmailTemplateServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/email/service/EmailTemplateServiceImpl.java @@ -21,6 +21,7 @@ package org.apache.fineract.infrastructure.email.service; import java.time.Year; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Service public class EmailTemplateServiceImpl implements EmailTemplateService { @@ -129,7 +130,7 @@ public class EmailTemplateServiceImpl implements EmailTemplateService { + escape(supportEmail) + "" + "

" + "

" - + " © " + Year.now().getValue() + " " + escape(brandName) + ". All rights reserved." + + " © " + Year.now(DateUtils.getDateTimeZoneOfTenant()).getValue() + " " + escape(brandName) + ". All rights reserved." + "

" + " " + " " diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/imps/crypto/ImpsCryptoUtil.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/imps/crypto/ImpsCryptoUtil.java index 2b79999f1..44ce44a64 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/imps/crypto/ImpsCryptoUtil.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/imps/crypto/ImpsCryptoUtil.java @@ -109,6 +109,7 @@ public class ImpsCryptoUtil { } /** Decrypt inbound payload using the CBS private key. */ + @SuppressWarnings("StringSplitter") public String decryptJson(String encryptedPayload) { if (cbsPrivateKey == null) { throw new IllegalStateException("CBS private key not loaded — check fineract.imps.cbs.private-key-path"); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/imps/service/ImpsCbsServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/imps/service/ImpsCbsServiceImpl.java index 6520a8ffe..cfe3660c8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/imps/service/ImpsCbsServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/imps/service/ImpsCbsServiceImpl.java @@ -34,6 +34,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** * STUB implementation — returns hard-coded SUCCESS for every request and @@ -108,7 +109,7 @@ public class ImpsCbsServiceImpl implements ImpsCbsService { resp.setRefId(request.getRefId()); resp.setUtr(request.getUtr()); resp.setAmount(request.getAmount()); - resp.setTimeStamp(OffsetDateTime.now().format(ISO_OFFSET)); + resp.setTimeStamp(OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant()).format(ISO_OFFSET)); resp.setStatus("SUCCESS"); resp.setResponseCode("00"); AccountData payeeAcc = Optional.ofNullable(request.getPayee()).map(PartyData::getAccount).orElse(null); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/openproject/service/OpenProjectService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/openproject/service/OpenProjectService.java index 3ef36d4ac..2f5cbc9e7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/openproject/service/OpenProjectService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/openproject/service/OpenProjectService.java @@ -28,7 +28,6 @@ import java.util.ArrayList; import java.util.Collection; import java.util.LinkedHashMap; import java.util.Map; -import java.util.concurrent.TimeUnit; import lombok.extern.slf4j.Slf4j; import okhttp3.Credentials; import okhttp3.MediaType; @@ -42,6 +41,7 @@ import org.apache.fineract.infrastructure.openproject.data.OpenProjectProjectDat import org.apache.fineract.infrastructure.openproject.data.OpenProjectStatusSummaryData; import org.apache.fineract.infrastructure.openproject.data.OpenProjectWorkPackageData; import org.springframework.stereotype.Service; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Service @Slf4j @@ -164,7 +164,7 @@ public class OpenProjectService { } // Set required custom field: "Last Follow-up Date" (customField8) - body.addProperty("customField8", LocalDate.now().toString()); + body.addProperty("customField8", LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString()); if (typeName != null && !typeName.isBlank()) { JsonObject links = new JsonObject(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/service/RbiFeedFetchService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/service/RbiFeedFetchService.java index 1d97d05d0..4dc27a0d6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/service/RbiFeedFetchService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/service/RbiFeedFetchService.java @@ -47,6 +47,7 @@ import org.springframework.transaction.annotation.Transactional; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.NodeList; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Service @RequiredArgsConstructor @@ -127,7 +128,7 @@ public class RbiFeedFetchService { NodeList items = doc.getElementsByTagName("item"); log.debug("RBI FeedFetchService - Parsed {} items from RSS for category: {}", items.getLength(), category); List newItems = new ArrayList<>(); - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); for (int i = 0; i < items.getLength(); i++) { Element item = (Element) items.item(i); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/api/EmailLoginOtpApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/api/EmailLoginOtpApiResource.java index a98b1f057..7bd8c7761 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/api/EmailLoginOtpApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/api/EmailLoginOtpApiResource.java @@ -18,6 +18,8 @@ */ package org.apache.fineract.infrastructure.security.otp.api; +import com.google.common.base.Splitter; +import com.google.common.collect.Iterables; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import io.swagger.v3.oas.annotations.Operation; @@ -107,7 +109,7 @@ public class EmailLoginOtpApiResource { } final String fwd = req.getHeader("X-Forwarded-For"); if (fwd != null && !fwd.isBlank()) { - return fwd.split(",")[0].trim(); + return Iterables.get(Splitter.on(',').split(fwd), 0).trim(); } return req.getRemoteAddr(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/api/LoginOtpApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/api/LoginOtpApiResource.java index a7f1e78d6..c1c739abf 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/api/LoginOtpApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/api/LoginOtpApiResource.java @@ -18,6 +18,8 @@ */ package org.apache.fineract.infrastructure.security.otp.api; +import com.google.common.base.Splitter; +import com.google.common.collect.Iterables; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import io.swagger.v3.oas.annotations.Operation; @@ -107,7 +109,7 @@ public class LoginOtpApiResource { } final String fwd = req.getHeader("X-Forwarded-For"); if (fwd != null && !fwd.isBlank()) { - return fwd.split(",")[0].trim(); + return Iterables.get(Splitter.on(',').split(fwd), 0).trim(); } return req.getRemoteAddr(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/EmailLoginOtpServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/EmailLoginOtpServiceImpl.java index 2f41ee110..b732ad8a4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/EmailLoginOtpServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/EmailLoginOtpServiceImpl.java @@ -33,6 +33,7 @@ import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse; import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse; import org.apache.fineract.infrastructure.security.otp.domain.OtpLog; import org.apache.fineract.infrastructure.security.otp.domain.OtpLogRepository; +import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker; import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUserRepository; import org.apache.fineract.useradministration.mfa.domain.MfaFactorType; @@ -44,6 +45,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Service public class EmailLoginOtpServiceImpl implements EmailLoginOtpService { @@ -127,17 +129,17 @@ public class EmailLoginOtpServiceImpl implements EmailLoginOtpService { // Render the branded template — same look and feel for OTP, welcome, password-reset, alerts. final String html = emailTemplateService.renderOtp(recipientName, otp, validityMinutes); - final String subject = "Your OTP for Login - " + appName + " (" + LocalDateTime.now().format(SUBJECT_TIMESTAMP) + ")"; + final String subject = "Your OTP for Login - " + appName + " (" + LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()).format(SUBJECT_TIMESTAMP) + ")"; final boolean sent = emailSenderService.sendHtml(email, subject, html); if (sent) { log.setStatus(STATUS_SENT); final OtpLog saved = otpLogRepository.saveAndFlush(log); - LOG.info("Email login OTP sent. otpId={}, user={}, email={}", saved.getId(), username, mask(email)); + LOG.info("Email login OTP sent. otpId={}, user={}, email={}", saved.getId(), username, OtpDestinationMasker.maskEmail(email)); return OtpGenerateResponse.builder() .success(true) .otpId(saved.getId()) - .maskedMobile(mask(email)) // reusing the field; UI just shows where it was sent + .maskedMobile(OtpDestinationMasker.maskEmail(email)) // reusing the field; UI just shows where it was sent .expiresInSeconds(ttlSeconds) .message("OTP sent to your registered email") .build(); @@ -240,19 +242,6 @@ public class EmailLoginOtpServiceImpl implements EmailLoginOtpService { } } - private String mask(final String email) { - if (email == null || !email.contains("@")) { - return "****"; - } - final int at = email.indexOf('@'); - final String local = email.substring(0, at); - final String domain = email.substring(at); - if (local.length() <= 2) { - return "*" + local.substring(local.length() - 1) + domain; - } - return local.charAt(0) + "***" + local.charAt(local.length() - 1) + domain; - } - private String displayName(final AppUser user) { if (user.getFirstname() != null && !user.getFirstname().isBlank()) { return user.getFirstname(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/LoginOtpServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/LoginOtpServiceImpl.java index 754cee034..61e7a0d05 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/LoginOtpServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/LoginOtpServiceImpl.java @@ -31,6 +31,7 @@ import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse; import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse; import org.apache.fineract.infrastructure.security.otp.domain.OtpLog; import org.apache.fineract.infrastructure.security.otp.domain.OtpLogRepository; +import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker; import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUserRepository; import org.apache.fineract.useradministration.mfa.domain.MfaFactorType; @@ -156,12 +157,12 @@ public class LoginOtpServiceImpl implements LoginOtpService { log.setOtpReference(extractField(response, "reference_id", "refNumber", "ref_number", "transactionId", "txnId", "requestId")); final OtpLog saved = otpLogRepository.saveAndFlush(log); - LOG.info("LincPay login OTP sent. otpId={}, mobile={}", saved.getId(), mask(mobileNumber)); + LOG.info("LincPay login OTP sent. otpId={}, mobile={}", saved.getId(), OtpDestinationMasker.maskMobile(mobileNumber)); return OtpGenerateResponse.builder() .success(true) .otpId(saved.getId()) .otpReference(saved.getOtpReference()) - .maskedMobile(mask(mobileNumber)) + .maskedMobile(OtpDestinationMasker.maskMobile(mobileNumber)) .expiresInSeconds(ttlSeconds) .message("OTP sent") .build(); @@ -239,7 +240,7 @@ public class LoginOtpServiceImpl implements LoginOtpService { row.setVerifiedAt(OffsetDateTime.now(ZoneOffset.UTC)); otpLogRepository.saveAndFlush(row); autoPromoteFactor(row); - LOG.info("LincPay login OTP verified. otpId={}, mobile={}", row.getId(), mask(row.getMobileNumber())); + LOG.info("LincPay login OTP verified. otpId={}, mobile={}", row.getId(), OtpDestinationMasker.maskMobile(row.getMobileNumber())); return OtpVerifyResponse.builder().verified(true).message("OTP verified").build(); } @@ -364,13 +365,6 @@ public class LoginOtpServiceImpl implements LoginOtpService { } } - private String mask(final String mobile) { - if (mobile == null || mobile.length() < 4) { - return "****"; - } - return "******" + mobile.substring(mobile.length() - 4); - } - private String truncate(final String s, final int max) { if (s == null) { return null; diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java index a2475b4c2..f4aaf9f4a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java @@ -20,7 +20,6 @@ package org.apache.fineract.organisation.teller.service; import jakarta.persistence.PersistenceException; import java.util.Map; -import java.util.Set; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.exception.ExceptionUtils; @@ -44,7 +43,6 @@ import org.apache.fineract.organisation.teller.domain.CashierTransactionReposito import org.apache.fineract.organisation.teller.domain.CashierTxnType; import org.apache.fineract.organisation.teller.domain.Teller; import org.apache.fineract.organisation.teller.domain.TellerRepositoryWrapper; -import org.apache.fineract.organisation.teller.exception.CashierExistForTellerException; import org.apache.fineract.organisation.teller.exception.CashierNotFoundException; import org.apache.fineract.organisation.teller.exception.TellerAlreadyExistsException; import org.apache.fineract.organisation.teller.serialization.TellerCommandFromApiJsonDeserializer; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java index 6abef1d29..346accadd 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java @@ -60,6 +60,8 @@ public interface ShareAccountApiConstants { String externalid_paramname = "externalId"; + String resolutionnumber_paramname = "resolutionNumber"; + String currency_paramname = "currencyCode"; String digitsafterdecimal_paramname = "digitsAfterDecimal"; @@ -102,9 +104,10 @@ public interface ShareAccountApiConstants { Set supportedParameters = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(locale_paramname, dateformat_paramname, id_paramname, clientid_paramname, productid_paramname, submitteddate_paramname, approveddate_paramname, externalid_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))); + 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))); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/data/request/AccountRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/data/request/AccountRequest.java index 7181f7dc8..64f07ddba 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/data/request/AccountRequest.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/data/request/AccountRequest.java @@ -42,6 +42,7 @@ public class AccountRequest implements Serializable { private Long numberOfShares; private String allowDividendCalculationForInactiveClients; private String externalId; + private String resolutionNumber; private String minimumActivePeriodFrequencyType; private Long savingsAccountId; private String locale; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/address/service/AddressWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/address/service/AddressWritePlatformServiceImpl.java index 316c86bc0..66162c676 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/address/service/AddressWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/address/service/AddressWritePlatformServiceImpl.java @@ -40,6 +40,7 @@ import org.apache.fineract.portfolio.client.domain.ClientAddress; import org.apache.fineract.portfolio.client.domain.ClientAddressRepository; import org.apache.fineract.portfolio.client.domain.ClientAddressRepositoryWrapper; import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.apache.fineract.portfolio.client.service.ClientChangeNotificationService; import org.springframework.stereotype.Service; @Service @@ -53,6 +54,7 @@ public class AddressWritePlatformServiceImpl implements AddressWritePlatformServ private final AddressRepository addressRepository; private final ClientAddressRepositoryWrapper clientAddressRepositoryWrapper; private final AddressCommandFromApiJsonDeserializer fromApiJsonDeserializer; + private final ClientChangeNotificationService clientChangeNotificationService; @Override public CommandProcessingResult addClientAddress(final Long clientId, final Long addressTypeId, final JsonCommand command) { @@ -170,6 +172,10 @@ public class AddressWritePlatformServiceImpl implements AddressWritePlatformServ final Address addobj = this.addressRepository.getReferenceById(addressId); + // Snapshot before mutation — notification-only (no OTP gate for address; see class Javadoc / + // feature plan). Compared against the post-update state below to build the change message. + final String oldAddressSummary = formatAddressSummary(addobj); + if (!command.stringValueOfParameterNamed("addressLine1").isEmpty()) { is_address_update = true; @@ -271,6 +277,38 @@ public class AddressWritePlatformServiceImpl implements AddressWritePlatformServ } this.clientAddressRepository.saveAndFlush(clientAddressObj); + if (is_address_update) { + final String newAddressSummary = formatAddressSummary(addobj); + final Client client = clientAddressObj.getClient(); + this.clientChangeNotificationService.notifyContactOrAddressChanged(client, ClientChangeNotificationService.FIELD_LABEL_ADDRESS, + oldAddressSummary, newAddressSummary, client.getEmailAddress()); + } + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientAddressObj.getId()).build(); } + + /** + * Human-readable, comma-joined summary of the address's free-text fields — used only for the + * change-notification email body (see {@link ClientChangeNotificationService}), not persisted. + */ + private String formatAddressSummary(final Address address) { + final StringBuilder sb = new StringBuilder(); + appendIfPresent(sb, address.getAddressLine1()); + appendIfPresent(sb, address.getAddressLine2()); + appendIfPresent(sb, address.getAddressLine3()); + appendIfPresent(sb, address.getTownVillage()); + appendIfPresent(sb, address.getCity()); + appendIfPresent(sb, address.getCountyDistrict()); + appendIfPresent(sb, address.getPostalCode()); + return sb.length() == 0 ? "(not set)" : sb.toString(); + } + + private void appendIfPresent(final StringBuilder sb, final String value) { + if (value != null && !value.isBlank()) { + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(value); + } + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashexchange/api/CashExchangeApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashexchange/api/CashExchangeApiResource.java index 367433ddf..3ec138689 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashexchange/api/CashExchangeApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashexchange/api/CashExchangeApiResource.java @@ -33,6 +33,7 @@ import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Path("/v1/cashexchange") @Component @@ -94,7 +95,7 @@ public class CashExchangeApiResource { header.setNote(note); header.setCustomerIdProof(customerIdProof); header.setIsReversed(false); - header.setCreatedAt(LocalDateTime.now()); + header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); try { AppUser user = this.context.authenticatedUser(); if (user != null) { @@ -107,7 +108,9 @@ public class CashExchangeApiResource { + "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?", Long.class, user.getId()); header.setCashierId(cashierId); - } catch (Exception ignored) {} + } catch (Exception ignored) { + // intentionally ignored + } } } catch (Exception e) { // resolving the current user must never block recording the exchange @@ -160,14 +163,18 @@ public class CashExchangeApiResource { try { JsonObject json = JsonParser.parseString(body).getAsJsonObject(); reason = json.has("reason") && !json.get("reason").isJsonNull() ? json.get("reason").getAsString() : null; - } catch (Exception ignored) {} + } catch (Exception ignored) { + // intentionally ignored + } exchange.setIsReversed(true); - exchange.setReversedAt(LocalDateTime.now()); + exchange.setReversedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); exchange.setReverseReason(reason); try { AppUser user = this.context.authenticatedUser(); if (user != null) exchange.setReversedBy(user.getId()); - } catch (Exception ignored) {} + } catch (Exception ignored) { + // intentionally ignored + } repository.save(exchange); JsonObject result = new JsonObject(); result.addProperty("resourceId", id); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index 930c06782..2474b9c70 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -59,6 +59,9 @@ import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import com.google.common.collect.Iterables; +import com.google.common.base.Splitter; @Path("/v1/cashier/deposit-requests") @Component @@ -101,6 +104,13 @@ public class CashierDepositRequestApiResource { final String bankName = fromJsonHelper.extractStringNamed("bankName", root); final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root); final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root); + // DD + Cash + GL breakdown from Transaction Entry - null for ordinary deposit requests. + // Persisted so the Sponsor DD issuance screen can read the breakdown back instead of + // retyping face value/charges once this request is approved and selected as cash evidence. + final BigDecimal ddFaceValue = parseBigDecimal(fromJsonHelper.extractStringNamed("ddFaceValue", root)); + final BigDecimal ddCharges = parseBigDecimal(fromJsonHelper.extractStringNamed("ddCharges", root)); + final BigDecimal ddGstPercentage = parseBigDecimal(fromJsonHelper.extractStringNamed("ddGstPercentage", root)); + final BigDecimal ddGstAmount = parseBigDecimal(fromJsonHelper.extractStringNamed("ddGstAmount", root)); if (accountNo == null || accountNo.isBlank()) { throw apiError(400, "Account number is required"); @@ -115,6 +125,7 @@ public class CashierDepositRequestApiResource { throw apiError(400, "Amount must be greater than zero"); } validateDenominationTotal(denominationData, amount); + validateDdLimit(ddFaceValue); final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null); @@ -123,8 +134,9 @@ public class CashierDepositRequestApiResource { (account_no, account_type, savings_account_id, gl_account_id, contra_gl_id, office_id, currency_code, client_id, client_name, amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date, - status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at, + dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) RETURNING id """, Long.class, @@ -135,7 +147,8 @@ public class CashierDepositRequestApiResource { txnDate != null ? Date.valueOf(txnDate) : null, "PENDING", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), - denominationData, activityLog, Timestamp.from(Instant.now()) + denominationData, activityLog, Timestamp.from(Instant.now()), + ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount ); // Globally unique voucher number from the central m_voucher register @@ -189,7 +202,8 @@ public class CashierDepositRequestApiResource { + " amount, payment_type_id, payment_type_name, description, cheque_no, bank_name," + " txn_date, status, cashier_id, cashier_name, cashier_username," + " manager_id, manager_name, manager_username, rejection_reason, txn_id," - + " denomination_data, activity_log, submitted_at, actioned_at" + + " denomination_data, activity_log, submitted_at, actioned_at," + + " dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount" + " FROM m_cashier_deposit_request " + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?", pageParams.toArray()); @@ -242,7 +256,8 @@ public class CashierDepositRequestApiResource { SELECT id, voucher_no, account_no, account_type, client_name, amount, payment_type_name, description, txn_date, status, cashier_name, manager_name, manager_username, rejection_reason, txn_id, - submitted_at, actioned_at + submitted_at, actioned_at, + dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount FROM m_cashier_deposit_request """ + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?", pageParams.toArray() @@ -352,7 +367,7 @@ public class CashierDepositRequestApiResource { final BigDecimal amount = new BigDecimal(record.get("amount").toString()); final Object ptId = record.get("payment_type_id"); final Object txnDateObj = record.get("txn_date"); - final String txnDateStr = txnDateObj != null ? txnDateObj.toString().split(" ")[0] : null; + final String txnDateStr = txnDateObj != null ? Iterables.get(Splitter.on(' ').split(txnDateObj.toString()), 0) : null; final String description = (String) record.get("description"); final String storedAccountType = (String) record.get("account_type"); @@ -424,6 +439,7 @@ public class CashierDepositRequestApiResource { final JsonObject res = new JsonObject(); res.addProperty("resourceId", id); res.addProperty("status", "APPROVED"); + res.addProperty("voucherNo", Optional.ofNullable(record.get("voucher_no")).map(Object::toString).orElse("")); if (txnId != null) res.addProperty("txnId", txnId); return gson.toJson(res); } @@ -500,7 +516,7 @@ public class CashierDepositRequestApiResource { final BigDecimal amount = new BigDecimal(record.get("amount").toString()); final Object ptId = record.get("payment_type_id"); final Object txnDateObj = record.get("txn_date"); - final String txnDateStr = txnDateObj != null ? txnDateObj.toString().split(" ")[0] : null; + final String txnDateStr = txnDateObj != null ? Iterables.get(Splitter.on(' ').split(txnDateObj.toString()), 0) : null; final String description = (String) record.get("description"); if (savingsAccountId == null || !isCurrentAccountById(savingsAccountId)) { @@ -532,7 +548,8 @@ public class CashierDepositRequestApiResource { amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date, status, cashier_id, cashier_name, cashier_username, manager_id, manager_name, manager_username, rejection_reason, txn_id, - denomination_data, activity_log, submitted_at, actioned_at + denomination_data, activity_log, submitted_at, actioned_at, + dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount FROM m_cashier_deposit_request WHERE id = ? """, id); if (rows.isEmpty()) { @@ -545,7 +562,7 @@ public class CashierDepositRequestApiResource { final Long savingsAccountId, final BigDecimal amount, final Object paymentTypeId, final String txnDateStr, final String note) { - final String today = LocalDate.now().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; final JsonObject payload = new JsonObject(); @@ -573,7 +590,7 @@ public class CashierDepositRequestApiResource { final Long currentAccountId, final BigDecimal amount, final Object paymentTypeId, final String txnDateStr, final String note) { - final String today = LocalDate.now().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; final JsonObject payload = new JsonObject(); @@ -630,7 +647,7 @@ public class CashierDepositRequestApiResource { final Object paymentTypeId, final String txnDateStr, final String note, final boolean isDeposit) { - final String today = LocalDate.now().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; // Deposit (CR): cashier receives cash → credit selected GL, debit cash vault (contra) @@ -672,67 +689,6 @@ public class CashierDepositRequestApiResource { return result.getTransactionId() != null ? result.getTransactionId() : null; } - private void saveGlDenomination(final String denominationData, - final Map record, final String txnIdRaw) { - final JsonObject denomMap; - try { - denomMap = gson.fromJson(denominationData, JsonObject.class); - } catch (Exception e) { - return; - } - if (denomMap == null || denomMap.size() == 0) return; - - // Convert hex transaction ID to Long (same logic as CashierGlDenominationApiResource) - Long txnId = null; - if (txnIdRaw != null && !txnIdRaw.isBlank()) { - try { - txnId = Long.valueOf(txnIdRaw); - } catch (NumberFormatException ignored) { - try { - final String hex = txnIdRaw.length() > 15 ? txnIdRaw.substring(txnIdRaw.length() - 15) : txnIdRaw; - txnId = Long.parseUnsignedLong(hex, 16); - } catch (NumberFormatException ignored2) { - txnId = null; - } - } - } - - final String accountNo = (String) record.get("account_no"); - final Object cashierIdObj = record.get("cashier_id"); - final Long cashierId = cashierIdObj != null ? Long.valueOf(cashierIdObj.toString()) : null; - final String cashierUsername = (String) record.get("cashier_username"); - - for (final Map.Entry entry : denomMap.entrySet()) { - final int count; - try { count = entry.getValue().getAsInt(); } catch (Exception e) { continue; } - if (count <= 0) continue; - - final String[] parts = entry.getKey().split(":", 2); - if (parts.length != 2) continue; - final String kind = parts[0]; - final int value; - try { value = Integer.parseInt(parts[1]); } catch (Exception e) { continue; } - - final List ids = jdbcTemplate.queryForList( - "SELECT id FROM m_denomination_master WHERE denomination = ? AND kind = ?" - + " AND (deleted IS NULL OR deleted = false) LIMIT 1", - Long.class, value, kind); - if (ids.isEmpty()) continue; - final Long denomId = ids.get(0); - - jdbcTemplate.update(""" - INSERT INTO m_savings_txn_denomination - (txn_id, savings_account_id, account_no, client_id, denomination_id, - denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username) - VALUES (?, NULL, ?, NULL, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?) - """, - txnId, accountNo, denomId, - value, count, BigDecimal.valueOf((long) value * count), - cashierId, cashierUsername - ); - } - } - /** * Saves the denomination breakdown the cashier entered at submit time into * m_savings_txn_denomination once the deposit is approved, so the cashier's @@ -783,56 +739,6 @@ public class CashierDepositRequestApiResource { } } - private void saveDepositDenomination(final String denominationData, - final Map record, final Long txnId) { - final JsonObject denomMap; - try { - denomMap = gson.fromJson(denominationData, JsonObject.class); - } catch (Exception e) { - return; // malformed JSON — skip silently - } - if (denomMap == null || denomMap.size() == 0) return; - - final Object saObj = record.get("savings_account_id"); - final Long savingsAccountId = saObj != null ? Long.valueOf(saObj.toString()) : null; - final String accountNo = (String) record.get("account_no"); - final Object clientIdObj = record.get("client_id"); - final Long clientId = clientIdObj != null ? Long.valueOf(clientIdObj.toString()) : null; - final Object cashierIdObj = record.get("cashier_id"); - final Long cashierId = cashierIdObj != null ? Long.valueOf(cashierIdObj.toString()) : null; - final String cashierUsername = (String) record.get("cashier_username"); - - for (final Map.Entry entry : denomMap.entrySet()) { - final int count; - try { count = entry.getValue().getAsInt(); } catch (Exception e) { continue; } - if (count <= 0) continue; - - final String[] parts = entry.getKey().split(":", 2); // "note:500" or "coin:1" - if (parts.length != 2) continue; - final String kind = parts[0]; - final int value; - try { value = Integer.parseInt(parts[1]); } catch (Exception e) { continue; } - - final List ids = jdbcTemplate.queryForList( - "SELECT id FROM m_denomination_master WHERE denomination = ? AND kind = ?" - + " AND (deleted IS NULL OR deleted = false) LIMIT 1", - Long.class, value, kind); - if (ids.isEmpty()) continue; - final Long denomId = ids.get(0); - - jdbcTemplate.update(""" - INSERT INTO m_savings_txn_denomination - (txn_id, savings_account_id, account_no, client_id, denomination_id, - denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?) - """, - txnId, savingsAccountId, accountNo, clientId, denomId, - value, count, BigDecimal.valueOf((long) value * count), - cashierId, cashierUsername - ); - } - } - /** * If a cash denomination breakdown is provided, its total must exactly equal * the deposit amount — prevents the recorded vault movement from drifting @@ -869,6 +775,26 @@ public class CashierDepositRequestApiResource { } } + /** + * DD + Cash + GL deposit requests carry a Face Value (ddFaceValue) - reject the cash intake + * itself, before the cashier even accepts the money, if it exceeds the admin-configured DD + * limit (m_sponsor_dd_gl_mapping.dd_limit_amount). The same limit is enforced again later at + * actual DD issuance (sponsordd module); this is a first line of defense so an over-limit + * amount never even gets accepted as cash. ddFaceValue is null for ordinary (non-DD) deposit + * requests, which this check skips entirely. Null/zero-or-negative limit means unconfigured. + */ + private void validateDdLimit(final BigDecimal ddFaceValue) { + if (ddFaceValue == null) return; + final List> rows = jdbcTemplate.queryForList( + "SELECT dd_limit_amount FROM m_sponsor_dd_gl_mapping LIMIT 1"); + if (rows.isEmpty()) return; + final BigDecimal ddLimitAmount = (BigDecimal) rows.get(0).get("dd_limit_amount"); + if (ddLimitAmount == null || ddLimitAmount.compareTo(BigDecimal.ZERO) <= 0) return; + if (ddFaceValue.compareTo(ddLimitAmount) > 0) { + throw apiError(400, "DD Face Value ₹" + ddFaceValue + " exceeds the configured DD limit of ₹" + ddLimitAmount + "."); + } + } + /** * Checker authorization: only manager-level users may approve or reject. * Allowed: super users (ALL_FUNCTIONS), or any role whose name contains @@ -912,7 +838,7 @@ public class CashierDepositRequestApiResource { final LocalDate ld = LocalDate.parse(isoDate, DateTimeFormatter.ISO_LOCAL_DATE); return ld.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); } catch (Exception e) { - return LocalDate.now().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/api/CashInterCashierApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/api/CashInterCashierApiResource.java index a3d76e4f5..2f72e2ad2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/api/CashInterCashierApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/api/CashInterCashierApiResource.java @@ -31,6 +31,7 @@ import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierRep import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Path("/v1/cashintercashier") @Component @@ -69,7 +70,7 @@ public class CashInterCashierApiResource { } final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull() - ? LocalDate.parse(json.get("transferDate").getAsString()) : LocalDate.now(); + ? LocalDate.parse(json.get("transferDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); final String fromParty = json.has("fromParty") && !json.get("fromParty").isJsonNull() ? json.get("fromParty").getAsString() : null; final String toParty = json.has("toParty") && !json.get("toParty").isJsonNull() @@ -95,13 +96,15 @@ public class CashInterCashierApiResource { header.setToParty(toParty); header.setTransferAmount(total); header.setNote(note); - header.setCreatedAt(LocalDateTime.now()); + header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); header.setIsReversed(false); try { AppUser user = this.context.authenticatedUser(); header.setPerformedBy(user.getId()); header.setPerformedByUsername(user.getUsername()); - } catch (Exception ignored) {} + } catch (Exception ignored) { + // intentionally ignored + } CashInterCashier saved = repository.saveAndFlush(header); saveDetails(saved.getId(), denominations); @@ -128,13 +131,17 @@ public class CashInterCashierApiResource { try { JsonObject json = JsonParser.parseString(body).getAsJsonObject(); reason = json.has("reason") ? json.get("reason").getAsString() : null; - } catch (Exception ignored) {} + } catch (Exception ignored) { + // intentionally ignored + } AppUser user = null; - try { user = this.context.authenticatedUser(); } catch (Exception ignored) {} + try { user = this.context.authenticatedUser(); } catch (Exception ignored) { + // intentionally ignored + } record.setIsReversed(true); - record.setReversedAt(LocalDateTime.now()); + record.setReversedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); record.setReverseReason(reason); if (user != null) record.setReversedBy(user.getId()); repository.save(record); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java index 9d8cf0325..cc922d638 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java @@ -32,6 +32,7 @@ import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Path("/v1/cashreconciliation") @Component @@ -62,7 +63,7 @@ public class CashReconciliationApiResource { throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required", "cashierId query param is required."); } - final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now().toString(); + final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); // In: vault → cashier allocations — GROUP BY denomination_id+kind to keep coin and note separate final String sqlIn1 = "SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count " @@ -187,7 +188,7 @@ public class CashReconciliationApiResource { @Path("expected/me") public String getExpectedForMe(@QueryParam("asOfDate") String asOfDate) { context.authenticatedUser().validateHasPermissionTo("READ_CASHRECONCILIATION"); - final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now().toString(); + final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); Long appUserId; try { appUserId = context.authenticatedUser().getId(); @@ -223,7 +224,7 @@ public class CashReconciliationApiResource { throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required", "cashierId query param is required."); } - final String reconDate = (date != null && !date.isBlank()) ? date : LocalDate.now().toString(); + final String reconDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM m_cash_reconciliation WHERE cashier_id = ? AND recon_date = CAST(? AS DATE)", Integer.class, cashierId, reconDate); @@ -247,7 +248,7 @@ public class CashReconciliationApiResource { "cashierId is required."); } final LocalDate reconDate = json.has("reconDate") && !json.get("reconDate").isJsonNull() - ? LocalDate.parse(json.get("reconDate").getAsString()) : LocalDate.now(); + ? LocalDate.parse(json.get("reconDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); final String cashierName = json.has("cashierName") && !json.get("cashierName").isJsonNull() ? json.get("cashierName").getAsString() : null; final Long tellerId = json.has("tellerId") && !json.get("tellerId").isJsonNull() @@ -299,12 +300,14 @@ public class CashReconciliationApiResource { header.setVariance(variance); header.setStatus(status); header.setNote(note); - header.setCreatedAt(LocalDateTime.now()); + header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); try { AppUser user = this.context.authenticatedUser(); header.setPerformedBy(user.getId()); header.setPerformedByUsername(user.getUsername()); - } catch (Exception ignored) {} + } catch (Exception ignored) { + // intentionally ignored + } CashReconciliation saved = repository.saveAndFlush(header); for (CashReconciliationDetail d : details) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index 3e29e1d0c..f0cb31fce 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -18,6 +18,7 @@ import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Path("/v1/cashreports") @Component @@ -34,7 +35,7 @@ public class CashReportApiResource { @GET @Path("closing") public String closing(@QueryParam("date") String date) { - final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now().toString(); + final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); // Single query — conditional aggregation for deposit, withdrawal, net per denomination final String sql = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, " @@ -238,7 +239,7 @@ public class CashReportApiResource { return gson.toJson(result); } - final String today = LocalDate.now().toString(); + final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); // Find the earliest transfer date before today for which no settlement exists on or after that date final String sql = "SELECT MIN(ct.transfer_date) AS earliest_unsettled " @@ -256,7 +257,9 @@ public class CashReportApiResource { java.sql.Date earliestDate = null; try { earliestDate = jdbcTemplate.queryForObject(sql, java.sql.Date.class, cashierId, today, cashierId); - } catch (Exception ignored) {} + } catch (Exception ignored) { + // intentionally ignored + } final boolean unsettled = earliestDate != null; final String unsettledSince = earliestDate != null ? earliestDate.toString() : null; @@ -265,7 +268,9 @@ public class CashReportApiResource { if (unsettled) { try { pendingAmount = denominationBreakdownWriter.getCashierTotalCash(cashierId); - } catch (Exception ignored) {} + } catch (Exception ignored) { + // intentionally ignored + } } // Check whether today's morning cash transfer has been received @@ -276,7 +281,9 @@ public class CashReportApiResource { Integer todayTransferCount = 0; try { todayTransferCount = jdbcTemplate.queryForObject(morningTransferSql, Integer.class, cashierId, today); - } catch (Exception ignored) {} + } catch (Exception ignored) { + // intentionally ignored + } final boolean morningTransferReceived = todayTransferCount != null && todayTransferCount > 0; result.put("cashierId", cashierId); @@ -295,7 +302,7 @@ public class CashReportApiResource { @Path("cashier-position") public String cashierPosition(@QueryParam("cashierId") Long cashierId, @QueryParam("asOfDate") String asOfDate) { - final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now().toString(); + final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); // CASHIER role: always scope to their own position only final Long scopedId = cashierRoleHelper.getCashierIdForFiltering(); @@ -326,7 +333,7 @@ public class CashReportApiResource { @GET @Path("cashier-position/me") public String cashierPositionMe(@QueryParam("asOfDate") String asOfDate) { - final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now().toString(); + final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); Long appUserId; try { appUserId = context.authenticatedUser().getId(); @@ -362,7 +369,7 @@ public class CashReportApiResource { @Path("cashier-morning") public String cashierMorning(@QueryParam("cashierId") Long cashierId, @QueryParam("date") String date) { - final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now().toString(); + final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); if (cashierId == null) { Map empty = new LinkedHashMap<>(); empty.put("cashierId", null); @@ -515,7 +522,9 @@ public class CashReportApiResource { cashierName = jdbcTemplate.queryForObject( "SELECT ms.display_name FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id WHERE mc.id = ?", String.class, cashierId); - } catch (Exception ignored) {} + } catch (Exception ignored) { + // intentionally ignored + } Map result = new LinkedHashMap<>(); result.put("cashierId", cashierId); @@ -562,7 +571,7 @@ public class CashReportApiResource { @QueryParam("offset") Integer offset, @QueryParam("limit") Integer limit) { - final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now().toString(); + final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); final int resolvedLimit = (limit != null && limit > 0) ? limit : 20; final int resolvedOffset = (offset != null && offset >= 0) ? offset : 0; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java index a9b8b64d9..90007a91a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java @@ -36,6 +36,7 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Path("/v1/cashsettlement") @Component @@ -70,7 +71,7 @@ public class CashSettlementApiResource { } final LocalDate settlementDate = json.has("settlementDate") && !json.get("settlementDate").isJsonNull() - ? LocalDate.parse(json.get("settlementDate").getAsString()) : LocalDate.now(); + ? LocalDate.parse(json.get("settlementDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); final String fromParty = json.has("fromParty") && !json.get("fromParty").isJsonNull() ? json.get("fromParty").getAsString() : null; final String toParty = json.has("toParty") && !json.get("toParty").isJsonNull() @@ -116,13 +117,15 @@ public class CashSettlementApiResource { header.setToParty(toParty); header.setSettlementAmount(total); header.setNote(note); - header.setCreatedAt(LocalDateTime.now()); + header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); header.setIsReversed(false); try { AppUser user = this.context.authenticatedUser(); header.setPerformedBy(user.getId()); header.setPerformedByUsername(user.getUsername()); - } catch (Exception ignored) {} + } catch (Exception ignored) { + // intentionally ignored + } // Commit header + details first, then GL dispatch separately to avoid // EclipseLink flushing pending detail rows inside Fineract's GL transaction. @@ -142,7 +145,9 @@ public class CashSettlementApiResource { Long resolved = jdbcTemplate.queryForObject( "SELECT teller_id FROM m_cashiers WHERE id = ?", Long.class, fromCashierId); if (resolved != null) glTellerId = resolved; - } catch (Exception ignored) {} + } catch (Exception ignored) { + // intentionally ignored + } glDispatchService.settleCashFromCashier(glTellerId, fromCashierId, total, currencyCode, settlementDate.format(DateTimeFormatter.ISO_LOCAL_DATE), note != null ? note : ""); @@ -174,13 +179,17 @@ public class CashSettlementApiResource { try { JsonObject json = JsonParser.parseString(body).getAsJsonObject(); reason = json.has("reason") ? json.get("reason").getAsString() : null; - } catch (Exception ignored) {} + } catch (Exception ignored) { + // intentionally ignored + } AppUser user = null; - try { user = this.context.authenticatedUser(); } catch (Exception ignored) {} + try { user = this.context.authenticatedUser(); } catch (Exception ignored) { + // intentionally ignored + } settlement.setIsReversed(true); - settlement.setReversedAt(LocalDateTime.now()); + settlement.setReversedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); settlement.setReverseReason(reason); if (user != null) settlement.setReversedBy(user.getId()); repository.save(settlement); @@ -192,12 +201,16 @@ public class CashSettlementApiResource { Long resolved = jdbcTemplate.queryForObject( "SELECT teller_id FROM m_cashiers WHERE id = ?", Long.class, settlement.getFromCashierId()); if (resolved != null) glTellerId = resolved; - } catch (Exception ignored) {} + } catch (Exception ignored) { + // intentionally ignored + } glDispatchService.allocateCashToCashier(glTellerId, settlement.getFromCashierId(), settlement.getSettlementAmount(), "INR", - LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE), + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ISO_LOCAL_DATE), "Reversal of cash settlement #" + id + (reason != null ? ": " + reason : "")); - } catch (Exception ignored) {} + } catch (Exception ignored) { + // intentionally ignored + } } JsonObject result = new JsonObject(); @@ -214,7 +227,7 @@ public class CashSettlementApiResource { throw new PlatformDataIntegrityException("error.msg.cashsettlement.cashier.required", "cashierId query param is required."); } - final String settlementDate = (date != null && !date.isBlank()) ? date : LocalDate.now().toString(); + final String settlementDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM m_cash_settlement WHERE from_cashier_id = ? AND settlement_date = CAST(? AS DATE) AND (is_reversed IS NULL OR is_reversed = false)", Integer.class, cashierId, settlementDate); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java index 359aad652..615b0623e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java @@ -36,6 +36,7 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Path("/v1/cashtransfer") @Component @@ -105,7 +106,7 @@ public class CashTransferApiResource { header.setToCashierId(toCashierId); header.setTransferAmount(total); header.setNote(note); - header.setCreatedAt(LocalDateTime.now()); + header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); try { AppUser user = this.context.authenticatedUser(); if (user != null) { @@ -136,8 +137,10 @@ public class CashTransferApiResource { Long resolved = jdbcTemplate.queryForObject( "SELECT teller_id FROM m_cashiers WHERE id = ?", Long.class, toCashierId); if (resolved != null) glTellerId = resolved; - } catch (Exception ignored) {} - final LocalDate txnDate = transferDate != null ? transferDate : LocalDate.now(); + } catch (Exception ignored) { + // intentionally ignored + } + final LocalDate txnDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); glDispatchService.allocateCashToCashier(glTellerId, toCashierId, total, currencyCode, txnDate.format(DateTimeFormatter.ISO_LOCAL_DATE), note != null ? note : ""); @@ -169,15 +172,19 @@ public class CashTransferApiResource { try { JsonObject json = JsonParser.parseString(body).getAsJsonObject(); reason = json.has("reason") ? json.get("reason").getAsString() : null; - } catch (Exception ignored) {} + } catch (Exception ignored) { + // intentionally ignored + } try { AppUser user = this.context.authenticatedUser(); transfer.setReversedBy(user.getId()); - } catch (Exception ignored) {} + } catch (Exception ignored) { + // intentionally ignored + } transfer.setIsReversed(true); - transfer.setReversedAt(java.time.LocalDateTime.now()); + transfer.setReversedAt(java.time.LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); transfer.setReverseReason(reason); repository.save(transfer); @@ -188,12 +195,16 @@ public class CashTransferApiResource { Long resolved = jdbcTemplate.queryForObject( "SELECT teller_id FROM m_cashiers WHERE id = ?", Long.class, transfer.getToCashierId()); if (resolved != null) glTellerId = resolved; - } catch (Exception ignored) {} + } catch (Exception ignored) { + // intentionally ignored + } glDispatchService.settleCashFromCashier(glTellerId, transfer.getToCashierId(), transfer.getTransferAmount(), "INR", - java.time.LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE), + java.time.LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ISO_LOCAL_DATE), "Reversal of cash transfer #" + id + (reason != null ? ": " + reason : "")); - } catch (Exception ignored) {} + } catch (Exception ignored) { + // intentionally ignored + } } JsonObject result = new JsonObject(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashierGlDenominationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashierGlDenominationApiResource.java index 57e7d361a..002638999 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashierGlDenominationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashierGlDenominationApiResource.java @@ -38,6 +38,7 @@ import org.apache.fineract.portfolio.denominationmaster.domain.SavingsTxnDenomin import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** * Records cashier denomination breakdown for GL account transactions (journal entries). @@ -90,7 +91,7 @@ public class CashierGlDenominationApiResource { final String txnDateStr = fromJsonHelper.extractStringNamed("txnDate", root); final LocalDate txnDate = (txnDateStr != null && !txnDateStr.isBlank()) ? LocalDate.parse(txnDateStr) - : LocalDate.now(); + : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); if (!fromJsonHelper.parameterExists("denominationBreakdown", root)) { return "{\"result\":\"skipped\"}"; @@ -109,7 +110,9 @@ public class CashierGlDenominationApiResource { officerId = user.getId(); officerUsername = user.getUsername(); } - } catch (final Exception ignored) {} + } catch (final Exception ignored) { + // intentionally ignored + } final String resolvedTxnType = (txnType != null && !txnType.isBlank()) ? txnType.toUpperCase() @@ -144,7 +147,7 @@ public class CashierGlDenominationApiResource { .count(count) .totalAmount(lineTotal) .txnDate(txnDate) - .createdAt(LocalDateTime.now()) + .createdAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())) .txnType(resolvedTxnType) .officerId(officerId) .officerUsername(officerUsername) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java index c59def7ee..5e7d9c977 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java @@ -29,6 +29,7 @@ import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** * Cashier Share Collection API — POST /v1/cashier/sharecollection @@ -131,7 +132,7 @@ public class ShareCollectionApiResource { final LocalDate entryDate; try { entryDate = transactionDateStr != null - ? LocalDate.parse(transactionDateStr) : LocalDate.now(); + ? LocalDate.parse(transactionDateStr) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); } catch (DateTimeParseException e) { throw new PlatformDataIntegrityException( "error.msg.sharecollection.date.invalid", @@ -193,7 +194,7 @@ public class ShareCollectionApiResource { final String txnId = "SC-" + shareAccountId + "-" + System.currentTimeMillis(); final long userId = currentUser.getId(); final String collectorName = currentUser.getUsername(); - final LocalDateTime now = LocalDateTime.now(); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); // ── Post GL journal entries ──────────────────────────────────────── // Dr Cash at Vault (SHARES_REFERENCE) = totalAmount diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/api/CfcfrmsComplaintApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/api/CfcfrmsComplaintApiResource.java index 3daf73e02..55ae18e7b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/api/CfcfrmsComplaintApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/api/CfcfrmsComplaintApiResource.java @@ -41,7 +41,6 @@ import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSer import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.cfcfrms.data.ComplaintStatsData; import org.apache.fineract.portfolio.cfcfrms.data.ComplaintTicketData; -import org.apache.fineract.portfolio.cfcfrms.data.TimelineEventData; import org.apache.fineract.portfolio.cfcfrms.service.CfcfrmsComplaintService; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/api/CfcfrmsSimulatorApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/api/CfcfrmsSimulatorApiResource.java index a64fecca5..25674bc53 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/api/CfcfrmsSimulatorApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/api/CfcfrmsSimulatorApiResource.java @@ -56,7 +56,7 @@ import org.springframework.stereotype.Component; * POST /v1/simulator/cfcfrms/verify-account – Verify account at bank * POST /v1/simulator/cfcfrms/verify-transaction – Verify transaction ref * - * --- STATUS & REPORTING --- + * --- STATUS & REPORTING --- * GET /v1/simulator/cfcfrms/complaint-status/{ref} – Complaint status from CFCFRMS side * GET /v1/simulator/cfcfrms/bank-performance – Bank performance metrics * diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/seed/CfcfrmsDataSeeder.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/seed/CfcfrmsDataSeeder.java index 851e9e734..febad64b7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/seed/CfcfrmsDataSeeder.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/seed/CfcfrmsDataSeeder.java @@ -36,6 +36,7 @@ import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** * Seeds CFCFRMS complaint tickets, audit logs, and simulator logs on startup. @@ -59,7 +60,7 @@ public class CfcfrmsDataSeeder { } LOG.info("Seeding CFCFRMS complaint data..."); - final OffsetDateTime now = OffsetDateTime.now(); + final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant()); // ─── 3 RECEIVED ────────────────────────────────────────────── seedTicket("TKT-20250318-001", "Rajesh Kumar Sharma", "9876543210", diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsComplaintServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsComplaintServiceImpl.java index 378f0eb35..1ebe927b3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsComplaintServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsComplaintServiceImpl.java @@ -46,6 +46,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Service @RequiredArgsConstructor @@ -206,7 +207,7 @@ public class CfcfrmsComplaintServiceImpl implements CfcfrmsComplaintService { final Map result = new HashMap<>(); result.put("escalatedTo", nextIfsc); result.put("escalationRef", escalationRef); - result.put("timestamp", OffsetDateTime.now().toString()); + result.put("timestamp", OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant()).toString()); return result; } @@ -218,7 +219,7 @@ public class CfcfrmsComplaintServiceImpl implements CfcfrmsComplaintService { } private String generateTicketId() { - final String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + final String date = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("yyyyMMdd")); final int seq = (int) (ticketRepository.count() + 1); return String.format("TKT-%s-%03d", date, seq); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsSimulatorServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsSimulatorServiceImpl.java index 20f2d1175..879f1c980 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsSimulatorServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/service/CfcfrmsSimulatorServiceImpl.java @@ -25,12 +25,10 @@ import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; -import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Random; -import java.util.UUID; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.apache.fineract.portfolio.cfcfrms.data.CfcfrmsConstants; @@ -44,6 +42,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Service @RequiredArgsConstructor @@ -76,7 +75,7 @@ public class CfcfrmsSimulatorServiceImpl implements CfcfrmsSimulatorService { final Map response = new LinkedHashMap<>(); response.put("cfcfrmsRef", cfcfrmsRef); response.put("ticketId", ticketData.getTicketId()); - response.put("pushedToBankAt", OffsetDateTime.now().toString()); + response.put("pushedToBankAt", OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant()).toString()); response.put("bankAcknowledged", true); response.put("message", "Complaint pushed to " + CfcfrmsConstants.BANK_IFSC + " successfully"); return response; @@ -85,7 +84,7 @@ public class CfcfrmsSimulatorServiceImpl implements CfcfrmsSimulatorService { @Override public Map pushHoldDirective(final JsonObject json) { final String cfcfrmsRef = getStringOrThrow(json, "cfcfrmsRef"); - final String directiveRef = "DIR-" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")) + final String directiveRef = "DIR-" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("yyyyMMdd")) + "-" + String.format("%04d", RANDOM.nextInt(10000)); final String directiveType = getStringOrThrow(json, "directiveType"); @@ -112,7 +111,7 @@ public class CfcfrmsSimulatorServiceImpl implements CfcfrmsSimulatorService { response.put("directiveRef", directiveRef); response.put("status", "DIRECTIVE_SENT"); response.put("bankResponse", bankResponse); - response.put("timestamp", OffsetDateTime.now().toString()); + response.put("timestamp", OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant()).toString()); return response; } @@ -208,7 +207,7 @@ public class CfcfrmsSimulatorServiceImpl implements CfcfrmsSimulatorService { public Map receiveEscalation(final JsonObject json) { final String cfcfrmsRef = getStringOrThrow(json, "cfcfrmsRef"); final String ticketId = getStringOrThrow(json, "ticketId"); - final String escalationRef = "ESC-" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MMdd")) + final String escalationRef = "ESC-" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("yyyy-MMdd")) + "-" + String.format("%04d", RANDOM.nextInt(10000)); // Extract nextBank from fundsMovedTo @@ -268,7 +267,7 @@ public class CfcfrmsSimulatorServiceImpl implements CfcfrmsSimulatorService { final Map response = new LinkedHashMap<>(); response.put("transactionFound", true); response.put("transactionStatus", "COMPLETED"); - response.put("transactionTimestamp", OffsetDateTime.now().minusHours(2).toString()); + response.put("transactionTimestamp", OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant()).minusHours(2).toString()); response.put("senderAccount", json.has("accountNumber") ? json.get("accountNumber").getAsString() : "UNKNOWN"); response.put("receiverAccount", json.has("accountNumber") ? json.get("accountNumber").getAsString() : "UNKNOWN"); @@ -369,14 +368,14 @@ public class CfcfrmsSimulatorServiceImpl implements CfcfrmsSimulatorService { .setScale(1, java.math.RoundingMode.HALF_UP) + "%" : "0.0%"; response.put("tatComplianceRate", tatCompliance); - response.put("lastActivityAt", OffsetDateTime.now().toString()); + response.put("lastActivityAt", OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant()).toString()); return response; } // ── Helpers ── private String generateCfcfrmsRef() { - final String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MMdd")); + final String date = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("yyyy-MMdd")); return "CFCFRMS-" + date + "-" + String.format("%04d", RANDOM.nextInt(10000)); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/domain/ChequeStockLotRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/domain/ChequeStockLotRepository.java index 0c6676ed6..2f57d49e5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/domain/ChequeStockLotRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/domain/ChequeStockLotRepository.java @@ -28,12 +28,14 @@ public interface ChequeStockLotRepository extends JpaRepository findBySeriesIdAndOfficeIdAndStatusOrderByIdAsc(Long seriesId, Long officeId, String status); /** - * Anti-duplication control: a newly received range must not overlap any existing lot's range within the SAME - * series. Each series is its own stationery batch from the press (different leaf count/paper stock) and is - * numbered independently — two different series legitimately reusing the same numeric range is normal in - * real-world cheque stock, matching how the press assigns a fresh serial block per print order. + * Anti-duplication control: a newly received range must not overlap any existing lot's range, REGARDLESS of + * series. A real bank's leaf-serial numbers are allocated from one bank-wide numbering authority — two + * different cheque designs (series) never legitimately reuse the same numeric range, because whichever + * account eventually receives books from both would end up holding two leaves with the identical serial + * (a collision NpciInwardPushService's drawer-account-number disambiguation can't resolve when both leaves + * belong to the same account). This used to be scoped to "within the same series only", which is exactly + * what let that collision happen in the first place. */ - @Query("SELECT COUNT(l) FROM ChequeStockLot l WHERE l.series.id = :seriesId AND l.rangeStart <= :rangeEnd AND l.rangeEnd >= :rangeStart") - long countOverlappingInSeries(@Param("seriesId") long seriesId, @Param("rangeStart") long rangeStart, - @Param("rangeEnd") long rangeEnd); + @Query("SELECT COUNT(l) FROM ChequeStockLot l WHERE l.rangeStart <= :rangeEnd AND l.rangeEnd >= :rangeStart") + long countOverlappingAnySeries(@Param("rangeStart") long rangeStart, @Param("rangeEnd") long rangeEnd); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookReadPlatformServiceImpl.java index 1827f9a00..9bebce03b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookReadPlatformServiceImpl.java @@ -223,8 +223,10 @@ public class ChequeBookReadPlatformServiceImpl implements ChequeBookReadPlatform @Override public List retrieveLeafBySerialNo(final Long leafSerialNo) { - // a bare serial number is only unique WITHIN a series (see ChequeStockLotRepository.countOverlappingInSeries) — - // two different series can legitimately share a number, so this can return more than one match + // new stock lots are now checked for overlap across every series (see + // ChequeStockLotRepository.countOverlappingAnySeries), so a bare serial number stays globally unique going + // forward. Older lots received before that check existed can still collide, so this stays defensive and + // may return more than one match for pre-existing data. final String sql = "select " + this.leafMapper.schema() + " where f.leaf_serial_no = ? order by s.series_code asc"; final List leaves = this.jdbcTemplate.query(sql, this.leafMapper, leafSerialNo); if (leaves.isEmpty()) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookWritePlatformServiceImpl.java index e7e9e0b6a..2e0deae3e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookWritePlatformServiceImpl.java @@ -185,13 +185,14 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo @Override public CommandProcessingResult receiveStock(final JsonCommand command) { this.validator.validateForStockLotReceive(command.json()); - final Long seriesId = command.longValueOfParameterNamed(ChequeBookApiJsonInputParams.SERIES_ID.getValue()); final Long rangeStart = command.longValueOfParameterNamed(ChequeBookApiJsonInputParams.RANGE_START.getValue()); final Long rangeEnd = command.longValueOfParameterNamed(ChequeBookApiJsonInputParams.RANGE_END.getValue()); // fail fast for the maker; re-checked authoritatively at approval time in case stock changed meanwhile - if (this.chequeStockLotRepository.countOverlappingInSeries(seriesId, rangeStart, rangeEnd) > 0) { + if (this.chequeStockLotRepository.countOverlappingAnySeries(rangeStart, rangeEnd) > 0) { throw new GeneralPlatformDomainRuleException("error.msg.chequestocklot.range.overlap", - "The given leaf-serial range overlaps with an existing stock lot for this series.", rangeStart); + "The given leaf-serial range overlaps with an existing stock lot (checked across every series, " + + "not just this one) — pick a different range.", + rangeStart); } final Long pendingId = submitPendingRequest(ChequeBookPendingRequestType.RECEIVE_STOCK, command); return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(pendingId).build(); @@ -208,9 +209,11 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo final Long rangeEnd = command.longValueOfParameterNamed(ChequeBookApiJsonInputParams.RANGE_END.getValue()); final String batchRef = command.stringValueOfParameterNamedAllowingNull(ChequeBookApiJsonInputParams.BATCH_REF.getValue()); - if (this.chequeStockLotRepository.countOverlappingInSeries(seriesId, rangeStart, rangeEnd) > 0) { + if (this.chequeStockLotRepository.countOverlappingAnySeries(rangeStart, rangeEnd) > 0) { throw new GeneralPlatformDomainRuleException("error.msg.chequestocklot.range.overlap", - "The given leaf-serial range overlaps with an existing stock lot for this series.", rangeStart); + "The given leaf-serial range overlaps with an existing stock lot (checked across every series, " + + "not just this one) — pick a different range.", + rangeStart); } final ChequeSeries series = this.chequeSeriesRepositoryWrapper.findOneWithNotFoundDetection(seriesId); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/npci/service/NpciInwardPushService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/npci/service/NpciInwardPushService.java index f6ae6838f..f3637cf92 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/npci/service/NpciInwardPushService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/npci/service/NpciInwardPushService.java @@ -201,7 +201,7 @@ public class NpciInwardPushService { final String presentingBranchCode = getString(instrument, "depositBranchCode"); item.recordInwardMeta(getString(instrument, "serialNumber"), presentingBranchCode, batch.getReturnTatDeadline()); - final Boolean instrumentStopPaymentFlag = instrument.has("stopPaymentFlag") && !instrument.get("stopPaymentFlag").isJsonNull() + final Boolean instrumentStopPaymentFlag = (instrument.has("stopPaymentFlag") && !instrument.get("stopPaymentFlag").isJsonNull()) ? instrument.get("stopPaymentFlag").getAsBoolean() : null; item.recordSourceDetails(getString(instrument, "ctsReferenceNumber"), instrumentStopPaymentFlag, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index ad8d054c7..bbea5abf1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -70,6 +70,7 @@ import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Path("/v1/clerk/disbursement-requests") @Component @@ -383,7 +384,7 @@ public class ClerkDisbursementRequestApiResource { final JsonArray creditEntries = gson.fromJson(creditEntriesJson, JsonArray.class); final Object ptId = record.get("payment_type_id"); final String description = (String) record.get("description"); - final String txnDateStr = LocalDate.now().toString(); + final String txnDateStr = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); final Long sourceGlId = record.get("gl_account_id") != null ? ((Number) record.get("gl_account_id")).longValue() : null; @@ -556,7 +557,7 @@ public class ClerkDisbursementRequestApiResource { final String entryType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS"; final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : "?"; final Long entryPtId = entry.has("paymentTypeId") && !entry.get("paymentTypeId").isJsonNull() - ? entry.get("paymentTypeId").getAsLong() + ? Long.valueOf(entry.get("paymentTypeId").getAsLong()) : (ptId != null ? ((Number) ptId).longValue() : null); if ("GL".equals(entryType)) { @@ -810,7 +811,7 @@ public class ClerkDisbursementRequestApiResource { ? req.get("currencyCode").getAsString() : "INR"; final String txnDateStr = req.has("transactionDate") && !req.get("transactionDate").isJsonNull() ? req.get("transactionDate").getAsString() - : LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE); + : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ISO_LOCAL_DATE); final String description = req.has("description") && !req.get("description").isJsonNull() ? req.get("description").getAsString() : null; @@ -830,7 +831,6 @@ public class ClerkDisbursementRequestApiResource { // Validate all entries: savings/current only (no GL), savingsAccountId present, amount > 0 final Set drAccountIds = new LinkedHashSet<>(); - final Set crAccountIds = new LinkedHashSet<>(); for (final JsonObject e : drEntries) { final String acTyp = e.has("accountType") ? e.get("accountType").getAsString() : "SAVINGS"; @@ -858,7 +858,6 @@ public class ClerkDisbursementRequestApiResource { if (drAccountIds.contains(crId)) throw apiError(400, "Account " + (e.has("accountNo") ? e.get("accountNo").getAsString() : crId) + " cannot appear on both DR and CR sides of the same transfer."); - crAccountIds.add(crId); } // Validate balanced entry: total DR == total CR @@ -999,7 +998,7 @@ public class ClerkDisbursementRequestApiResource { * under concurrent inserts on the same date. */ private String generateBtVoucherNo(final long logId) { - return "BT" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyMMdd")) + return "BT" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("yyMMdd")) + String.format("%05d", logId); } @@ -1053,7 +1052,9 @@ public class ClerkDisbursementRequestApiResource { try { final Long txnIdLong = Long.parseLong(txnId); undoOps.add(() -> undoAccountTransaction(accountId, txnIdLong, isCurrentAccount)); - } catch (NumberFormatException ignored) {} + } catch (NumberFormatException ignored) { + // intentionally ignored + } } private void undoAccountTransaction(final Long accountId, final Long transactionId, @@ -1072,50 +1073,6 @@ public class ClerkDisbursementRequestApiResource { commandsSourceWritePlatformService.logCommandSource(cmd); } - private String executeSavingsDeposit( - final Long savingsAccountId, final BigDecimal amount, - final Long paymentTypeId, final String txnDateStr, final String note) { - - final String date = formatForFineract(txnDateStr); - final JsonObject payload = new JsonObject(); - payload.addProperty("locale", "en"); - payload.addProperty("dateFormat", "dd MMMM yyyy"); - payload.addProperty("transactionDate", date); - payload.addProperty("transactionAmount", amount); - if (paymentTypeId != null) payload.addProperty("paymentTypeId", paymentTypeId); - if (note != null && !note.isBlank()) payload.addProperty("note", note); - - final CommandWrapper cmd = new CommandWrapperBuilder() - .savingsAccountDeposit(savingsAccountId) - .withJson(payload.toString()) - .build(); - fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); - final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd); - return result.getResourceId() != null ? result.getResourceId().toString() : null; - } - - private String executeCurrentAccountDeposit( - final Long currentAccountId, final BigDecimal amount, - final Long paymentTypeId, final String txnDateStr, final String note) { - - final String date = formatForFineract(txnDateStr); - final JsonObject payload = new JsonObject(); - payload.addProperty("locale", "en"); - payload.addProperty("dateFormat", "dd MMMM yyyy"); - payload.addProperty("transactionDate", date); - payload.addProperty("transactionAmount", amount); - if (paymentTypeId != null) payload.addProperty("paymentTypeId", paymentTypeId); - if (note != null && !note.isBlank()) payload.addProperty("note", note); - - final CommandWrapper cmd = new CommandWrapperBuilder() - .currentAccountDeposit(currentAccountId) - .withJson(payload.toString()) - .build(); - fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); - final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd); - return result.getResourceId() != null ? result.getResourceId().toString() : null; - } - private boolean isCurrentAccountById(final Long accountId) { try { final Integer count = jdbcTemplate.queryForObject( @@ -1131,7 +1088,7 @@ public class ClerkDisbursementRequestApiResource { final LocalDate ld = LocalDate.parse(isoDate, DateTimeFormatter.ISO_LOCAL_DATE); return ld.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); } catch (Exception e) { - return LocalDate.now().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); } } @@ -1380,7 +1337,7 @@ public class ClerkDisbursementRequestApiResource { ? (BigDecimal) accts.get(0).get("account_balance_derived") : BigDecimal.ZERO; final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate); - final OffsetDateTime now = OffsetDateTime.now(); + final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant()); final String refNo = UUID.randomUUID().toString().replace("-", "").substring(0, 20); final Long userId = currentUser.getId(); @@ -1534,7 +1491,7 @@ public class ClerkDisbursementRequestApiResource { + ". Available: " + currentBalance + ", Requested: " + amount); final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate); - final OffsetDateTime now = OffsetDateTime.now(); + final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant()); final String refNo = UUID.randomUUID().toString().replace("-", "").substring(0, 20); final Long userId = currentUser.getId(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java index bd7055cc6..813e80602 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java @@ -59,6 +59,9 @@ import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import com.google.common.collect.Iterables; +import com.google.common.base.Splitter; @Path("/v1/clerk/withdrawal-requests") @Component @@ -437,7 +440,7 @@ public class ClerkWithdrawalRequestApiResource { final BigDecimal amount = new BigDecimal(record.get("amount").toString()); final Object ptId = record.get("payment_type_id"); final Object txnDateObj = record.get("txn_date"); - final String txnDateStr = txnDateObj != null ? txnDateObj.toString().split(" ")[0] : null; + final String txnDateStr = txnDateObj != null ? Iterables.get(Splitter.on(' ').split(txnDateObj.toString()), 0) : null; final String description = (String) record.get("description"); String txnId = null; @@ -713,7 +716,7 @@ public class ClerkWithdrawalRequestApiResource { final Long savingsAccountId, final BigDecimal amount, final Object paymentTypeId, final String txnDateStr, final String note) { - final String today = LocalDate.now().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; final JsonObject payload = new JsonObject(); @@ -741,7 +744,7 @@ public class ClerkWithdrawalRequestApiResource { final Long currentAccountId, final BigDecimal amount, final Object paymentTypeId, final String txnDateStr, final String note) { - final String today = LocalDate.now().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; final JsonObject payload = new JsonObject(); @@ -780,7 +783,7 @@ public class ClerkWithdrawalRequestApiResource { final LocalDate ld = LocalDate.parse(isoDate, DateTimeFormatter.ISO_LOCAL_DATE); return ld.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); } catch (Exception e) { - return LocalDate.now().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientEditApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientEditApiResource.java new file mode 100644 index 000000000..f2437a0c0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientEditApiResource.java @@ -0,0 +1,164 @@ +/** + * 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.Parameter; +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.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.UriInfo; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.client.data.ClientPendingEditData; +import org.apache.fineract.portfolio.client.service.ClientEditWritePlatformService; +import org.apache.fineract.portfolio.client.service.ClientPendingEditReadPlatformService; +import org.springframework.stereotype.Component; + +@Path("/v1/clients") +@Component +@Tag(name = "Client Edit Approval", + description = "Maker-checker workflow for editing an existing Client's core profile details. Edits are held in " + + "m_client_pending_edit (a pending row there is itself the lock) and only applied to the real client once a " + + "different user (the checker) approves — see /clients/{clientId}/edit/approve and /reject below.") +@RequiredArgsConstructor +public class ClientEditApiResource { + + private final PlatformSecurityContext context; + private final ClientEditWritePlatformService clientEditWritePlatformService; + private final ClientPendingEditReadPlatformService clientPendingEditReadPlatformService; + private final FromJsonHelper fromApiJsonHelper; + private final ApiRequestParameterHelper apiRequestParameterHelper; + private final DefaultToApiJsonSerializer toApiJsonSerializer; + private final DefaultToApiJsonSerializer pendingEditApiJsonSerializer; + + @POST + @Path("{clientId}/edit") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Submit a details edit for a Client", + description = "Stages the proposed changes in m_client_pending_edit instead of applying them immediately — see " + + "/clients/{clientId}/edit/approve and /reject below.") + public String submitEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + + this.context.authenticatedUser().validateHasPermissionTo("SUBMITEDIT_CLIENT"); + + final JsonCommand command = JsonCommand.fromJsonElement(clientId, this.fromApiJsonHelper.parse(apiRequestBodyAsJson), + this.fromApiJsonHelper); + + final CommandProcessingResult result = this.clientEditWritePlatformService.submitEditForApproval(clientId, command); + + return this.toApiJsonSerializer.serialize(result); + } + + @GET + @Path("edit/pending") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "List Client details edits pending approval", + description = "Supports status/clientName/fromDate/toDate filtering (fromDate/toDate as yyyy-MM-dd, inclusive, filtered " + + "on submitted date; clientName is a case-insensitive substring match) plus offset/limit pagination — mirrors " + + "/chequebook/pending-requests.") + public Page retrieveAllPendingEdits(@QueryParam("status") final String status, + @QueryParam("clientName") final String clientName, @QueryParam("fromDate") final String fromDate, + @QueryParam("toDate") final String toDate, @QueryParam("offset") @DefaultValue("0") final Integer offset, + @QueryParam("limit") @DefaultValue("20") final Integer limit) { + + this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME); + + final int safeLimit = Math.min(limit, 200); + return this.clientPendingEditReadPlatformService.retrieveAllPendingEdits(status, clientName, fromDate, toDate, offset, safeLimit); + } + + @GET + @Path("{clientId}/edit/pending") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve the pending details edit for a Client, if any") + public String retrievePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId, + @Context final UriInfo uriInfo) { + + this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME); + + final ClientPendingEditData pendingEdit = this.clientPendingEditReadPlatformService.retrievePendingEdit(clientId); + + final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters()); + return this.pendingEditApiJsonSerializer.serialize(settings, pendingEdit); + } + + @POST + @Path("{clientId}/edit/approve") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Approve a pending Client details edit", + description = "Applies the proposed changes to the client. The maker of the edit cannot also approve it — no exceptions.") + public String approvePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId) { + + if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVEEDIT_CLIENT")) { + throw new NoAuthorizationException("User has no authority to approve client edits"); + } + + final CommandProcessingResult result = this.clientEditWritePlatformService.approveEdit(clientId); + + return this.toApiJsonSerializer.serialize(result); + } + + @POST + @Path("{clientId}/edit/reject") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Reject a pending Client details edit", + description = "Discards the proposed changes without applying them. The maker of the edit cannot also reject it — no exceptions.") + public String rejectPendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + + if (this.context.authenticatedUser().hasNotPermissionForAnyOf("REJECTEDIT_CLIENT")) { + throw new NoAuthorizationException("User has no authority to reject client edits"); + } + + String rejectReason = null; + if (apiRequestBodyAsJson != null && !apiRequestBodyAsJson.isBlank()) { + final var parsed = this.fromApiJsonHelper.parse(apiRequestBodyAsJson); + if (parsed != null && parsed.isJsonObject() && parsed.getAsJsonObject().has("rejectReason")) { + rejectReason = parsed.getAsJsonObject().get("rejectReason").getAsString(); + } + } + + final CommandProcessingResult result = this.clientEditWritePlatformService.rejectEdit(clientId, rejectReason); + + return this.toApiJsonSerializer.serialize(result); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycApiResource.java index 46d5add4b..ebc4e49a3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycApiResource.java @@ -39,6 +39,8 @@ import jakarta.ws.rs.core.MediaType; import java.io.InputStream; import org.glassfish.jersey.media.multipart.FormDataContentDisposition; import org.glassfish.jersey.media.multipart.FormDataParam; +import java.time.LocalDate; +import java.time.Period; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -158,7 +160,13 @@ public class ClientKycApiResource { template.put("clientTypeName", clientTypeName); template.put("legalFormId", legalFormId); - template.put("requiredDocuments", getRequiredDocuments(clientTypeName, legalFormId)); + // Minor detection mirrors the frontend's own rule (age < 18) so the document list + // served here stays consistent with what the UI computes from the same date of birth. + final LocalDate dateOfBirth = client.dateOfBirth(); + final boolean isMinor = dateOfBirth != null && Period.between(dateOfBirth, LocalDate.now()).getYears() < 18; + template.put("isMinor", isMinor); + + template.put("requiredDocuments", getRequiredDocuments(clientTypeName, legalFormId, isMinor)); return toApiJsonSerializer.serialize(template); } @@ -166,11 +174,21 @@ public class ClientKycApiResource { /** * Returns required KYC documents based on client type (RBI Master Direction on KYC). */ - private List> getRequiredDocuments(final String clientTypeName, final Integer legalFormId) { + private List> getRequiredDocuments(final String clientTypeName, final Integer legalFormId, + final boolean isMinor) { final List> docs = new ArrayList<>(); // Individual types (legalFormId=1) if (legalFormId != null && legalFormId == 1) { + if (isMinor) { + // Minors cannot hold PAN, Voter ID, Driving Licence or an NREGA job card — RBI + // accepts Aadhaar, Passport, Birth Certificate or School ID Card instead. + docs.add(doc("Aadhaar Card", "AADHAAR", true)); + docs.add(doc("Birth Certificate", "BIRTH_CERTIFICATE", false)); + docs.add(doc("School ID Card", "SCHOOL_ID_CARD", false)); + docs.add(doc("Passport", "PASSPORT", false)); + return docs; + } docs.add(doc("Aadhaar Card", "AADHAAR", true)); docs.add(doc("PAN Card", "PAN", true)); docs.add(doc("Voter ID", "VOTER_ID", false)); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientsApiResource.java index d348b42a6..19a64c334 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientsApiResource.java @@ -44,6 +44,7 @@ import java.time.LocalDate; import java.util.Collection; import java.util.List; import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; import org.apache.fineract.commands.data.AuditData; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.AuditReadPlatformService; @@ -142,7 +143,8 @@ public class ClientsApiResource { @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "List Clients", description = "The list capability of clients can support pagination and sorting.\n\n" + "Example Requests:\n" + "\n" + "clients\n" + "\n" + "clients?fields=displayName,officeName,timeline\n" + "\n" - + "clients?offset=10&limit=50\n" + "\n" + "clients?orderBy=displayName&sortOrder=DESC") + + "clients?offset=10&limit=50\n" + "\n" + "clients?orderBy=displayName&sortOrder=DESC\n\n" + + "clients?fromDate=2026-01-01&toDate=2026-01-31 (submittedon_date range, yyyy-MM-dd, inclusive)") @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.GetClientsResponse.class))) public String retrieveAll(@Context final UriInfo uriInfo, @QueryParam("officeId") @Parameter(description = "officeId") final Long officeId, @@ -157,10 +159,15 @@ public class ClientsApiResource { @QueryParam("orderBy") @Parameter(description = "orderBy") final String orderBy, @QueryParam("sortOrder") @Parameter(description = "sortOrder") final String sortOrder, @QueryParam("orphansOnly") @Parameter(description = "orphansOnly") final Boolean orphansOnly, - @QueryParam("legalForm") final Integer legalForm) { + @QueryParam("legalForm") final Integer legalForm, + @QueryParam("fromDate") @Parameter(description = "fromDate") final String fromDate, + @QueryParam("toDate") @Parameter(description = "toDate") final String toDate) { + + final LocalDate fromDateParsed = StringUtils.isNotBlank(fromDate) ? LocalDate.parse(fromDate) : null; + final LocalDate toDateParsed = StringUtils.isNotBlank(toDate) ? LocalDate.parse(toDate) : null; return retrieveAll(uriInfo, officeId, externalId, displayName, firstname, lastname, status, legalForm, hierarchy, offset, limit, - orderBy, sortOrder, orphansOnly, false); + orderBy, sortOrder, orphansOnly, false, fromDateParsed, toDateParsed); } @GET @@ -437,7 +444,7 @@ public class ClientsApiResource { public String retrieveAll(final UriInfo uriInfo, final Long officeId, final String externalId, final String displayName, final String firstname, final String lastname, final String status, final Integer legalForm, final String hierarchy, final Integer offset, final Integer limit, final String orderBy, final String sortOrder, final Boolean orphansOnly, - final boolean isSelfUser) { + final boolean isSelfUser, final LocalDate fromDate, final LocalDate toDate) { context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME); sqlValidator.validate(orderBy); sqlValidator.validate(sortOrder); @@ -445,7 +452,8 @@ public class ClientsApiResource { sqlValidator.validate(hierarchy); final SearchParameters searchParameters = SearchParameters.builder().limit(limit).officeId(officeId).externalId(externalId) .name(displayName).hierarchy(hierarchy).firstname(firstname).lastname(lastname).status(status).orphansOnly(orphansOnly) - .isSelfUser(isSelfUser).offset(offset).orderBy(orderBy).sortOrder(sortOrder).legalForm(legalForm).build(); + .isSelfUser(isSelfUser).offset(offset).orderBy(orderBy).sortOrder(sortOrder).legalForm(legalForm).fromDate(fromDate) + .toDate(toDate).build(); final Page clientData = clientReadPlatformService.retrieveAll(searchParameters); final ApiRequestJsonSerializationSettings settings = apiRequestParameterHelper.process(uriInfo.getQueryParameters()); return toApiJsonSerializer.serialize(settings, clientData, ClientApiConstants.CLIENT_RESPONSE_DATA_PARAMETERS); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/KycRiskRulesApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/KycRiskRulesApiResource.java new file mode 100644 index 000000000..45abba10b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/KycRiskRulesApiResource.java @@ -0,0 +1,55 @@ +/** + * 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.KycRiskRuleData; +import org.apache.fineract.portfolio.client.service.KycRiskRulesReadPlatformService; +import org.springframework.stereotype.Component; + +@Path("/v1/kyc/risk-rules") +@Component +@Tag(name = "KYC Risk Rules", description = "Retrieve database-driven master rules for client AML/CFT risk rating calculation.") +@RequiredArgsConstructor +public class KycRiskRulesApiResource { + + private final PlatformSecurityContext context; + private final KycRiskRulesReadPlatformService readPlatformService; + private final DefaultToApiJsonSerializer toApiJsonSerializer; + + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve KYC risk rules", description = "Returns list of database-defined risk rules ordered by precedence.") + public String retrieveRiskRules() { + this.context.authenticatedUser(); + final List rules = this.readPlatformService.retrieveAllRules(); + return this.toApiJsonSerializer.serialize(rules); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/ckyc/DummyCkycSftpUploadServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/ckyc/DummyCkycSftpUploadServiceImpl.java index 2a3facfbc..021ee4fa9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/ckyc/DummyCkycSftpUploadServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/ckyc/DummyCkycSftpUploadServiceImpl.java @@ -24,6 +24,7 @@ import java.util.concurrent.atomic.AtomicLong; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.portfolio.client.domain.ClientKyc; import org.springframework.stereotype.Service; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** * Dummy / stub implementation of {@link CkycSftpUploadService}. @@ -60,7 +61,7 @@ public class DummyCkycSftpUploadServiceImpl implements CkycSftpUploadService { final String ckycNumber = generateCkycNumber(kyc.getClient().getId()); final String batchId = generateBatchId(); - final LocalDate today = LocalDate.now(); + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); return CkycUploadResult.builder() .ckycNumber(ckycNumber) @@ -82,7 +83,7 @@ public class DummyCkycSftpUploadServiceImpl implements CkycSftpUploadService { return CkycUploadResult.builder() .status("FAILED") .remarks("Uploaded file is not a valid ZIP archive (bad magic bytes).") - .uploadedOn(LocalDate.now()) + .uploadedOn(LocalDate.now(DateUtils.getDateTimeZoneOfTenant())) .build(); } @@ -92,7 +93,7 @@ public class DummyCkycSftpUploadServiceImpl implements CkycSftpUploadService { return CkycUploadResult.builder() .ckycNumber(ckycNumber) .batchId(batchId) - .uploadedOn(LocalDate.now()) + .uploadedOn(LocalDate.now(DateUtils.getDateTimeZoneOfTenant())) .status("SUCCESS") .remarks("Dummy CKYC ZIP upload accepted (" + zipBytes.length + " bytes). " + "Replace with real CERSAI SFTP integration before go-live.") @@ -112,7 +113,7 @@ public class DummyCkycSftpUploadServiceImpl implements CkycSftpUploadService { } private String generateBatchId() { - return "DUMMYCKYC-" + BATCH_ID_FORMAT.format(LocalDate.now()) + "-" + return "DUMMYCKYC-" + BATCH_ID_FORMAT.format(LocalDate.now(DateUtils.getDateTimeZoneOfTenant())) + "-" + String.format("%06d", BATCH_COUNTER.getAndIncrement()); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/ckyc/service/CkycBatchBuilderServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/ckyc/service/CkycBatchBuilderServiceImpl.java index f90a9d1a0..6e9da02a0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/ckyc/service/CkycBatchBuilderServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/ckyc/service/CkycBatchBuilderServiceImpl.java @@ -32,7 +32,6 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import lombok.RequiredArgsConstructor; @@ -50,6 +49,7 @@ import org.apache.fineract.portfolio.client.ckyc.domain.CkycBatchRepository; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Slf4j @Service @@ -61,16 +61,22 @@ public class CkycBatchBuilderServiceImpl implements CkycBatchBuilderService { private static final DateTimeFormatter ZIP_NAME_TS_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss"); /** - * Names of code values (from {@code KYCAddressProofType}) that unambiguously indicate an - * address proof. Aadhaar / Passport / DL / Voter ID can be both ID and address proof — we - * default them to ID (PI) unless the UI tags them explicitly. + * Code (m_code.code_name) under which an identifier's document type must be classified for it + * to count as an address proof. Matches the same m_code_value config that + * KycProofTypesApiResource (GET /kyc/proof-types) reads to populate the CKYC tab's + * "Address Proof Document" card — e.g. Aadhaar Card is seeded as a document type under BOTH + * KYCIdProofType and KYCAddressProofType (two separate m_client_identifier rows), so trusting + * the code a given identifier was actually tagged under is the only way this can agree with + * what the CKYC tab already shows as "available". */ - private static final Set ADDRESS_PROOF_ONLY = Set.of( - "UTILITY BILLS", "UTILITY BILL", "ELECTRICITY BILL", "WATER BILL", "GAS BILL", "INTERNET BILL", - "BANK ACCOUNT STATEMENT / PASSBOOK", "BANK STATEMENT", "POSTPAID MOBILE BILL", - "REGISTERED RENT AGREEMENT", "RENT AGREEMENT", "PROPERTY TAX RECEIPT", - "EMPLOYER LETTER (FOR SALARIED EMPLOYEES)", "EMPLOYER LETTER", - "PENSION PAYMENT ORDERS (FOR RETIREES)", "PENSION PAYMENT ORDER"); + private static final String ADDRESS_PROOF_CODE_NAME = "KYCAddressProofType"; + + // Top-level ZIP folders — one per artefact type, so every client's file of a given type lands + // in the same folder instead of all 4 types being interleaved flat at the ZIP root. + private static final String PHOTO_FOLDER = "PHOTO/"; + private static final String SIGNATURE_FOLDER = "SIGNATURE/"; + private static final String ID_PROOF_FOLDER = "ID_PROOF/"; + private static final String ADDRESS_PROOF_FOLDER = "ADDRESS_PROOF/"; private final JdbcTemplate jdbcTemplate; private final DocumentReadPlatformService documentReadPlatformService; @@ -86,11 +92,11 @@ public class CkycBatchBuilderServiceImpl implements CkycBatchBuilderService { throw new IllegalArgumentException("clientIds must not be empty"); } - final LocalDate today = LocalDate.now(); + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); final int seq = nextBatchSequenceForToday(today); final String batchId = "BATCH-" + BATCH_ID_FMT.format(today) + "-" + String.format("%03d", seq); // ZIP filename: "CERSAI-ZIP-.zip" — hyphen-separated per spec. - final String filename = "CERSAI-ZIP-" + ZIP_NAME_TS_FMT.format(java.time.LocalDateTime.now()) + ".zip"; + final String filename = "CERSAI-ZIP-" + ZIP_NAME_TS_FMT.format(java.time.LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())) + ".zip"; final List manifest = new ArrayList<>(); final ByteArrayOutputStream zipBytesOut = new ByteArrayOutputStream(); @@ -111,13 +117,30 @@ public class CkycBatchBuilderServiceImpl implements CkycBatchBuilderService { final byte[] zipBytes = zipBytesOut.toByteArray(); final String checksum = "sha256:" + sha256Hex(zipBytes); - // Persist the ZIP as an m_document row (parent_entity_type='ckyc_batch', parent_entity_id=seq) - // Persistence happens BEFORE we know m_ckyc_batch.id, so we use the synthetic seq as a - // temporary parent_entity_id. The real FK is via m_ckyc_batch.document_id after save. + // Persist the batch registry row FIRST (without documentId) so we have its real, + // DB-generated id before creating the ZIP's m_document row. The document's + // parent_entity_id must be the actual m_ckyc_batch.id — using the daily `seq` here + // (as a stand-in for an id that doesn't exist yet) was wrong: seq resets every day + // while m_ckyc_batch.id is a global auto-increment, so GET + // /ckyc_batch/{id}/documents/{documentId}/attachment (which matches on the REAL batch + // id) 404'd for any batch whose seq didn't coincidentally equal its DB id. + final CkycBatch batch = new CkycBatch(); + batch.setBatchId(batchId); + batch.setFilename(filename); + batch.setChecksumSha256(checksum); + batch.setRecordCount(includedCount); + batch.setSizeBytes((long) zipBytes.length); + batch.setCreatedOnDate(today); + batch.setUploadedToCersai(false); + batch.setManifestJson(new Gson().toJson(manifest)); + + final CkycBatch saved = ckycBatchRepository.saveAndFlush(batch); + + // Now persist the ZIP as an m_document row keyed to the batch's real id. final var docResponse = documentWritePlatformService.createDocument( DocumentCreateRequest.builder() .entityType("ckyc_batch") - .entityId((long) seq) + .entityId(saved.getId()) .name(batchId) .fileName(filename) .description(StringUtils.defaultIfEmpty(remarks, "CERSAI CKYC batch ZIP")) @@ -126,19 +149,8 @@ public class CkycBatchBuilderServiceImpl implements CkycBatchBuilderService { .stream(new ByteArrayInputStream(zipBytes)) .build()); - // Persist the batch registry row - final CkycBatch batch = new CkycBatch(); - batch.setBatchId(batchId); - batch.setFilename(filename); - batch.setChecksumSha256(checksum); - batch.setRecordCount(includedCount); - batch.setSizeBytes((long) zipBytes.length); - batch.setDocumentId(docResponse.getResourceId() != null ? docResponse.getResourceId().longValue() : null); - batch.setCreatedOnDate(today); - batch.setUploadedToCersai(false); - batch.setManifestJson(new Gson().toJson(manifest)); - - final CkycBatch saved = ckycBatchRepository.saveAndFlush(batch); + saved.setDocumentId(docResponse.getResourceId() != null ? docResponse.getResourceId().longValue() : null); + ckycBatchRepository.saveAndFlush(saved); log.info("CKYC batch created: id={}, batchId={}, filename={}, recordCount={}/{}, bytes={}", saved.getId(), batchId, filename, includedCount, clientIds.size(), zipBytes.length); @@ -184,32 +196,40 @@ public class CkycBatchBuilderServiceImpl implements CkycBatchBuilderService { entry.setCifNumber(cifNumber); entry.setDisplayName(displayName); - // Photo — packaged as "-PI-PHOTO." using m_client.image_id → m_image + // Photo — packaged as "PHOTO/-PI-PHOTO." using m_client.image_id → m_image. + // Every artefact type gets its own top-level ZIP folder, so a batch of N clients produces + // one PHOTO/ folder containing all N client photos (and likewise for the other 3 types), + // rather than every client's files interleaved flat at the ZIP root. final Map photo = new LinkedHashMap<>(); photo.put("present", imageId != null); entry.setPhoto(photo); if (imageId != null && cifNumber != null) { - packageClientImage(zip, clientId, cifNumber + "-PI-PHOTO", photo); + packageClientImage(zip, clientId, PHOTO_FOLDER + cifNumber + "-PI-PHOTO", photo); } - // Signature — packaged as "-PI-SIGNATURE." + // Signature — packaged as "SIGNATURE/-PI-SIGNATURE." final Long signatureDocId = findSignatureDocId(clientId); final Map signature = new LinkedHashMap<>(); signature.put("present", signatureDocId != null); entry.setSignature(signature); if (signatureDocId != null && cifNumber != null) { packageFile(zip, "clients", clientId, signatureDocId, - cifNumber + "-PI-SIGNATURE", signature); + SIGNATURE_FOLDER + cifNumber + "-PI-SIGNATURE", signature); } - // ID proof + Address proof: iterate m_client_identifier, classify by code value name. + // ID proof + Address proof: iterate m_client_identifier, classify by the m_code the + // identifier's document type actually belongs to (KYCIdProofType vs KYCAddressProofType) — + // the same source of truth KycProofTypesApiResource uses for the CKYC tab, so a document + // the client sees as "Address Proof: Available" is never silently dropped here. // For each identifier, find the attached m_document (one-to-one, latest wins). // NOTE: the `active` column in m_client_identifier stores the status code (200 = active) // not a 1/0 boolean — so we filter on `status = 200` (ClientIdentifierStatus.ACTIVE). final List> identifiers = jdbcTemplate.queryForList( - "select ci.id as identifierId, ci.document_key as docKey, cv.code_value as codeValue " + "select ci.id as identifierId, ci.document_key as docKey, cv.code_value as codeValue, " + + " c.code_name as codeName " + " from m_client_identifier ci " + " join m_code_value cv on cv.id = ci.document_type_id " + + " join m_code c on c.id = cv.code_id " + " where ci.client_id = ? and (ci.status = 200 or ci.status is null)", clientId); @@ -221,8 +241,9 @@ public class CkycBatchBuilderServiceImpl implements CkycBatchBuilderService { for (final Map idRow : identifiers) { final Long identifierId = ((Number) idRow.get("identifierId")).longValue(); final String codeValue = (String) idRow.get("codeValue"); + final String codeName = (String) idRow.get("codeName"); final String docKey = (String) idRow.get("docKey"); - final boolean isAddress = ADDRESS_PROOF_ONLY.contains(codeValue == null ? "" : codeValue.toUpperCase(Locale.ROOT)); + final boolean isAddress = ADDRESS_PROOF_CODE_NAME.equalsIgnoreCase(codeName); final String role = isAddress ? "PA" : "PI"; final Long attachedDocId = findLatestDocForIdentifier(identifierId); @@ -242,8 +263,9 @@ public class CkycBatchBuilderServiceImpl implements CkycBatchBuilderService { bucket.put("key", docKey); if (cifNumber != null) { + final String folder = isAddress ? ADDRESS_PROOF_FOLDER : ID_PROOF_FOLDER; packageFile(zip, "client_identifiers", identifierId, attachedDocId, - cifNumber + "-" + role + "-" + shortCode, bucket); + folder + cifNumber + "-" + role + "-" + shortCode, bucket); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/ckyc/service/CkycReportsReadServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/ckyc/service/CkycReportsReadServiceImpl.java index 12ff63362..498260139 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/ckyc/service/CkycReportsReadServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/ckyc/service/CkycReportsReadServiceImpl.java @@ -26,7 +26,6 @@ import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.ArrayList; -import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -36,6 +35,7 @@ import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecific import org.glassfish.jersey.media.multipart.FormDataContentDisposition; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Slf4j @Service @@ -219,6 +219,7 @@ public class CkycReportsReadServiceImpl implements CkycReportsReadService { // ===== Customer List ===== @Override + @SuppressWarnings("StringSplitter") public Map listCustomers(String name, String branch, String city, String gender, String state, String dateFrom, String dateTo, Integer page, Integer pageSize, String sort) { @@ -375,13 +376,13 @@ public class CkycReportsReadServiceImpl implements CkycReportsReadService { switch (format != null ? format.toLowerCase() : "csv") { case "txt": contentType = "text/plain"; - filename = "ckyc_export_" + LocalDate.now() + ".txt"; + filename = "ckyc_export_" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()) + ".txt"; content = buildPipeDelimitedExport(rows); break; case "csv": default: contentType = "text/csv"; - filename = "ckyc_export_" + LocalDate.now() + ".csv"; + filename = "ckyc_export_" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()) + ".csv"; content = buildCsvExport(rows); break; } @@ -431,7 +432,7 @@ public class CkycReportsReadServiceImpl implements CkycReportsReadService { errors.add("Parse error: " + e.getMessage()); } - String fileId = "UPLOAD-" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")) + "-" + System.currentTimeMillis(); + String fileId = "UPLOAD-" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("yyyyMMdd")) + "-" + System.currentTimeMillis(); result.put("fileId", fileId); result.put("totalRecords", totalRecords); result.put("customers", customers); @@ -453,9 +454,9 @@ public class CkycReportsReadServiceImpl implements CkycReportsReadService { @Override public Response generateCkycFile(Map request) { // Placeholder — delegates to CkycBatchBuilderService for actual ZIP generation - String content = "10|BATCH|" + LocalDate.now() + "|0\n"; + String content = "10|BATCH|" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()) + "|0\n"; return Response.ok(content.getBytes(StandardCharsets.UTF_8), "text/plain") - .header("Content-Disposition", "attachment; filename=\"ckyc_submission_" + LocalDate.now() + ".txt\"") + .header("Content-Disposition", "attachment; filename=\"ckyc_submission_" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()) + ".txt\"") .build(); } @@ -572,7 +573,7 @@ public class CkycReportsReadServiceImpl implements CkycReportsReadService { private String buildPipeDelimitedExport(List> rows) { StringBuilder sb = new StringBuilder(); - sb.append("10|CKYC_EXPORT|").append(LocalDate.now()).append("|").append(rows.size()).append("\n"); + sb.append("10|CKYC_EXPORT|").append(LocalDate.now(DateUtils.getDateTimeZoneOfTenant())).append("|").append(rows.size()).append("\n"); for (Map row : rows) { sb.append("20|"); sb.append(val(row.get("cif"))).append("|"); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/ckyc/service/DummyCkycBatchSftpUploadServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/ckyc/service/DummyCkycBatchSftpUploadServiceImpl.java index e4f7635ea..f212130d9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/ckyc/service/DummyCkycBatchSftpUploadServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/ckyc/service/DummyCkycBatchSftpUploadServiceImpl.java @@ -47,6 +47,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** * Dummy / stub implementation of {@link CkycBatchSftpUploadService}. @@ -149,10 +150,10 @@ public class DummyCkycBatchSftpUploadServiceImpl implements CkycBatchSftpUploadS } // 3) Build the ACK reference and timestamp - final LocalDateTime now = LocalDateTime.now(); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); final String ackReference = "CERSAI-ACK-" + ACK_TS_FMT.format(now) + "-" + String.format("%04d", ACK_COUNTER.getAndIncrement()); - final LocalDate today = LocalDate.now(); + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); // 4) Parse manifest → generate a CKYC number per included client → persist on m_client_kyc final List> perClient = new ArrayList<>(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/command/ClientKycCommand.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/command/ClientKycCommand.java index 246a47656..b3d5cb840 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/command/ClientKycCommand.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/command/ClientKycCommand.java @@ -71,6 +71,15 @@ public class ClientKycCommand { } public void validateForInitiate() { + validateForInitiate(false); + } + + /** + * @param skipIdentityDocumentCheck true for minors, who identify via the Birth Certificate / + * School ID document-type picker (a separate upload path) + * rather than PAN/Aadhaar/GSTIN. + */ + public void validateForInitiate(final boolean skipIdentityDocumentCheck) { final List errors = new ArrayList<>(); final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(ClientKycApiConstants.RESOURCE_NAME); @@ -101,13 +110,16 @@ public class ClientKycCommand { "GSTIN must be a valid 15-character GST Identification Number"); } - // At least one ID document must be provided - final boolean hasPan = panNumber != null && !panNumber.isBlank(); - final boolean hasAadhaar = aadhaarNumberMasked != null && !aadhaarNumberMasked.isBlank(); - final boolean hasGstin = gstinNumber != null && !gstinNumber.isBlank(); - if (!hasPan && !hasAadhaar && !hasGstin) { - v.reset().parameter(ClientKycApiConstants.panNumberParam).value(panNumber) - .notBlank(); + // 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) { + final boolean hasPan = panNumber != null && !panNumber.isBlank(); + final boolean hasAadhaar = aadhaarNumberMasked != null && !aadhaarNumberMasked.isBlank(); + final boolean hasGstin = gstinNumber != null && !gstinNumber.isBlank(); + if (!hasPan && !hasAadhaar && !hasGstin) { + v.reset().parameter(ClientKycApiConstants.panNumberParam).value(panNumber) + .notBlank(); + } } throwExceptionIfValidationWarningsExist(errors); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/api/ClientContactVerificationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/api/ClientContactVerificationApiResource.java new file mode 100644 index 000000000..eaf333fc3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/api/ClientContactVerificationApiResource.java @@ -0,0 +1,161 @@ +/** + * 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.contactverification.api; + +import com.google.common.base.Splitter; +import com.google.common.collect.Iterables; +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.servlet.http.HttpServletRequest; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse; +import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.client.api.ClientApiConstants; +import org.apache.fineract.portfolio.client.contactverification.service.ClientContactOtpService; +import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.springframework.stereotype.Component; + +/** + * Staff-facing endpoints used to gate a client's mobileNo / emailAddress change on + * {@code PUT /v1/clients/{clientId}} behind dual OTP verification (old destination AND new + * destination must each be OTP-verified before the update call will accept the change — see + * {@code ClientDataValidator} and {@code ClientWritePlatformServiceJpaRepositoryImpl.updateClient()}). + * + *

+ * OTP is entered by the staff member on the customer's behalf (no self-service customer portal + * exists in this deployment), which is why this resource requires a normal, fully-authenticated + * staff session rather than being public like the pre-login {@code /v1/login/otp/*} endpoints. + * + *

+ * Deliberately does NOT contain the literal path segment {@code /self/} anywhere — that substring + * causes {@code TenantAwareBasicAuthenticationFilter} to reject any non-self-service (staff) caller + * with a 401, which triggers a browser Basic-Auth popup. See the class-level {@code @Path} below. + * + *

+ *   POST /v1/clients/{clientId}/contact-verification/otp          body: {"channel":"MOBILE"|"EMAIL","destination":"..."}
+ *   POST /v1/clients/{clientId}/contact-verification/otp/verify   body: {"otpId":123,"otp":"123456"}
+ * 
+ */ +@Path("/v1/clients/{clientId}/contact-verification/otp") +@Component +@Tag(name = "Client Contact Verification", + description = "Dual-OTP verification of a client's old/new mobile number or email address, " + + "required before PUT /v1/clients/{clientId} will accept a mobileNo/emailAddress change.") +@RequiredArgsConstructor +public class ClientContactVerificationApiResource { + + private final PlatformSecurityContext context; + private final ClientRepositoryWrapper clientRepositoryWrapper; + private final ClientContactOtpService clientContactOtpService; + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Generate an OTP against a client's mobile number or email address", + description = "channel=MOBILE sends via LincPay to `destination`; channel=EMAIL sends a locally-generated " + + "6-digit code to `destination` via SMTP. Call this once for the client's *current* (old) " + + "destination and once for the *incoming* (new) destination before attempting the actual " + + "PUT /v1/clients/{clientId} update — both otpId's are required as mobileOldOtpId/mobileNewOtpId " + + "(or emailOldOtpId/emailNewOtpId) on that call.") + public OtpGenerateResponse generate(@PathParam("clientId") final Long clientId, final String apiRequestBodyAsJson, + @Context final HttpServletRequest httpRequest) { + + this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME); + this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId); + + final JsonObject payload = parse(apiRequestBodyAsJson); + final String channel = stringOrNull(payload, "channel"); + final String destination = stringOrNull(payload, "destination"); + + final String ipAddress = clientIp(httpRequest); + final String userAgent = httpRequest != null ? httpRequest.getHeader("User-Agent") : null; + + if ("EMAIL".equalsIgnoreCase(channel)) { + return clientContactOtpService.generateEmailOtp(destination, ipAddress, userAgent); + } + if ("MOBILE".equalsIgnoreCase(channel)) { + return clientContactOtpService.generateMobileOtp(destination, ipAddress, userAgent); + } + throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.channel.invalid", + "channel must be one of MOBILE, EMAIL", channel); + } + + @POST + @Path("verify") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Verify a previously generated client-contact-change OTP") + public OtpVerifyResponse verify(@PathParam("clientId") final Long clientId, final String apiRequestBodyAsJson) { + + this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME); + this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId); + + final JsonObject payload = parse(apiRequestBodyAsJson); + final Long otpId = longOrNull(payload, "otpId"); + final String otp = stringOrNull(payload, "otp"); + return clientContactOtpService.verify(otpId, otp); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private JsonObject parse(final String body) { + if (body == null || body.isBlank()) { + return new JsonObject(); + } + try { + return JsonParser.parseString(body).getAsJsonObject(); + } catch (final Exception e) { + return new JsonObject(); + } + } + + private String stringOrNull(final JsonObject obj, final String key) { + return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsString() : null; + } + + private Long longOrNull(final JsonObject obj, final String key) { + try { + return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsLong() : null; + } catch (final Exception e) { + return null; + } + } + + private String clientIp(final HttpServletRequest req) { + if (req == null) { + return null; + } + final String fwd = req.getHeader("X-Forwarded-For"); + if (fwd != null && !fwd.isBlank()) { + return Iterables.get(Splitter.on(',').split(fwd), 0).trim(); + } + return req.getRemoteAddr(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/api/ClientCreationContactVerificationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/api/ClientCreationContactVerificationApiResource.java new file mode 100644 index 000000000..c3ad7832f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/api/ClientCreationContactVerificationApiResource.java @@ -0,0 +1,156 @@ +/** + * 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.contactverification.api; + +import com.google.common.base.Splitter; +import com.google.common.collect.Iterables; +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.servlet.http.HttpServletRequest; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse; +import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.client.api.ClientApiConstants; +import org.apache.fineract.portfolio.client.contactverification.service.ClientContactOtpService; +import org.springframework.stereotype.Component; + +/** + * Sibling of {@link ClientContactVerificationApiResource}, for the CLIENT CREATION flow — a + * brand-new client has no id yet, so the {@code {clientId}} path segment used by the edit-time + * resource (which looks the client up via {@code findOneWithNotFoundDetection}) can't be reused + * as-is here. Same {@code ClientContactOtpService} underneath, same request/response shapes — + * only the path (no {@code clientId} segment, so no client-existence check) and the permission + * (CREATE instead of UPDATE) differ. + * + *

+ * Segment count disambiguates this from {@code ClientContactVerificationApiResource}'s path at the + * JAX-RS routing level (4 segments here vs. 5 with {@code {clientId}}), so both resources coexist + * without ambiguity. + * + *

+ * Deliberately does NOT contain the literal path segment {@code /self/} anywhere — see + * {@link ClientContactVerificationApiResource}'s class Javadoc for why that substring is unsafe in + * this codebase. + * + *

+ *   POST /v1/clients/contact-verification/otp          body: {"channel":"MOBILE"|"EMAIL","destination":"..."}
+ *   POST /v1/clients/contact-verification/otp/verify    body: {"otpId":123,"otp":"123456"}
+ * 
+ */ +@Path("/v1/clients/contact-verification/otp") +@Component +@Tag(name = "Client Creation Contact Verification", + description = "OTP verification of a brand-new client's mobile number / email address at creation time, " + + "required before POST /v1/clients will accept the value — guarantees every mobileNo/emailAddress " + + "ever stored was reachable at the moment it was recorded.") +@RequiredArgsConstructor +public class ClientCreationContactVerificationApiResource { + + private final PlatformSecurityContext context; + private final ClientContactOtpService clientContactOtpService; + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Generate an OTP against a not-yet-created client's mobile number or email address", + description = "channel=MOBILE sends via LincPay to `destination`; channel=EMAIL sends a locally-generated " + + "6-digit code to `destination` via SMTP. The returned otpId is required as mobileNewOtpId / " + + "emailNewOtpId on the subsequent POST /v1/clients call.") + public OtpGenerateResponse generate(final String apiRequestBodyAsJson, @Context final HttpServletRequest httpRequest) { + + this.context.authenticatedUser().validateHasCreatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME); + + final JsonObject payload = parse(apiRequestBodyAsJson); + final String channel = stringOrNull(payload, "channel"); + final String destination = stringOrNull(payload, "destination"); + + final String ipAddress = clientIp(httpRequest); + final String userAgent = httpRequest != null ? httpRequest.getHeader("User-Agent") : null; + + if ("EMAIL".equalsIgnoreCase(channel)) { + return clientContactOtpService.generateEmailOtp(destination, ipAddress, userAgent); + } + if ("MOBILE".equalsIgnoreCase(channel)) { + return clientContactOtpService.generateMobileOtp(destination, ipAddress, userAgent); + } + throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.channel.invalid", + "channel must be one of MOBILE, EMAIL", channel); + } + + @POST + @Path("verify") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Verify a previously generated client-creation contact OTP") + public OtpVerifyResponse verify(final String apiRequestBodyAsJson) { + + this.context.authenticatedUser().validateHasCreatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME); + + final JsonObject payload = parse(apiRequestBodyAsJson); + final Long otpId = longOrNull(payload, "otpId"); + final String otp = stringOrNull(payload, "otp"); + return clientContactOtpService.verify(otpId, otp); + } + + // ── Helpers (mirrors ClientContactVerificationApiResource) ──────────────── + + private JsonObject parse(final String body) { + if (body == null || body.isBlank()) { + return new JsonObject(); + } + try { + return JsonParser.parseString(body).getAsJsonObject(); + } catch (final Exception e) { + return new JsonObject(); + } + } + + private String stringOrNull(final JsonObject obj, final String key) { + return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsString() : null; + } + + private Long longOrNull(final JsonObject obj, final String key) { + try { + return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsLong() : null; + } catch (final Exception e) { + return null; + } + } + + private String clientIp(final HttpServletRequest req) { + if (req == null) { + return null; + } + final String fwd = req.getHeader("X-Forwarded-For"); + if (fwd != null && !fwd.isBlank()) { + return Iterables.get(Splitter.on(',').split(fwd), 0).trim(); + } + return req.getRemoteAddr(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/service/ClientContactOtpService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/service/ClientContactOtpService.java new file mode 100644 index 000000000..5fd7af5aa --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/service/ClientContactOtpService.java @@ -0,0 +1,76 @@ +/** + * 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.contactverification.service; + +import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse; +import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse; + +/** + * Client-facing OTP generate/verify used to gate mobileNo / emailAddress changes on + * {@code PUT /v1/clients/{clientId}} (see {@code ClientDataValidator} and + * {@code ClientWritePlatformServiceJpaRepositoryImpl.updateClient()}). + * + *

+ * This mirrors {@code LoginOtpService} / {@code EmailLoginOtpService} (same LincPay / local-hash + * mechanics, same {@code m_otp_log} bookkeeping) but takes a raw destination directly instead of + * resolving one from an {@code AppUser} — here the "user" being verified is a client's contact + * channel, entered by a staff member on the client's behalf, not a login user. + * + *

+ * Every row created by this service uses {@code purpose = "CLIENT_CONTACT_CHANGE"} so it is + * unambiguously distinct from the login-2FA rows in the same table. + */ +public interface ClientContactOtpService { + + /** + * Send an OTP to {@code mobileNumber} via LincPay (same call shape as + * {@code LoginOtpServiceImpl.generate()}). {@code userId}/{@code username} are left null on the + * {@code m_otp_log} row since no AppUser is involved. + */ + OtpGenerateResponse generateMobileOtp(String mobileNumber, String ipAddress, String userAgent); + + /** + * Generate a 6-digit OTP locally, store its SHA-256 hash, and email it to {@code emailAddress} + * (same mechanics as {@code EmailLoginOtpServiceImpl.generate()}). + */ + OtpGenerateResponse generateEmailOtp(String emailAddress, String ipAddress, String userAgent); + + /** + * Verify an OTP issued by either of the above. Dispatches to the LincPay delegate or the local + * hash-compare depending on the referenced row's {@code provider}. + */ + OtpVerifyResponse verify(Long otpId, String otp); + + /** + * Assert that {@code otpId} references a {@code CLIENT_CONTACT_CHANGE}, {@code VERIFIED} + * {@code m_otp_log} row whose stored mobile number equals {@code expectedMobileNumber}. Throws + * {@link org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException} on + * any mismatch. Used by {@code ClientWritePlatformServiceJpaRepositoryImpl} immediately before + * applying a mobileNo change — never trusts the client-supplied JSON, always re-derives the + * expected destination from the DB (old) or the incoming command (new). + * + * @param otpIdParamLabel the request parameter name this otpId came from (e.g. {@code mobileOldOtpId}, + * {@code mobileNewOtpId}, {@code emailOldOtpId}, {@code emailNewOtpId}) — used + * only to make the error message useful. + */ + void assertVerifiedMobile(Long otpId, String expectedMobileNumber, String otpIdParamLabel); + + /** Email counterpart of {@link #assertVerifiedMobile(Long, String, String)}. */ + void assertVerifiedEmail(Long otpId, String expectedEmailAddress, String otpIdParamLabel); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/service/ClientContactOtpServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/service/ClientContactOtpServiceImpl.java new file mode 100644 index 000000000..f207434ac --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/service/ClientContactOtpServiceImpl.java @@ -0,0 +1,463 @@ +/** + * 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.contactverification.service; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.HexFormat; +import java.util.Locale; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.email.service.EmailSenderService; +import org.apache.fineract.infrastructure.email.service.EmailTemplateService; +import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse; +import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse; +import org.apache.fineract.infrastructure.security.otp.domain.OtpLog; +import org.apache.fineract.infrastructure.security.otp.domain.OtpLogRepository; +import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.RestTemplate; +import org.apache.fineract.infrastructure.core.service.DateUtils; + +/** + * See {@link ClientContactOtpService}. Deliberately does NOT touch {@code LoginOtpServiceImpl} / + * {@code EmailLoginOtpServiceImpl} — this is a parallel, client-contact-change-specific variant that + * happens to talk to the same LincPay endpoints / SMTP path with a raw destination instead of an + * AppUser lookup. Small helper methods (JSON post/parse, sha256, six-digit generator) are duplicated + * rather than extracted, matching how this codebase already keeps each OTP-issuing service + * self-contained (only the genuinely cross-cutting {@link OtpDestinationMasker} was pulled out). + */ +@Service +public class ClientContactOtpServiceImpl implements ClientContactOtpService { + + private static final Logger LOG = LoggerFactory.getLogger(ClientContactOtpServiceImpl.class); + + public static final String PURPOSE_CLIENT_CONTACT_CHANGE = "CLIENT_CONTACT_CHANGE"; + private static final String PROVIDER_LINCPAY = "LINCPAY"; + private static final String PROVIDER_EMAIL = "EMAIL"; + + private static final String STATUS_PENDING = "PENDING"; + private static final String STATUS_SENT = "SENT"; + private static final String STATUS_FAILED = "FAILED"; + private static final String STATUS_VERIFIED = "VERIFIED"; + private static final String STATUS_EXPIRED = "EXPIRED"; + + private static final DateTimeFormatter SUBJECT_TIMESTAMP = DateTimeFormatter.ofPattern("dd-MM-yy-HH:mm"); + + @Value("${fineract.kyc.lincpay.api-key}") + private String apiKey; + + @Value("${fineract.kyc.lincpay.secret}") + private String secret; + + @Value("${fineract.kyc.lincpay.base-url}") + private String baseUrl; + + @Value("${fineract.security.otp.lincpay.generate-path:/mobile/generate-otp}") + private String generateOtpPath; + + @Value("${fineract.security.otp.lincpay.verify-path:/verify-mobile-otp}") + private String verifyOtpPath; + + @Value("${fineract.security.otp.ttl-seconds:300}") + private int ttlSeconds; + + @Value("${fineract.security.otp.max-attempts:5}") + private int maxAttempts; + + @Value("${fineract.email.from-name:CBSnexora}") + private String appName; + + private final RestTemplate restTemplate; + private final OtpLogRepository otpLogRepository; + private final EmailSenderService emailSenderService; + private final EmailTemplateService emailTemplateService; + private final SecureRandom secureRandom = new SecureRandom(); + + @Autowired + public ClientContactOtpServiceImpl(final OtpLogRepository otpLogRepository, final EmailSenderService emailSenderService, + final EmailTemplateService emailTemplateService) { + this.restTemplate = new RestTemplate(); + this.otpLogRepository = otpLogRepository; + this.emailSenderService = emailSenderService; + this.emailTemplateService = emailTemplateService; + } + + // ── Generate ───────────────────────────────────────────────────────────── + + @Override + @Transactional + public OtpGenerateResponse generateMobileOtp(final String mobileNumber, final String ipAddress, final String userAgent) { + if (StringUtils.isBlank(mobileNumber)) { + return OtpGenerateResponse.builder().success(false).message("mobileNumber is required").build(); + } + + final OtpLog log = new OtpLog(); + log.setPurpose(PURPOSE_CLIENT_CONTACT_CHANGE); + log.setProvider(PROVIDER_LINCPAY); + log.setMobileNumber(mobileNumber); + log.setStatus(STATUS_PENDING); + log.setIpAddress(ipAddress); + log.setUserAgent(userAgent); + log.setExpiresAt(OffsetDateTime.now(ZoneOffset.UTC).plusSeconds(ttlSeconds)); + + final JsonObject requestBody = new JsonObject(); + requestBody.addProperty("apiKey", apiKey); + requestBody.addProperty("mobileNumber", mobileNumber); + requestBody.addProperty("secretKey", secret); + + final JsonObject redactedRequest = new JsonObject(); + redactedRequest.addProperty("apiKey", "***REDACTED***"); + redactedRequest.addProperty("mobileNumber", mobileNumber); + redactedRequest.addProperty("secretKey", "***REDACTED***"); + log.setRequestPayload(redactedRequest.toString()); + + try { + final String url = baseUrl + generateOtpPath; + final String rawResponse = post(url, requestBody.toString()); + log.setResponsePayload(truncate(rawResponse, 65000)); + + final JsonObject response = parseJson(rawResponse); + if (isSuccess(response)) { + log.setStatus(STATUS_SENT); + log.setOtpReference(extractField(response, "reference_id", "refNumber", "ref_number", "transactionId", "txnId", + "requestId")); + final OtpLog saved = otpLogRepository.saveAndFlush(log); + LOG.info("Client contact-change mobile OTP sent. otpId={}, mobile={}", saved.getId(), + OtpDestinationMasker.maskMobile(mobileNumber)); + return OtpGenerateResponse.builder().success(true).otpId(saved.getId()).otpReference(saved.getOtpReference()) + .maskedMobile(OtpDestinationMasker.maskMobile(mobileNumber)).expiresInSeconds(ttlSeconds) + .message("OTP sent").build(); + } + + final String errorMsg = extractField(response, "message", "errorMessage", "error", "description"); + log.setStatus(STATUS_FAILED); + log.setErrorMessage(errorMsg); + otpLogRepository.saveAndFlush(log); + LOG.warn("Client contact-change mobile OTP failed: {}", errorMsg); + return OtpGenerateResponse.builder().success(false).message(errorMsg != null ? errorMsg : "OTP send failed").build(); + + } catch (final HttpClientErrorException | HttpServerErrorException e) { + log.setStatus(STATUS_FAILED); + log.setErrorMessage("HTTP " + e.getStatusCode() + ": " + truncate(e.getResponseBodyAsString(), 900)); + log.setResponsePayload(truncate(e.getResponseBodyAsString(), 65000)); + otpLogRepository.saveAndFlush(log); + LOG.error("Client contact-change mobile OTP HTTP error: {} {}", e.getStatusCode(), e.getResponseBodyAsString()); + return OtpGenerateResponse.builder().success(false).message("OTP send failed (HTTP " + e.getStatusCode() + ")").build(); + } catch (final Exception e) { + log.setStatus(STATUS_FAILED); + log.setErrorMessage(truncate(e.getMessage(), 900)); + otpLogRepository.saveAndFlush(log); + LOG.error("Client contact-change mobile OTP unexpected error", e); + return OtpGenerateResponse.builder().success(false).message("OTP send failed: " + e.getMessage()).build(); + } + } + + @Override + @Transactional + public OtpGenerateResponse generateEmailOtp(final String emailAddress, final String ipAddress, final String userAgent) { + if (StringUtils.isBlank(emailAddress)) { + return OtpGenerateResponse.builder().success(false).message("emailAddress is required").build(); + } + + final String otp = generateSixDigitOtp(); + final int validityMinutes = Math.max(1, ttlSeconds / 60); + + final OtpLog log = new OtpLog(); + log.setPurpose(PURPOSE_CLIENT_CONTACT_CHANGE); + log.setProvider(PROVIDER_EMAIL); + log.setEmailAddress(emailAddress); + log.setOtpHash(sha256(otp)); + log.setStatus(STATUS_PENDING); + log.setIpAddress(ipAddress); + log.setUserAgent(userAgent); + log.setExpiresAt(OffsetDateTime.now(ZoneOffset.UTC).plusSeconds(ttlSeconds)); + + final String html = emailTemplateService.renderOtp(null, otp, validityMinutes); + final String subject = "Your OTP for Contact Verification - " + appName + " (" + LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()).format(SUBJECT_TIMESTAMP) + + ")"; + + final boolean sent = emailSenderService.sendHtml(emailAddress, subject, html); + if (sent) { + log.setStatus(STATUS_SENT); + final OtpLog saved = otpLogRepository.saveAndFlush(log); + LOG.info("Client contact-change email OTP sent. otpId={}, email={}", saved.getId(), + OtpDestinationMasker.maskEmail(emailAddress)); + return OtpGenerateResponse.builder().success(true).otpId(saved.getId()) + .maskedMobile(OtpDestinationMasker.maskEmail(emailAddress)).expiresInSeconds(ttlSeconds) + .message("OTP sent to the given email address").build(); + } + + log.setStatus(STATUS_FAILED); + log.setErrorMessage("SMTP send failed"); + otpLogRepository.saveAndFlush(log); + return OtpGenerateResponse.builder().success(false).message("OTP send failed").build(); + } + + // ── Verify ─────────────────────────────────────────────────────────────── + + @Override + @Transactional + public OtpVerifyResponse verify(final Long otpId, final String otp) { + if (otpId == null || StringUtils.isBlank(otp)) { + return OtpVerifyResponse.builder().verified(false).message("otpId and otp are required").build(); + } + + final OtpLog row = otpLogRepository.findFirstByIdAndIsDeletedFalseOrderByIdDesc(otpId).orElse(null); + if (row == null) { + return OtpVerifyResponse.builder().verified(false).message("OTP not found").build(); + } + if (!PURPOSE_CLIENT_CONTACT_CHANGE.equals(row.getPurpose())) { + return OtpVerifyResponse.builder().verified(false).message("OTP purpose mismatch").build(); + } + if (STATUS_VERIFIED.equals(row.getStatus())) { + return OtpVerifyResponse.builder().verified(false).message("OTP already used").build(); + } + if (row.getExpiresAt() != null && OffsetDateTime.now(ZoneOffset.UTC).isAfter(row.getExpiresAt())) { + row.setStatus(STATUS_EXPIRED); + otpLogRepository.saveAndFlush(row); + return OtpVerifyResponse.builder().verified(false).message("OTP expired").build(); + } + if (row.getAttempts() != null && row.getAttempts() >= maxAttempts) { + row.setStatus(STATUS_EXPIRED); + otpLogRepository.saveAndFlush(row); + return OtpVerifyResponse.builder().verified(false).message("Max attempts exceeded").build(); + } + + row.setAttempts((row.getAttempts() == null ? 0 : row.getAttempts()) + 1); + + if (PROVIDER_LINCPAY.equals(row.getProvider())) { + return verifyViaLincPay(row, otp); + } + return verifyViaLocalHash(row, otp); + } + + private OtpVerifyResponse verifyViaLincPay(final OtpLog row, final String otp) { + row.setOtpHash(sha256(otp)); + + final JsonObject requestBody = new JsonObject(); + requestBody.addProperty("apiKey", apiKey); + requestBody.addProperty("mobileNumber", row.getMobileNumber()); + requestBody.addProperty("otp", otp); + requestBody.addProperty("secretKey", secret); + + try { + final String url = baseUrl + verifyOtpPath; + final String rawResponse = post(url, requestBody.toString()); + final String existing = row.getResponsePayload() == null ? "" : row.getResponsePayload(); + row.setResponsePayload(truncate(existing + "\n--verify--\n" + rawResponse, 65000)); + + final JsonObject response = parseJson(rawResponse); + if (isSuccess(response)) { + row.setStatus(STATUS_VERIFIED); + row.setVerifiedAt(OffsetDateTime.now(ZoneOffset.UTC)); + otpLogRepository.saveAndFlush(row); + LOG.info("Client contact-change mobile OTP verified. otpId={}, mobile={}", row.getId(), + OtpDestinationMasker.maskMobile(row.getMobileNumber())); + return OtpVerifyResponse.builder().verified(true).message("OTP verified").build(); + } + + otpLogRepository.saveAndFlush(row); + final String providerMsg = extractField(response, "message", "errorMessage", "error", "description"); + final int remaining = Math.max(0, maxAttempts - row.getAttempts()); + return OtpVerifyResponse.builder().verified(false).message(providerMsg != null ? providerMsg : "Invalid OTP") + .attemptsRemaining(remaining).build(); + + } catch (final HttpClientErrorException | HttpServerErrorException e) { + row.setErrorMessage("HTTP " + e.getStatusCode() + ": " + truncate(e.getResponseBodyAsString(), 900)); + otpLogRepository.saveAndFlush(row); + LOG.error("Client contact-change verify-mobile-otp HTTP error: {} {}", e.getStatusCode(), e.getResponseBodyAsString()); + return OtpVerifyResponse.builder().verified(false).message("OTP verification failed (HTTP " + e.getStatusCode() + ")") + .build(); + } catch (final Exception e) { + row.setErrorMessage(truncate(e.getMessage(), 900)); + otpLogRepository.saveAndFlush(row); + LOG.error("Client contact-change verify-mobile-otp unexpected error", e); + return OtpVerifyResponse.builder().verified(false).message("OTP verification failed: " + e.getMessage()).build(); + } + } + + private OtpVerifyResponse verifyViaLocalHash(final OtpLog row, final String otp) { + final boolean matched = row.getOtpHash() != null && row.getOtpHash().equals(sha256(otp)); + if (matched) { + row.setStatus(STATUS_VERIFIED); + row.setVerifiedAt(OffsetDateTime.now(ZoneOffset.UTC)); + otpLogRepository.saveAndFlush(row); + LOG.info("Client contact-change email OTP verified. otpId={}", row.getId()); + return OtpVerifyResponse.builder().verified(true).message("OTP verified").build(); + } + + otpLogRepository.saveAndFlush(row); + final int remaining = Math.max(0, maxAttempts - row.getAttempts()); + return OtpVerifyResponse.builder().verified(false).message("Invalid OTP").attemptsRemaining(remaining).build(); + } + + // ── Assertion helpers used by ClientWritePlatformServiceJpaRepositoryImpl ── + + @Override + public void assertVerifiedMobile(final Long otpId, final String expectedMobileNumber, final String otpIdParamLabel) { + final OtpLog row = requireVerifiedRow(otpId, otpIdParamLabel); + if (!equalsIgnoringWhitespace(row.getMobileNumber(), expectedMobileNumber)) { + throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.destination.mismatch", + "The OTP referenced by '" + otpIdParamLabel + "' was not issued for this mobile number.", otpIdParamLabel); + } + } + + @Override + public void assertVerifiedEmail(final Long otpId, final String expectedEmailAddress, final String otpIdParamLabel) { + final OtpLog row = requireVerifiedRow(otpId, otpIdParamLabel); + if (!equalsIgnoreCaseAndWhitespace(row.getEmailAddress(), expectedEmailAddress)) { + throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.destination.mismatch", + "The OTP referenced by '" + otpIdParamLabel + "' was not issued for this email address.", otpIdParamLabel); + } + } + + private OtpLog requireVerifiedRow(final Long otpId, final String otpIdParamLabel) { + if (otpId == null) { + throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.required", + "'" + otpIdParamLabel + "' is required to change this contact detail.", otpIdParamLabel); + } + final OtpLog row = otpLogRepository.findFirstByIdAndIsDeletedFalseOrderByIdDesc(otpId).orElse(null); + if (row == null) { + throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.not.found", + "No OTP record found for '" + otpIdParamLabel + "' = " + otpId + ".", otpIdParamLabel, otpId); + } + if (!PURPOSE_CLIENT_CONTACT_CHANGE.equals(row.getPurpose())) { + throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.purpose.mismatch", + "'" + otpIdParamLabel + "' does not reference a contact-change OTP.", otpIdParamLabel); + } + if (!STATUS_VERIFIED.equals(row.getStatus())) { + throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.not.verified", + "'" + otpIdParamLabel + "' has not been verified yet. Complete the OTP verification step first.", otpIdParamLabel); + } + return row; + } + + private boolean equalsIgnoringWhitespace(final String a, final String b) { + final String na = a == null ? null : a.trim(); + final String nb = b == null ? null : b.trim(); + return na != null && na.equals(nb); + } + + private boolean equalsIgnoreCaseAndWhitespace(final String a, final String b) { + final String na = a == null ? null : a.trim().toLowerCase(Locale.ROOT); + final String nb = b == null ? null : b.trim().toLowerCase(Locale.ROOT); + return na != null && na.equals(nb); + } + + // ── Helpers (deliberately duplicated from LoginOtpServiceImpl/EmailLoginOtpServiceImpl — + // see class Javadoc) ──────────────────────────────────────────────────── + + private String post(final String url, final String requestBodyJson) { + final HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("accept", "*/*"); + final HttpEntity entity = new HttpEntity<>(requestBodyJson, headers); + final ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + return response.getBody(); + } + + private JsonObject parseJson(final String raw) { + if (raw == null || raw.isBlank()) { + return new JsonObject(); + } + final JsonElement parsed = JsonParser.parseString(raw); + return parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject(); + } + + private boolean isSuccess(final JsonObject response) { + for (final String key : new String[] { "status", "statusCode", "responseCode", "code" }) { + if (response.has(key)) { + final JsonElement v = response.get(key); + if (v.isJsonPrimitive()) { + final String s = v.getAsString(); + if ("200".equals(s) || "0".equals(s) || "true".equalsIgnoreCase(s) || "success".equalsIgnoreCase(s) + || "SUCCESS".equalsIgnoreCase(s)) { + return true; + } + } + } + } + if (response.has("success") && response.get("success").isJsonPrimitive()) { + return response.get("success").getAsBoolean(); + } + return false; + } + + private String extractField(final JsonObject obj, final String... keys) { + if (obj == null) { + return null; + } + for (final String key : keys) { + if (obj.has(key) && obj.get(key).isJsonPrimitive()) { + return obj.get(key).getAsString(); + } + } + if (obj.has("data") && obj.get("data").isJsonObject()) { + final JsonObject data = obj.getAsJsonObject("data"); + for (final String key : keys) { + if (data.has(key) && data.get(key).isJsonPrimitive()) { + return data.get(key).getAsString(); + } + } + } + return null; + } + + private String generateSixDigitOtp() { + final int n = secureRandom.nextInt(1_000_000); + return String.format("%06d", n); + } + + private String sha256(final String input) { + try { + final MessageDigest md = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(md.digest(input.getBytes(StandardCharsets.UTF_8))); + } catch (final NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + private String truncate(final String s, final int max) { + if (s == null) { + return null; + } + return s.length() <= max ? s : s.substring(0, max); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientApiCollectionConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientApiCollectionConstants.java index 063cfc30c..28dcd60e0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientApiCollectionConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientApiCollectionConstants.java @@ -32,14 +32,16 @@ public class ClientApiCollectionConstants extends ClientApiConstants { dateOfBirthParamName, genderIdParamName, salutationIdParamName, clientTypeIdParamName, clientClassificationIdParamName, occupationCategoryIdParamName, occupationIdParamName, maritalStatusIdParamName, educationLevelIdParamName, degreeQualificationIdParamName, - religionIdParamName, socialCategoryIdParamName, monthlyIncomeParamName, annualTurnoverParamName, - agricultureIncomeParamName, nonAgricultureIncomeParamName, + religionIdParamName, socialCategoryIdParamName, monthlyIncomeParamName, annualIncomeParamName, annualTurnoverParamName, + monthlyTurnoverParamName, agricultureIncomeParamName, nonAgricultureIncomeParamName, + isSeniorCitizenParamName, isPwdParamName, pwdCategoryIdParamName, ageYearsParamName, ageMonthsParamName, ageDaysParamName, nationalityIdParamName, isMinorParamName, guardianClientIdParamName, guardianRelationParamName, dipVerifiedParamName, dipLookupLogIdParamName, dipTimestampParamName, alternateDipVerifiedParamName, alternateDipLookupLogIdParamName, alternateDipTimestampParamName, nameVerifiedParamName, screeningLogIdParamName, - clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, datatables, isStaffParamName, kycParamName)); + clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, datatables, isStaffParamName, kycParamName, + mobileNewOtpIdParamName, emailNewOtpIdParamName)); protected static final Set CLIENT_NON_PERSON_CREATE_REQUEST_DATA_PARAMETERS = new HashSet<>( Arrays.asList(familyMembers, address, localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, @@ -54,14 +56,16 @@ public class ClientApiCollectionConstants extends ClientApiConstants { dateOfBirthParamName, genderIdParamName, salutationIdParamName, clientTypeIdParamName, clientClassificationIdParamName, occupationCategoryIdParamName, occupationIdParamName, maritalStatusIdParamName, educationLevelIdParamName, degreeQualificationIdParamName, - religionIdParamName, socialCategoryIdParamName, monthlyIncomeParamName, annualTurnoverParamName, - agricultureIncomeParamName, nonAgricultureIncomeParamName, + religionIdParamName, socialCategoryIdParamName, monthlyIncomeParamName, annualIncomeParamName, annualTurnoverParamName, + monthlyTurnoverParamName, agricultureIncomeParamName, nonAgricultureIncomeParamName, + isSeniorCitizenParamName, isPwdParamName, pwdCategoryIdParamName, ageYearsParamName, ageMonthsParamName, ageDaysParamName, nationalityIdParamName, isMinorParamName, guardianClientIdParamName, guardianRelationParamName, dipVerifiedParamName, dipLookupLogIdParamName, dipTimestampParamName, alternateDipVerifiedParamName, alternateDipLookupLogIdParamName, alternateDipTimestampParamName, nameVerifiedParamName, screeningLogIdParamName, - submittedOnDateParamName, clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, isStaffParamName)); + submittedOnDateParamName, clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, isStaffParamName, + mobileOldOtpIdParamName, mobileNewOtpIdParamName, emailOldOtpIdParamName, emailNewOtpIdParamName)); protected static final Set CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>( Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientDataValidator.java index 6fa5e76e8..ddd3a9452 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientDataValidator.java @@ -166,6 +166,24 @@ public final class ClientDataValidator { .notExceedingLengthOf(50); } + // A mobile number/email address entered at client creation is never re-verified before this + // point — if it's a typo (wrong number, doesn't belong to this customer), the client would be + // permanently stuck later: editing it requires proving ownership of the OLD (wrong) value via + // OTP, which is impossible if that value was never real. Requiring a verified OTP for the + // value here — the same ClientContactOtpService used by the edit-time dual-OTP gate, just the + // single "new" leg since there's no "old" value yet — guarantees every mobileNo/emailAddress + // ever stored was actually reachable at the moment it was recorded. + if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.mobileNoParamName, element)) { + final Long mobileNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.mobileNewOtpIdParamName, element); + baseDataValidator.reset().parameter(ClientApiConstants.mobileNewOtpIdParamName).value(mobileNewOtpId).notNull() + .longGreaterThanZero(); + } + if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.emailAddressParamName, element)) { + final Long emailNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.emailNewOtpIdParamName, element); + baseDataValidator.reset().parameter(ClientApiConstants.emailNewOtpIdParamName).value(emailNewOtpId).notNull() + .longGreaterThanZero(); + } + if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.alternateMobileNoParamName, element)) { final String alternateMobileNo = this.fromApiJsonHelper.extractStringNamed(ClientApiConstants.alternateMobileNoParamName, element); baseDataValidator.reset().parameter(ClientApiConstants.alternateMobileNoParamName).value(alternateMobileNo).ignoreIfNull() @@ -495,6 +513,42 @@ public final class ClientDataValidator { .notExceedingLengthOf(50); } + // Dual-OTP contact-change gate: a mobileNo or emailAddress change must reference an + // already-VERIFIED m_otp_log row for the OLD destination and one for the NEW destination + // (see ClientContactOtpService + ClientWritePlatformServiceJpaRepositoryImpl.updateClient()). + // + // IMPORTANT: this validator only sees the raw request JSON, not the client's current DB row — + // it cannot tell "emailAddress is present but unchanged" (this codebase's edit-client screen + // always PUTs the full client object, changed fields or not) apart from "emailAddress is + // present and being changed". Asserting these params mandatory here would reject ordinary + // saves that don't touch contact details at all. The real gate — requiring a verified otpId + // ONLY when the value actually differs from the DB — already lives correctly in + // ClientWritePlatformServiceJpaRepositoryImpl.updateClient() (inside the + // isChangeInStringParameterNamed(...) blocks) via ClientContactOtpService.assertVerifiedMobile/ + // assertVerifiedEmail, which itself throws if the otpId is null. So here we only validate + // shape (a positive long) when the ids happen to be present, and leave presence-enforcement to + // the write service, which is the only place that knows whether this is a real change. + if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.mobileOldOtpIdParamName, element)) { + final Long mobileOldOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.mobileOldOtpIdParamName, element); + baseDataValidator.reset().parameter(ClientApiConstants.mobileOldOtpIdParamName).value(mobileOldOtpId).ignoreIfNull() + .longGreaterThanZero(); + } + if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.mobileNewOtpIdParamName, element)) { + final Long mobileNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.mobileNewOtpIdParamName, element); + baseDataValidator.reset().parameter(ClientApiConstants.mobileNewOtpIdParamName).value(mobileNewOtpId).ignoreIfNull() + .longGreaterThanZero(); + } + if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.emailOldOtpIdParamName, element)) { + final Long emailOldOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.emailOldOtpIdParamName, element); + baseDataValidator.reset().parameter(ClientApiConstants.emailOldOtpIdParamName).value(emailOldOtpId).ignoreIfNull() + .longGreaterThanZero(); + } + if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.emailNewOtpIdParamName, element)) { + final Long emailNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.emailNewOtpIdParamName, element); + baseDataValidator.reset().parameter(ClientApiConstants.emailNewOtpIdParamName).value(emailNewOtpId).ignoreIfNull() + .longGreaterThanZero(); + } + final Boolean active = this.fromApiJsonHelper.extractBooleanNamed(ClientApiConstants.activeParamName, element); if (active != null) { atLeastOneParameterPassedForUpdate = true; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientPendingEditData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientPendingEditData.java new file mode 100644 index 000000000..aaeb9f137 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientPendingEditData.java @@ -0,0 +1,44 @@ +/** + * 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.OffsetDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class ClientPendingEditData { + + private final Long id; + private final Long clientId; + private final String clientDisplayName; + private final String payloadJson; + private final String originalJson; + private final String status; + private final Long makerId; + private final String makerName; + private final OffsetDateTime submittedDate; + private final Long checkerId; + private final String checkerName; + private final OffsetDateTime checkedDate; + private final String rejectReason; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleData.java new file mode 100644 index 000000000..61772d879 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleData.java @@ -0,0 +1,133 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.data; + +import java.io.Serializable; +import java.math.BigDecimal; + +public final class KycRiskRuleData implements Serializable { + + private static final long serialVersionUID = 1L; + + private final Long id; + private final String ruleName; + private final Integer legalFormId; + private final Long occupationCategoryCvId; + private final String occupationCategoryName; + private final Long occupationCvId; + private final String occupationName; + private final BigDecimal minMonthlyIncome; + private final BigDecimal maxMonthlyIncome; + private final BigDecimal minAnnualIncome; + private final BigDecimal maxAnnualIncome; + private final BigDecimal minAnnualTurnover; + private final BigDecimal maxAnnualTurnover; + private final Integer riskCategory; + private final String reason; + private final Integer orderPosition; + + public KycRiskRuleData(final Long id, final String ruleName, final Integer legalFormId, + final Long occupationCategoryCvId, final String occupationCategoryName, + final Long occupationCvId, final String occupationName, + final BigDecimal minMonthlyIncome, final BigDecimal maxMonthlyIncome, + final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome, + final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover, + final Integer riskCategory, final String reason, final Integer orderPosition) { + this.id = id; + this.ruleName = ruleName; + this.legalFormId = legalFormId; + this.occupationCategoryCvId = occupationCategoryCvId; + this.occupationCategoryName = occupationCategoryName; + this.occupationCvId = occupationCvId; + this.occupationName = occupationName; + this.minMonthlyIncome = minMonthlyIncome; + this.maxMonthlyIncome = maxMonthlyIncome; + this.minAnnualIncome = minAnnualIncome; + this.maxAnnualIncome = maxAnnualIncome; + this.minAnnualTurnover = minAnnualTurnover; + this.maxAnnualTurnover = maxAnnualTurnover; + this.riskCategory = riskCategory; + this.reason = reason; + this.orderPosition = orderPosition; + } + + public Long getId() { + return id; + } + + public String getRuleName() { + return ruleName; + } + + public Integer getLegalFormId() { + return legalFormId; + } + + public Long getOccupationCategoryCvId() { + return occupationCategoryCvId; + } + + public String getOccupationCategoryName() { + return occupationCategoryName; + } + + public Long getOccupationCvId() { + return occupationCvId; + } + + public String getOccupationName() { + return occupationName; + } + + public BigDecimal getMinMonthlyIncome() { + return minMonthlyIncome; + } + + public BigDecimal getMaxMonthlyIncome() { + return maxMonthlyIncome; + } + + public BigDecimal getMinAnnualIncome() { + return minAnnualIncome; + } + + public BigDecimal getMaxAnnualIncome() { + return maxAnnualIncome; + } + + public BigDecimal getMinAnnualTurnover() { + return minAnnualTurnover; + } + + public BigDecimal getMaxAnnualTurnover() { + return maxAnnualTurnover; + } + + public Integer getRiskCategory() { + return riskCategory; + } + + public String getReason() { + return reason; + } + + public Integer getOrderPosition() { + return orderPosition; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientFamilyMembersRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientFamilyMembersRepository.java index e9614d5bd..9276827f9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientFamilyMembersRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientFamilyMembersRepository.java @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.client.domain; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; @@ -52,4 +53,11 @@ public interface ClientFamilyMembersRepository @Param("relationshipId") Long relationshipId, @Param("excludeFamilyMemberId") Long excludeFamilyMemberId); + /** + * All non-deleted family members for a client, with their relationship CodeValue. Backs the + * "Mother required, plus Father or Caretaker" activation gate. + */ + @Query("SELECT fm FROM ClientFamilyMembers fm WHERE fm.client.id = :clientId AND fm.isDeleted = false") + List findAllActiveByClient(@Param("clientId") Long clientId); + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEdit.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEdit.java new file mode 100644 index 000000000..96bb93a8d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEdit.java @@ -0,0 +1,132 @@ +/** + * 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.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.useradministration.domain.AppUser; + +/** + * Bespoke maker-checker for Client edits — modelled on {@link ClientPendingEditStatus} / + * {@code m_share_account_pending_edit} elsewhere in this codebase rather than Fineract's native + * command-approval engine, because that engine's hold is bypassed by any role carrying + * ALL_FUNCTIONS (Super user, Clerk in this tenant), making it a no-op for this CBS's actual admin + * accounts. + * + *

Holds the maker's raw PUT /clients/{id}/edit payload as-is (rather than one typed column per + * editable Client field, which — given how many fields Client actually has across general, + * financial-classification and KYC-adjacent data — would mean a huge, fragile shadow-column table + * that has to be kept in lockstep with the entity forever). Approval reconstructs a + * {@code JsonCommand} from the stored JSON and replays it through the existing + * {@code ClientWritePlatformService.updateClient(...)}, so all of Client's existing validation and + * change-detection logic runs exactly as it would for a direct edit — just deferred until a + * different user approves it. The maker can never approve/reject their own submission — enforced + * in the write service, not just by role, with no bypass for CHECKER_SUPER_USER. + */ +@Entity +@Table(name = "m_client_pending_edit") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ClientPendingEdit extends AbstractPersistableCustom { + + @ManyToOne + @JoinColumn(name = "client_id", nullable = false) + private Client client; + + @Column(name = "payload_json", nullable = false) + private String payloadJson; + + /** Point-in-time "before" snapshot the maker's client had when the edit was submitted — see + * client-edit-diff.util.ts's buildOriginalValuesSnapshot(). Null for edits submitted before + * this column existed. Lets the checker's compare view show a true historical diff regardless + * of whether the live client has since changed (e.g. once approved, current == proposed). */ + @Column(name = "original_json") + private String originalJson; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @Column(name = "maker_id", nullable = false) + private Long makerId; + + @Column(name = "submitted_date", nullable = false) + private OffsetDateTime submittedDate; + + @Column(name = "checker_id") + private Long checkerId; + + @Column(name = "checked_date") + private OffsetDateTime checkedDate; + + @Column(name = "reject_reason", length = 500) + private String rejectReason; + + @Column(name = "pending_lock_key", length = 60, unique = true) + private String pendingLockKey; + + public static ClientPendingEdit submit(final Client client, final String payloadJson, final String originalJson, + final AppUser maker) { + final ClientPendingEdit edit = new ClientPendingEdit(); + edit.client = client; + edit.payloadJson = payloadJson; + edit.originalJson = originalJson; + edit.status = ClientPendingEditStatus.PENDING.name(); + edit.makerId = maker.getId(); + edit.submittedDate = DateUtils.getOffsetDateTimeOfTenant(); + edit.pendingLockKey = lockKeyFor(client.getId()); + return edit; + } + + public static String lockKeyFor(final Long clientId) { + return "CLIENT_EDIT:" + clientId; + } + + public boolean isPending() { + return ClientPendingEditStatus.PENDING.name().equals(this.status); + } + + /** The maker can never be their own checker — a distinct human must approve/reject an edit. */ + public boolean isMadeBy(final Long userId) { + return this.makerId != null && this.makerId.equals(userId); + } + + public void approve(final AppUser checker) { + this.status = ClientPendingEditStatus.APPROVED.name(); + this.checkerId = checker.getId(); + this.checkedDate = DateUtils.getOffsetDateTimeOfTenant(); + this.pendingLockKey = null; + } + + public void reject(final AppUser checker, final String reason) { + this.status = ClientPendingEditStatus.REJECTED.name(); + this.checkerId = checker.getId(); + this.checkedDate = DateUtils.getOffsetDateTimeOfTenant(); + this.rejectReason = reason; + this.pendingLockKey = null; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditRepository.java new file mode 100644 index 000000000..a819e8128 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditRepository.java @@ -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.client.domain; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ClientPendingEditRepository extends JpaRepository { + + Optional findByClientIdAndStatus(Long clientId, String status); + + List findByStatusOrderBySubmittedDateDesc(String status); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditRepositoryWrapper.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditRepositoryWrapper.java new file mode 100644 index 000000000..97c1c8112 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditRepositoryWrapper.java @@ -0,0 +1,49 @@ +/** + * 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 java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.client.exception.ClientNoPendingEditException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ClientPendingEditRepositoryWrapper { + + private final ClientPendingEditRepository repository; + + public ClientPendingEdit findPendingByClientIdWithNotFoundDetection(final Long clientId) { + return this.repository.findByClientIdAndStatus(clientId, ClientPendingEditStatus.PENDING.name()) + .orElseThrow(() -> new ClientNoPendingEditException(clientId)); + } + + public Optional findPendingByClientId(final Long clientId) { + return this.repository.findByClientIdAndStatus(clientId, ClientPendingEditStatus.PENDING.name()); + } + + public List findAllPending() { + return this.repository.findByStatusOrderBySubmittedDateDesc(ClientPendingEditStatus.PENDING.name()); + } + + public ClientPendingEdit saveAndFlush(final ClientPendingEdit edit) { + return this.repository.saveAndFlush(edit); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditStatus.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditStatus.java new file mode 100644 index 000000000..306d4a4ee --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditStatus.java @@ -0,0 +1,27 @@ +/** + * 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; + +/** Lifecycle of a {@link ClientPendingEdit}. Terminal once APPROVED or REJECTED. */ +public enum ClientPendingEditStatus { + + PENDING, // + APPROVED, // + REJECTED; // +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientActivationSelfApprovalNotAllowedException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientActivationSelfApprovalNotAllowedException.java new file mode 100644 index 000000000..c5416963e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientActivationSelfApprovalNotAllowedException.java @@ -0,0 +1,35 @@ +/** + * 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.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +/** + * Thrown when the user attempting to activate a client is the same user who created it — a + * four-eyes (maker-checker) requirement with no bypass, including for users holding + * CHECKER_SUPER_USER, since a bypass would recreate the exact "ALL_FUNCTIONS/super user ignores + * the hold" loophole this check exists to close. + */ +public class ClientActivationSelfApprovalNotAllowedException extends AbstractPlatformDomainRuleException { + + public ClientActivationSelfApprovalNotAllowedException() { + super("error.msg.client.activation.self.approval.not.allowed", + "You cannot activate a client you created yourself — a different user must activate it."); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientEditPendingApprovalException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientEditPendingApprovalException.java new file mode 100644 index 000000000..88a28de33 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientEditPendingApprovalException.java @@ -0,0 +1,29 @@ +/** + * 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.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class ClientEditPendingApprovalException extends AbstractPlatformDomainRuleException { + + public ClientEditPendingApprovalException(final Long clientId) { + super("error.msg.client.edit.pending.approval", "Client with identifier " + clientId + " already has an edit pending approval", + clientId); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientEditSelfApprovalNotAllowedException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientEditSelfApprovalNotAllowedException.java new file mode 100644 index 000000000..eed902092 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientEditSelfApprovalNotAllowedException.java @@ -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.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +/** + * Thrown when the user attempting to approve/reject a Client's pending edit is the same user who + * submitted it — no bypass, including for users holding CHECKER_SUPER_USER, since a bypass would + * recreate the exact "role bypasses the hold" loophole this check exists to close. + */ +public class ClientEditSelfApprovalNotAllowedException extends AbstractPlatformDomainRuleException { + + public ClientEditSelfApprovalNotAllowedException(final Long clientId) { + super("error.msg.client.edit.self.approval.not.allowed", + "You cannot approve or reject an edit you submitted yourself for client " + clientId + + " — a different user must review it.", + clientId); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientFamilyMemberFatherOrCaretakerRequiredException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientFamilyMemberFatherOrCaretakerRequiredException.java new file mode 100644 index 000000000..1f6b37de5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientFamilyMemberFatherOrCaretakerRequiredException.java @@ -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.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +/** + * Thrown on client activation when neither an active "Father" nor "Caretaker" family member has + * been recorded. Kept separate from {@link ClientFamilyMemberMotherRequiredException} so the two + * failure reasons surface as distinct errors instead of one merged message. + */ +public class ClientFamilyMemberFatherOrCaretakerRequiredException extends AbstractPlatformDomainRuleException { + + public ClientFamilyMemberFatherOrCaretakerRequiredException() { + super("error.msg.client.family.member.father.or.caretaker.required", + "Either Father or a Caretaker is required as a family member before this client can be activated."); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientFamilyMemberMotherRequiredException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientFamilyMemberMotherRequiredException.java new file mode 100644 index 000000000..54fb99532 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientFamilyMemberMotherRequiredException.java @@ -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.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +/** + * Thrown on client activation when no active "Mother" family member has been recorded. Kept + * separate from {@link ClientFamilyMemberFatherOrCaretakerRequiredException} so the two failure + * reasons surface as distinct errors instead of one merged message. + */ +public class ClientFamilyMemberMotherRequiredException extends AbstractPlatformDomainRuleException { + + public ClientFamilyMemberMotherRequiredException() { + super("error.msg.client.family.member.mother.required", + "Mother is required as a family member before this client can be activated."); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientNoPendingEditException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientNoPendingEditException.java new file mode 100644 index 000000000..9f28f80cf --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientNoPendingEditException.java @@ -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.client.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class ClientNoPendingEditException extends AbstractPlatformDomainRuleException { + + public ClientNoPendingEditException(final Long clientId) { + super("error.msg.client.no.pending.edit", "Client with identifier " + clientId + " has no edit pending approval", clientId); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/mapper/ClientMapper.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/mapper/ClientMapper.java index 38aadc990..c9b7dddcf 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/mapper/ClientMapper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/mapper/ClientMapper.java @@ -34,6 +34,7 @@ import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.MappingTarget; import org.mapstruct.Named; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Mapper(config = MapstructMapperConfig.class) public interface ClientMapper { @@ -65,12 +66,14 @@ public interface ClientMapper { @Mapping(target = "degreeQualification", source = "source", qualifiedByName = "degreeQualificationCode") @Mapping(target = "religion", source = "source", qualifiedByName = "religionCode") @Mapping(target = "socialCategory", source = "source", qualifiedByName = "socialCategoryCode") + @Mapping(target = "pwdCategory", source = "source", qualifiedByName = "pwdCategoryCode") @Mapping(target = "occupationCategoryOptions", ignore = true) @Mapping(target = "occupationOptions", ignore = true) @Mapping(target = "educationLevelOptions", ignore = true) @Mapping(target = "degreeQualificationOptions", ignore = true) @Mapping(target = "religionOptions", ignore = true) @Mapping(target = "socialCategoryOptions", ignore = true) + @Mapping(target = "pwdCategoryOptions", ignore = true) @Mapping(target = "legalForm", source = "source", qualifiedByName = "clientLegalFormEnum") @Mapping(target = "isStaff", source = "source", qualifiedByName = "clientIsStaff") @Mapping(target = "ageYears", ignore = true) @@ -196,6 +199,15 @@ public interface ClientMapper { return CodeValueData.instance(code.getId(), code.getLabel()); } + @Named("pwdCategoryCode") + default CodeValueData pwdCategoryCode(Client client) { + final CodeValue code = client.getPwdCategory(); + if (code == null) { + return null; + } + return CodeValueData.instance(code.getId(), code.getLabel()); + } + @Named("clientSubStatusCode") default CodeValueData clientSubStatusCode(Client client) { final CodeValue code = client.getSubStatus(); @@ -271,7 +283,7 @@ public interface ClientMapper { default void computeAge(Client source, @MappingTarget ClientData target) { final LocalDate dob = source.getDateOfBirth(); if (dob != null) { - final Period age = Period.between(dob, LocalDate.now()); + final Period age = Period.between(dob, LocalDate.now(DateUtils.getDateTimeZoneOfTenant())); target.setAgeYears(age.getYears()); target.setAgeMonths(age.getMonths()); target.setAgeDays(age.getDays()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/CifNumberGenerator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/CifNumberGenerator.java index b54290143..c02688dc9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/CifNumberGenerator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/CifNumberGenerator.java @@ -25,6 +25,7 @@ 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 TTYYYYNNNNNN: @@ -90,6 +91,6 @@ public class CifNumberGenerator { private int resolveYear(final Client client) { final LocalDate submittedOn = client.getSubmittedOnDate(); - return (submittedOn != null ? submittedOn : LocalDate.now()).getYear(); + return (submittedOn != null ? submittedOn : LocalDate.now(DateUtils.getDateTimeZoneOfTenant())).getYear(); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientChangeNotificationService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientChangeNotificationService.java new file mode 100644 index 000000000..03f313dae --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientChangeNotificationService.java @@ -0,0 +1,57 @@ +/** + * 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.domain.Client; + +/** + * Notifies a client (SMS + email) whenever their mobileNo, emailAddress, or address changes. Called + * from {@code ClientWritePlatformServiceJpaRepositoryImpl.updateClient()} (after a successful + * mobileNo/emailAddress save) and from {@code AddressWritePlatformServiceImpl.updateClientAddress()} + * (after a successful address save, notification-only — address changes are not gated by OTP). + */ +public interface ClientChangeNotificationService { + + String FIELD_LABEL_MOBILE_NO = "Mobile Number"; + String FIELD_LABEL_EMAIL_ADDRESS = "Email Address"; + String FIELD_LABEL_ADDRESS = "Address"; + + /** + * @param client the client whose contact detail changed (already saved with the NEW value at + * the time this is called) + * @param fieldLabel one of {@link #FIELD_LABEL_MOBILE_NO}, {@link #FIELD_LABEL_EMAIL_ADDRESS}, or + * {@link #FIELD_LABEL_ADDRESS} — use these exact constants so the implementation's + * "notify both old and new email owner" special case is triggered correctly. + * @param oldValue the value being replaced (raw, unmasked — the implementation masks contact + * destinations for display and uses the raw value to decide where to send) + * @param newValue the value it was changed to (raw, unmasked) + * @param emailAddressBeforeUpdate the client's email address as it was BEFORE this update + * transaction touched anything — used (for the mobile/address case) as the + * notification recipient instead of {@code client.getEmailAddress()}. This + * matters when mobile and email are both changed in the same request: by the + * time this is called the in-memory client already carries the NEW email, so + * reading it directly would send the "Mobile Number Updated" alert to the new + * (unverified-by-the-real-owner-until-now) address instead of the address the + * customer actually still controls. Ignored for the + * {@link #FIELD_LABEL_EMAIL_ADDRESS} case, which already always notifies + * {@code oldValue} directly. + */ + void notifyContactOrAddressChanged(Client client, String fieldLabel, String oldValue, String newValue, + String emailAddressBeforeUpdate); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientChangeNotificationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientChangeNotificationServiceImpl.java new file mode 100644 index 000000000..2c8af5d72 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientChangeNotificationServiceImpl.java @@ -0,0 +1,160 @@ +/** + * 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.time.format.DateTimeFormatter; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.email.service.EmailSenderService; +import org.apache.fineract.infrastructure.email.service.EmailTemplateService; +import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker; +import org.apache.fineract.portfolio.client.domain.Client; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.apache.fineract.infrastructure.core.service.DateUtils; + +/** + * See {@link ClientChangeNotificationService}. + * + *

+ * Email uses {@link EmailTemplateService#renderAlert(String, String, String)} (the existing generic + * branded "alert" template — not the OTP one) sent via {@link EmailSenderService}, the same + * lightweight direct-SMTP path (configured from this app's own {@code spring.mail.*} properties) + * already used by {@code EmailLoginOtpServiceImpl} for OTP emails. Deliberately does NOT use + * {@code PlatformEmailService} / {@code GmailBackedPlatformEmailService} — that path resolves SMTP + * credentials from {@code ExternalServicesPropertiesReadPlatformService} (a DB-configured "external + * services" table used by the campaign-email pipeline), which is a separate, likely-unconfigured + * configuration surface in this deployment. See class Javadoc discussion in the feature plan. + * + *

+ * SMS is a deliberate no-op-with-warning-log: LincPay's confirmed API surface in this codebase + * ({@code fineract.security.otp.lincpay.generate-path=/mobile/generate-otp} and + * {@code .../verify-path}) only exposes OTP generate/verify, not a plain "send this exact message" + * SMS endpoint. Routing a notification through the OTP-generate endpoint would create a real, + * verifiable OTP the client never asked to receive, which is worse than sending nothing. See + * {@link #sendSmsNotification(Client, String, String, String)}. + */ +@Service +public class ClientChangeNotificationServiceImpl implements ClientChangeNotificationService { + + private static final Logger LOG = LoggerFactory.getLogger(ClientChangeNotificationServiceImpl.class); + + private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("dd-MMM-yyyy HH:mm"); + + @Value("${fineract.email.from-name:CBSnexora}") + private String appName; + + private final EmailSenderService emailSenderService; + private final EmailTemplateService emailTemplateService; + + @Autowired + public ClientChangeNotificationServiceImpl(final EmailSenderService emailSenderService, + final EmailTemplateService emailTemplateService) { + this.emailSenderService = emailSenderService; + this.emailTemplateService = emailTemplateService; + } + + @Override + public void notifyContactOrAddressChanged(final Client client, final String fieldLabel, final String oldValue, + final String newValue, final String emailAddressBeforeUpdate) { + if (client == null) { + return; + } + sendEmailNotification(client, fieldLabel, oldValue, newValue, emailAddressBeforeUpdate); + sendSmsNotification(client, fieldLabel, oldValue, newValue); + } + + private void sendEmailNotification(final Client client, final String fieldLabel, final String oldValue, final String newValue, + final String emailAddressBeforeUpdate) { + final String recipientName = displayName(client); + final String alertTitle = fieldLabel + " Updated"; + final String alertMessage = "Your " + fieldLabel.toLowerCase() + " on file was changed from " + + describeForDisplay(fieldLabel, oldValue) + " to " + describeForDisplay(fieldLabel, newValue) + " on " + + LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()).format(TIMESTAMP) + ". If you did not authorize this change, please contact us immediately."; + final String html = emailTemplateService.renderAlert(recipientName, alertTitle, alertMessage); + final String subject = alertTitle + " - " + appName; + + if (FIELD_LABEL_EMAIL_ADDRESS.equals(fieldLabel)) { + // When the email address itself is what changed, alert only the OLD address — the new + // address already proved ownership via the dual-OTP verification step at change time, so a + // duplicate confirmation there is unnecessary; the security-relevant alert is to the + // previous owner, in case the change wasn't authorized by them. + sendIfPresent(oldValue, subject, html); + } else { + // Mobile-number or address change: always alert the email the customer had BEFORE this + // update, never `client.getEmailAddress()` — if email was also changed in the very same + // request, the in-memory client already carries the NEW address by the time this runs, and + // sending "Mobile Number Updated" there would tell nobody but the (possibly unauthorized) + // new owner. The old address is the one the real customer is guaranteed to still read. + sendIfPresent(emailAddressBeforeUpdate, subject, html); + } + } + + private void sendIfPresent(final String to, final String subject, final String html) { + if (StringUtils.isBlank(to)) { + LOG.warn("Change-notification email skipped — no recipient address available."); + return; + } + emailSenderService.sendHtml(to, subject, html); + } + + /** + * SMS notification — see class Javadoc. Left as a clearly-logged no-op pending a confirmed + * LincPay plain-SMS endpoint; deliberately does NOT call the OTP-generate endpoint as a + * workaround. + */ + private void sendSmsNotification(final Client client, final String fieldLabel, final String oldValue, final String newValue) { + final String mobileNo = client.getMobileNo(); + if (StringUtils.isBlank(mobileNo)) { + LOG.warn("Change-notification SMS skipped — client {} has no registered mobile number.", client.getId()); + return; + } + LOG.warn( + "SMS change-notification NOT sent for client {} (field='{}') — no confirmed LincPay plain-SMS endpoint exists yet " + + "(only /mobile/generate-otp and /verify-mobile-otp are configured). Falling back to email-only " + + "notification. See ClientChangeNotificationServiceImpl Javadoc / TODO. mobile={}", + client.getId(), fieldLabel, OtpDestinationMasker.maskMobile(mobileNo)); + } + + private String describeForDisplay(final String fieldLabel, final String value) { + if (StringUtils.isBlank(value)) { + return "(not set)"; + } + if (FIELD_LABEL_MOBILE_NO.equals(fieldLabel)) { + return OtpDestinationMasker.maskMobile(value); + } + if (FIELD_LABEL_EMAIL_ADDRESS.equals(fieldLabel)) { + return OtpDestinationMasker.maskEmail(value); + } + return value; + } + + private String displayName(final Client client) { + if (StringUtils.isNotBlank(client.getFirstname())) { + return client.getFirstname(); + } + if (StringUtils.isNotBlank(client.getDisplayName())) { + return client.getDisplayName(); + } + return "Customer"; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditWritePlatformService.java new file mode 100644 index 000000000..4bb8011be --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditWritePlatformService.java @@ -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.client.service; + +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +/** Maker-checker for Client edits — see {@link org.apache.fineract.portfolio.client.domain.ClientPendingEdit}. */ +public interface ClientEditWritePlatformService { + + CommandProcessingResult submitEditForApproval(Long clientId, JsonCommand command); + + CommandProcessingResult approveEdit(Long clientId); + + CommandProcessingResult rejectEdit(Long clientId, String rejectReason); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditWritePlatformServiceImpl.java new file mode 100644 index 000000000..d56095e39 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditWritePlatformServiceImpl.java @@ -0,0 +1,136 @@ +/** + * 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 com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.client.domain.ClientPendingEdit; +import org.apache.fineract.portfolio.client.domain.ClientPendingEditRepositoryWrapper; +import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.apache.fineract.portfolio.client.exception.ClientEditPendingApprovalException; +import org.apache.fineract.portfolio.client.exception.ClientEditSelfApprovalNotAllowedException; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatformService { + + /** Matches ORIGINAL_VALUES_PAYLOAD_KEY in client-edit-diff.util.ts. */ + private static final String ORIGINAL_VALUES_PAYLOAD_KEY = "_originalValues"; + + private final PlatformSecurityContext context; + private final ClientRepositoryWrapper clientRepository; + private final ClientPendingEditRepositoryWrapper pendingEditRepository; + private final ClientWritePlatformService clientWritePlatformService; + private final FromJsonHelper fromApiJsonHelper; + + @Transactional + @Override + public CommandProcessingResult submitEditForApproval(final Long clientId, final JsonCommand command) { + final AppUser maker = this.context.authenticatedUser(); + final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId); + + // Four-eyes lock: refuse a second maker while one edit is already awaiting approval for + // this client, rather than silently overwriting or queueing it — matches + // m_share_account_pending_edit / m_savings_product_temp elsewhere in this codebase. + if (this.pendingEditRepository.findPendingByClientId(clientId).isPresent()) { + throw new ClientEditPendingApprovalException(clientId); + } + + // The frontend rides its point-in-time "before" snapshot along under _originalValues so + // the checker's compare view can show a true historical diff later, even after the edit + // has been applied (by then the live client equals the proposed values, not the original + // ones — see client-edit-diff.util.ts). Split it out here: the rest replays through + // updateClient() at approval time and must not carry an unrecognised parameter. + final JsonElement parsedPayload = this.fromApiJsonHelper.parse(command.json()); + String originalJson = null; + String cleanPayloadJson = command.json(); + if (parsedPayload != null && parsedPayload.isJsonObject()) { + final JsonObject payloadObject = parsedPayload.getAsJsonObject(); + final JsonElement originalValues = payloadObject.remove(ORIGINAL_VALUES_PAYLOAD_KEY); + if (originalValues != null) { + originalJson = originalValues.toString(); + } + cleanPayloadJson = payloadObject.toString(); + } + + final ClientPendingEdit pendingEdit = ClientPendingEdit.submit(client, cleanPayloadJson, originalJson, maker); + this.pendingEditRepository.saveAndFlush(pendingEdit); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withOfficeId(client.officeId()) // + .withClientId(clientId) // + .withEntityId(pendingEdit.getId()) // + .build(); + } + + @Transactional + @Override + public CommandProcessingResult approveEdit(final Long clientId) { + final AppUser checker = this.context.authenticatedUser(); + final ClientPendingEdit pendingEdit = this.pendingEditRepository.findPendingByClientIdWithNotFoundDetection(clientId); + + // No bypass — including CHECKER_SUPER_USER — a bypass here would recreate the exact + // "role bypasses the hold" loophole this whole feature exists to close. + if (pendingEdit.isMadeBy(checker.getId())) { + throw new ClientEditSelfApprovalNotAllowedException(clientId); + } + + // Replay the staged JSON through the SAME write-service method a direct PUT would have + // used, so every bit of Client's existing validation/change-detection logic runs exactly + // as it would for an immediate edit — just deferred until a different user approves it. + final JsonElement parsedJson = this.fromApiJsonHelper.parse(pendingEdit.getPayloadJson()); + final JsonCommand replayedCommand = JsonCommand.fromJsonElement(clientId, parsedJson, this.fromApiJsonHelper); + final CommandProcessingResult result = this.clientWritePlatformService.updateClient(clientId, replayedCommand); + + pendingEdit.approve(checker); + this.pendingEditRepository.saveAndFlush(pendingEdit); + + return result; + } + + @Transactional + @Override + public CommandProcessingResult rejectEdit(final Long clientId, final String rejectReason) { + final AppUser checker = this.context.authenticatedUser(); + final ClientPendingEdit pendingEdit = this.pendingEditRepository.findPendingByClientIdWithNotFoundDetection(clientId); + + if (pendingEdit.isMadeBy(checker.getId())) { + throw new ClientEditSelfApprovalNotAllowedException(clientId); + } + + pendingEdit.reject(checker, rejectReason); + this.pendingEditRepository.saveAndFlush(pendingEdit); + + return new CommandProcessingResultBuilder() // + .withClientId(clientId) // + .withEntityId(pendingEdit.getId()) // + .build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformService.java index 9b7dd7edb..54da8b18a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformService.java @@ -33,4 +33,12 @@ public interface ClientFamilyMembersWritePlatformService { CommandProcessingResult deleteFamilyMember(Long familyMemberId, JsonCommand command); + /** + * Enforces that an individual client has "Mother" plus either "Father" or "Caretaker" recorded + * as active family members. No-op for non-individual (entity) clients. Throws + * {@code ClientFamilyMemberMotherRequiredException} or + * {@code ClientFamilyMemberFatherOrCaretakerRequiredException} on failure. + */ + void validateFamilyRequirementsForActivation(Client client); + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java index 1e9d342d9..8fa38b8c8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java @@ -37,10 +37,13 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import java.util.List; import org.apache.fineract.portfolio.client.domain.Client; import org.apache.fineract.portfolio.client.domain.ClientFamilyMembers; import org.apache.fineract.portfolio.client.domain.ClientFamilyMembersRepository; import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberFatherOrCaretakerRequiredException; +import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberMotherRequiredException; import org.apache.fineract.portfolio.client.serialization.ClientFamilyMemberCommandFromApiJsonDeserializer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -131,6 +134,47 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily } } + @Override + public void validateFamilyRequirementsForActivation(final Client client) { + if (client == null || client.getId() == null || client.getLegalForm() == null) { + return; + } + // Family (Mother/Father/Caretaker) is an individual-client concept — see + // assertCanHaveFamilyMembers above; non-individual entities are exempt from this gate. + if (org.apache.fineract.portfolio.client.domain.LegalForm.fromInt(client.getLegalForm()).isEntity()) { + return; + } + + final List members = clientFamilyRepository.findAllActiveByClient(client.getId()); + boolean hasMother = false; + boolean hasFather = false; + boolean hasCaretaker = false; + for (final ClientFamilyMembers member : members) { + final CodeValue relationship = member.getRelationship(); + if (relationship == null || relationship.getLabel() == null) { + continue; + } + final String label = relationship.getLabel().trim().toLowerCase(Locale.ROOT); + switch (label) { + case "mother" -> hasMother = true; + case "father" -> hasFather = true; + case "caretaker" -> hasCaretaker = true; + default -> { + // other relationships don't affect this rule + } + } + } + + // Reported as two independent failures rather than one merged message, so the caller can + // tell which specific requirement is unmet. + if (!hasMother) { + throw new ClientFamilyMemberMotherRequiredException(); + } + if (!hasFather && !hasCaretaker) { + throw new ClientFamilyMemberFatherOrCaretakerRequiredException(); + } + } + @Override public CommandProcessingResult addFamilyMember(final long clientId, final JsonCommand command) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java index e1642e84b..bae1084f9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java @@ -84,9 +84,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc this.context.authenticatedUser(); LOG.info("initiateKyc: clientId={}, requestJson={}", clientId, command.json()); + final Client clientForValidation = clientRepository.findOneWithNotFoundDetection(clientId); + final ClientKycCommand kycCommand = deserializer.commandFromApiJson( command.json(), ClientKycApiConstants.INITIATE_REQUEST_PARAMS); - kycCommand.validateForInitiate(); + // Minors identify via the Birth Certificate / School ID document-type picker (a separate + // upload path), not PAN/Aadhaar/GSTIN — so the "at least one ID" check doesn't apply to them. + kycCommand.validateForInitiate(Boolean.TRUE.equals(clientForValidation.getIsMinor())); // CIN / GSTIN are only applicable for NON_INDIVIDUAL clients final Integer kycTypeOnInitiate = command.integerValueOfParameterNamed("kycType"); @@ -104,7 +108,7 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc } try { - final Client client = clientRepository.findOneWithNotFoundDetection(clientId); + final Client client = clientForValidation; final ClientKyc kyc = ClientKyc.initiateKyc(client, command); kycRepositoryWrapper.saveAndFlush(kyc); @@ -957,7 +961,7 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc public void initiateKycInline(final Client client, final String kycJson) { final ClientKycCommand kycCommand = deserializer.commandFromApiJson( kycJson, ClientKycApiConstants.INITIATE_REQUEST_PARAMS); - kycCommand.validateForInitiate(); + kycCommand.validateForInitiate(Boolean.TRUE.equals(client.getIsMinor())); if (kycRepositoryWrapper.existsByClientId(client.getId())) { LOG.info("KYC record already exists for client {}. Skipping inline initiation.", client.getId()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientNomineeWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientNomineeWritePlatformServiceImpl.java index 46ec0bace..7675c811f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientNomineeWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientNomineeWritePlatformServiceImpl.java @@ -44,6 +44,8 @@ import org.apache.fineract.portfolio.client.domain.LegalForm; import org.apache.fineract.portfolio.client.exception.ClientNotFoundException; import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; import org.apache.fineract.portfolio.savings.domain.SavingsAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository; import org.springframework.stereotype.Service; @@ -62,6 +64,7 @@ public class ClientNomineeWritePlatformServiceImpl implements ClientNomineeWrite private final ClientNomineeRepository nomineeRepository; private final CodeValueRepositoryWrapper codeValueRepository; private final SavingsAccountRepository savingsAccountRepository; + private final CurrentAccountRepository currentAccountRepository; private final LoanRepository loanRepository; private final ClientNonPersonRepositoryWrapper clientNonPersonRepository; @@ -299,8 +302,18 @@ public class ClientNomineeWritePlatformServiceImpl implements ClientNomineeWrite throw new PlatformDataIntegrityException("error.msg.nominee.loan.account.not.found", "Loan account '" + accountNo + "' not found for client " + clientId + ".", "accountNo"); } + } else if ("CB".equals(accountType)) { + final CurrentAccount ca = this.currentAccountRepository.findByAccountNo(accountNo).orElse(null); + if (ca == null) { + throw new PlatformDataIntegrityException("error.msg.nominee.current.account.not.found", + "Account '" + accountNo + "' not found.", "accountNo"); + } + if (ca.getClient() == null || !ca.getClient().getId().equals(clientId)) { + throw new PlatformDataIntegrityException("error.msg.nominee.account.does.not.belong.to.client", + "Account '" + accountNo + "' does not belong to client " + clientId + ".", "accountNo"); + } } else { - // SB, CB, FD, RD are all savings accounts with different deposit_type_enum + // SB, FD, RD are all savings accounts with different deposit_type_enum final SavingsAccount sa = savingsAccountRepository.findSavingsAccountByAccountNumber(accountNo); if (sa == null) { throw new PlatformDataIntegrityException("error.msg.nominee.savings.account.not.found", diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientPendingEditReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientPendingEditReadPlatformService.java new file mode 100644 index 000000000..aa3191581 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientPendingEditReadPlatformService.java @@ -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.service; + +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.portfolio.client.data.ClientPendingEditData; + +public interface ClientPendingEditReadPlatformService { + + ClientPendingEditData retrievePendingEdit(Long clientId); + + /** + * @param status defaults to PENDING when blank + * @param clientName case-insensitive substring match against the client's display name + * @param fromDate ISO (yyyy-MM-dd), inclusive, filters on submitted_date + * @param toDate ISO (yyyy-MM-dd), inclusive, filters on submitted_date + */ + Page retrieveAllPendingEdits(String status, String clientName, String fromDate, String toDate, Integer offset, + Integer limit); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientPendingEditReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientPendingEditReadPlatformServiceImpl.java new file mode 100644 index 000000000..be2e05f83 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientPendingEditReadPlatformServiceImpl.java @@ -0,0 +1,122 @@ +/** + * 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.sql.ResultSet; +import java.sql.SQLException; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.domain.JdbcSupport; +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.portfolio.client.data.ClientPendingEditData; +import org.apache.fineract.portfolio.client.exception.ClientNoPendingEditException; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEditReadPlatformService { + + private final JdbcTemplate jdbcTemplate; + + private static final String SELECT_SQL = """ + SELECT pe.id AS id, pe.client_id AS clientId, c.display_name AS clientDisplayName, + pe.payload_json AS payloadJson, pe.original_json AS originalJson, pe.status AS status, + pe.maker_id AS makerId, (maker.firstname || ' ' || maker.lastname) AS makerName, + pe.submitted_date AS submittedDate, + pe.checker_id AS checkerId, (checker.firstname || ' ' || checker.lastname) AS checkerName, + pe.checked_date AS checkedDate, pe.reject_reason AS rejectReason + FROM m_client_pending_edit pe + JOIN m_client c ON c.id = pe.client_id + JOIN m_appuser maker ON maker.id = pe.maker_id + LEFT JOIN m_appuser checker ON checker.id = pe.checker_id + """; + + private static final RowMapper MAPPER = (ResultSet rs, int rowNum) -> { + final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class); + final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class); + return ClientPendingEditData.builder() // + .id(rs.getLong("id")) // + .clientId(rs.getLong("clientId")) // + .clientDisplayName(rs.getString("clientDisplayName")) // + .payloadJson(rs.getString("payloadJson")) // + .originalJson(rs.getString("originalJson")) // + .status(rs.getString("status")) // + .makerId(rs.getLong("makerId")) // + .makerName(rs.getString("makerName")) // + .submittedDate(submittedDate) // + .checkerId(JdbcSupport.getLong(rs, "checkerId")) // + .checkerName(rs.getString("checkerName")) // + .checkedDate(checkedDate) // + .rejectReason(rs.getString("rejectReason")) // + .build(); + }; + + @Override + public ClientPendingEditData retrievePendingEdit(final Long clientId) { + try { + return this.jdbcTemplate.queryForObject(SELECT_SQL + " WHERE pe.client_id = ? AND pe.status = 'PENDING'", MAPPER, clientId); + } catch (final EmptyResultDataAccessException e) { + throw new ClientNoPendingEditException(clientId); + } + } + + @Override + public Page retrieveAllPendingEdits(final String status, final String clientName, final String fromDate, + final String toDate, final Integer offset, final Integer limit) { + final StringBuilder where = new StringBuilder(" WHERE 1=1"); + final List args = new ArrayList<>(); + if (StringUtils.isNotBlank(status)) { + where.append(" AND pe.status = ?"); + args.add(status.trim().toUpperCase()); + } else { + where.append(" AND pe.status = 'PENDING'"); + } + if (StringUtils.isNotBlank(clientName)) { + where.append(" AND c.display_name ILIKE ?"); + args.add("%" + clientName.trim() + "%"); + } + if (StringUtils.isNotBlank(fromDate)) { + where.append(" AND pe.submitted_date::date >= ?::date"); + args.add(fromDate); + } + if (StringUtils.isNotBlank(toDate)) { + where.append(" AND pe.submitted_date::date <= ?::date"); + args.add(toDate); + } + + final String countSql = "SELECT COUNT(*) FROM m_client_pending_edit pe JOIN m_client c ON c.id = pe.client_id" + where; + final Long totalFilteredRecords = this.jdbcTemplate.queryForObject(countSql, Long.class, args.toArray()); + + final int pageOffset = offset == null ? 0 : offset; + final int pageLimit = limit == null ? 10 : limit; + final String pageSql = SELECT_SQL + where + " ORDER BY pe.submitted_date DESC LIMIT ? OFFSET ?"; + final List pageArgs = new ArrayList<>(args); + pageArgs.add(pageLimit); + pageArgs.add(pageOffset); + final List items = this.jdbcTemplate.query(pageSql, MAPPER, pageArgs.toArray()); + + return new Page<>(items, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientReadPlatformServiceImpl.java index d1bd60de7..4a2480852 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientReadPlatformServiceImpl.java @@ -63,6 +63,7 @@ import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Service; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Service @RequiredArgsConstructor @@ -177,6 +178,16 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService extraCriteria += " and c.status_enum = " + clientStatus.getValue().toString() + " "; } + if (searchParameters.hasFromDate()) { + paramList.add(searchParameters.getFromDate().toString()); + extraCriteria += " and c.submittedon_date >= ?::date "; + } + + if (searchParameters.hasToDate()) { + paramList.add(searchParameters.getToDate().toString()); + extraCriteria += " and c.submittedon_date <= ?::date "; + } + if (firstname != null) { paramList.add(firstname); extraCriteria += " and c.firstname like ? "; @@ -321,7 +332,13 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService sqlBuilder.append("c.occupation_cv_id as occupationId, "); sqlBuilder.append("cvOccupation.code_value as occupationValue, "); sqlBuilder.append("c.monthly_income as monthlyIncome, "); + sqlBuilder.append("c.annual_income as annualIncome, "); sqlBuilder.append("c.annual_turnover as annualTurnover, "); + sqlBuilder.append("c.monthly_turnover as monthlyTurnover, "); + sqlBuilder.append("c.is_senior_citizen as isSeniorCitizen, "); + sqlBuilder.append("c.is_pwd as isPwd, "); + sqlBuilder.append("c.pwd_category_cv_id as pwdCategoryId, "); + sqlBuilder.append("cvPwdCategory.code_value as pwdCategoryValue, "); sqlBuilder.append("c.nationality as nationality, "); sqlBuilder.append("c.is_minor as isMinor, "); sqlBuilder.append("c.guardian_client_id as guardianClientId, "); @@ -383,6 +400,7 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService sqlBuilder.append("left join m_code_value cvclassification on cvclassification.id = c.client_classification_cv_id "); sqlBuilder.append("left join m_code_value cvSubStatus on cvSubStatus.id = c.sub_status "); sqlBuilder.append("left join m_code_value cvOccupationCategory on cvOccupationCategory.id = c.occupation_category_cv_id "); + sqlBuilder.append("left join m_code_value cvPwdCategory on cvPwdCategory.id = c.pwd_category_cv_id "); sqlBuilder.append("left join m_code_value cvOccupation on cvOccupation.id = c.occupation_cv_id "); sqlBuilder.append("left join m_client gc on gc.id = c.guardian_client_id "); sqlBuilder.append("left join m_code_value cvConstitution on cvConstitution.id = cnp.constitution_cv_id "); @@ -482,13 +500,20 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService final CodeValueData occupation = CodeValueData.instance(occupationId, occupationValue); final BigDecimal monthlyIncome1 = rs.getBigDecimal("monthlyIncome"); + final BigDecimal annualIncome1 = rs.getBigDecimal("annualIncome"); final BigDecimal annualTurnover1 = rs.getBigDecimal("annualTurnover"); + final BigDecimal monthlyTurnover1 = rs.getBigDecimal("monthlyTurnover"); + final Boolean isSeniorCitizen1 = rs.getBoolean("isSeniorCitizen"); + final Boolean isPwd1 = rs.getBoolean("isPwd"); + final Long pwdCategoryId1 = JdbcSupport.getLong(rs, "pwdCategoryId"); + final String pwdCategoryValue1 = rs.getString("pwdCategoryValue"); + final CodeValueData pwdCategory1 = CodeValueData.instance(pwdCategoryId1, pwdCategoryValue1); Integer ageYears1 = null; Integer ageMonths1 = null; Integer ageDays1 = null; if (dateOfBirth != null) { - final Period age = Period.between(dateOfBirth, LocalDate.now()); + final Period age = Period.between(dateOfBirth, LocalDate.now(DateUtils.getDateTimeZoneOfTenant())); ageYears1 = age.getYears(); ageMonths1 = age.getMonths(); ageDays1 = age.getDays(); @@ -520,7 +545,12 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService savingsProductName, savingsAccountId, clienttype, classification, occupationCategory, occupation, legalForm, clientNonPerson, isStaff); clientData.setMonthlyIncome(monthlyIncome1); + clientData.setAnnualIncome(annualIncome1); clientData.setAnnualTurnover(annualTurnover1); + clientData.setMonthlyTurnover(monthlyTurnover1); + clientData.setIsSeniorCitizen(isSeniorCitizen1); + clientData.setIsPwd(isPwd1); + clientData.setPwdCategory(pwdCategory1); clientData.setAgeYears(ageYears1); clientData.setAgeMonths(ageMonths1); clientData.setAgeDays(ageDays1); @@ -714,7 +744,13 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService builder.append("c.occupation_cv_id as occupationId, "); builder.append("cvOccupation.code_value as occupationValue, "); builder.append("c.monthly_income as monthlyIncome, "); + builder.append("c.annual_income as annualIncome, "); builder.append("c.annual_turnover as annualTurnover, "); + builder.append("c.monthly_turnover as monthlyTurnover, "); + builder.append("c.is_senior_citizen as isSeniorCitizen, "); + builder.append("c.is_pwd as isPwd, "); + builder.append("c.pwd_category_cv_id as pwdCategoryId, "); + builder.append("cvPwdCategory.code_value as pwdCategoryValue, "); builder.append("c.nationality as nationality, "); builder.append("c.is_minor as isMinor, "); builder.append("c.guardian_client_id as guardianClientId, "); @@ -775,6 +811,7 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService builder.append("left join m_code_value cvclassification on cvclassification.id = c.client_classification_cv_id "); builder.append("left join m_code_value cvSubStatus on cvSubStatus.id = c.sub_status "); builder.append("left join m_code_value cvOccupationCategory on cvOccupationCategory.id = c.occupation_category_cv_id "); + builder.append("left join m_code_value cvPwdCategory on cvPwdCategory.id = c.pwd_category_cv_id "); builder.append("left join m_code_value cvOccupation on cvOccupation.id = c.occupation_cv_id "); builder.append("left join m_client gc on gc.id = c.guardian_client_id "); builder.append("left join m_code_value cvConstitution on cvConstitution.id = cnp.constitution_cv_id "); @@ -873,13 +910,20 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService final CodeValueData occupation = CodeValueData.instance(occupationId, occupationValue); final BigDecimal monthlyIncome = rs.getBigDecimal("monthlyIncome"); + final BigDecimal annualIncome = rs.getBigDecimal("annualIncome"); final BigDecimal annualTurnover = rs.getBigDecimal("annualTurnover"); + final BigDecimal monthlyTurnover = rs.getBigDecimal("monthlyTurnover"); + final Boolean isSeniorCitizen = rs.getBoolean("isSeniorCitizen"); + final Boolean isPwd = rs.getBoolean("isPwd"); + final Long pwdCategoryId = JdbcSupport.getLong(rs, "pwdCategoryId"); + final String pwdCategoryValue = rs.getString("pwdCategoryValue"); + final CodeValueData pwdCategory = CodeValueData.instance(pwdCategoryId, pwdCategoryValue); Integer ageYears = null; Integer ageMonths = null; Integer ageDays = null; if (dateOfBirth != null) { - final Period age = Period.between(dateOfBirth, LocalDate.now()); + final Period age = Period.between(dateOfBirth, LocalDate.now(DateUtils.getDateTimeZoneOfTenant())); ageYears = age.getYears(); ageMonths = age.getMonths(); ageDays = age.getDays(); @@ -911,7 +955,12 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService savingsProductName, savingsAccountId, clienttype, classification, occupationCategory, occupation, legalForm, clientNonPerson, isStaff); clientData.setMonthlyIncome(monthlyIncome); + clientData.setAnnualIncome(annualIncome); clientData.setAnnualTurnover(annualTurnover); + clientData.setMonthlyTurnover(monthlyTurnover); + clientData.setIsSeniorCitizen(isSeniorCitizen); + clientData.setIsPwd(isPwd); + clientData.setPwdCategory(pwdCategory); clientData.setAgeYears(ageYears); clientData.setAgeMonths(ageMonths); clientData.setAgeDays(ageDays); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientTemplateReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientTemplateReadPlatformServiceImpl.java index 964d46cc4..ffeb3dc66 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientTemplateReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientTemplateReadPlatformServiceImpl.java @@ -126,6 +126,9 @@ public class ClientTemplateReadPlatformServiceImpl implements ClientTemplateRead final List socialCategoryOptions = new ArrayList<>( this.codeValueReadPlatformService.retrieveCodeValuesByCode(ClientApiConstants.SOCIAL_CATEGORY)); + final List pwdCategoryOptions = new ArrayList<>( + this.codeValueReadPlatformService.retrieveCodeValuesByCode(ClientApiConstants.PWD_CATEGORY)); + final List clientNonPersonConstitutionOptions = new ArrayList<>( this.codeValueReadPlatformService.retrieveCodeValuesByCode(ClientApiConstants.CLIENT_NON_PERSON_CONSTITUTION)); @@ -146,6 +149,7 @@ public class ClientTemplateReadPlatformServiceImpl implements ClientTemplateRead templateData.setDegreeQualificationOptions(degreeQualificationOptions); templateData.setReligionOptions(religionOptions); templateData.setSocialCategoryOptions(socialCategoryOptions); + templateData.setPwdCategoryOptions(pwdCategoryOptions); return templateData; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java index 27f81563e..86eb53575 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java @@ -63,6 +63,7 @@ import org.apache.fineract.organisation.staff.domain.StaffRepositoryWrapper; import org.apache.fineract.portfolio.account.service.AccountNumberGenerator; import org.apache.fineract.portfolio.address.service.AddressWritePlatformService; import org.apache.fineract.portfolio.client.api.ClientApiConstants; +import org.apache.fineract.portfolio.client.contactverification.service.ClientContactOtpService; import org.apache.fineract.portfolio.client.data.ClientDataValidator; import org.apache.fineract.portfolio.client.domain.Client; import org.apache.fineract.portfolio.client.domain.ClientEnumerations; @@ -73,6 +74,7 @@ import org.apache.fineract.portfolio.client.domain.ClientRepository; import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; import org.apache.fineract.portfolio.client.domain.ClientStatus; import org.apache.fineract.portfolio.client.domain.LegalForm; +import org.apache.fineract.portfolio.client.exception.ClientActivationSelfApprovalNotAllowedException; import org.apache.fineract.portfolio.client.exception.ClientActiveForUpdateException; import org.apache.fineract.portfolio.client.exception.ClientHasNoStaffException; import org.apache.fineract.portfolio.client.exception.ClientMustBePendingToBeDeletedException; @@ -132,6 +134,8 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP private final org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLogRepository mobileLookupLogRepository; private final org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository sanctionsScreeningLogRepository; private final CifNumberGenerator cifNumberGenerator; + private final ClientContactOtpService clientContactOtpService; + private final ClientChangeNotificationService clientChangeNotificationService; @Transactional @Override @@ -392,6 +396,13 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP .findOneByCodeNameAndIdWithNotFoundDetection(ClientApiConstants.SOCIAL_CATEGORY, socialCategoryId); } + CodeValue pwdCategory = null; + final Long pwdCategoryId = command.longValueOfParameterNamed(ClientApiConstants.pwdCategoryIdParamName); + if (pwdCategoryId != null) { + pwdCategory = this.codeValueRepository + .findOneByCodeNameAndIdWithNotFoundDetection(ClientApiConstants.PWD_CATEGORY, pwdCategoryId); + } + final Long savingsProductId = command.longValueOfParameterNamed(ClientApiConstants.savingsProductIdParamName); if (savingsProductId != null) { this.savingsProductRepository.findById(savingsProductId) @@ -416,6 +427,22 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP final String mobileNo = command.stringValueOfParameterNamed(ClientApiConstants.mobileNoParamName); final String alternateMobileNo = command.stringValueOfParameterNamed(ClientApiConstants.alternateMobileNoParamName); final String emailAddress = command.stringValueOfParameterNamed(ClientApiConstants.emailAddressParamName); + + // A wrong/unreachable mobileNo or emailAddress entered at creation can never be fixed + // later — editing it requires a verified OTP against the OLD (wrong) value, which is + // impossible if that value was never real. Verifying here (single "new" leg — there's no + // "old" value for a brand-new client) guarantees every value ever stored was reachable at + // the moment it was recorded. See ClientDataValidator.validateForCreate() for the mandatory + // otpId presence check. + if (StringUtils.isNotBlank(mobileNo)) { + final Long mobileNewOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileNewOtpIdParamName); + this.clientContactOtpService.assertVerifiedMobile(mobileNewOtpId, mobileNo, ClientApiConstants.mobileNewOtpIdParamName); + } + if (StringUtils.isNotBlank(emailAddress)) { + final Long emailNewOtpId = command.longValueOfParameterNamed(ClientApiConstants.emailNewOtpIdParamName); + this.clientContactOtpService.assertVerifiedEmail(emailNewOtpId, emailAddress, ClientApiConstants.emailNewOtpIdParamName); + } + // Title-case names at the boundary so every persistence path (REST, internal, // bulk import) stores "Aditya Kumar Vyas" regardless of how the caller cased the // input. This is the single write seam for new clients — normalising here means we @@ -462,11 +489,31 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP newClient.setMonthlyIncome(monthlyIncome); } + final java.math.BigDecimal annualIncome = command.bigDecimalValueOfParameterNamed(ClientApiConstants.annualIncomeParamName); + if (annualIncome != null) { + newClient.setAnnualIncome(annualIncome); + } + final java.math.BigDecimal annualTurnover = command.bigDecimalValueOfParameterNamed(ClientApiConstants.annualTurnoverParamName); if (annualTurnover != null) { newClient.setAnnualTurnover(annualTurnover); } + final java.math.BigDecimal monthlyTurnover = command.bigDecimalValueOfParameterNamed(ClientApiConstants.monthlyTurnoverParamName); + if (monthlyTurnover != null) { + newClient.setMonthlyTurnover(monthlyTurnover); + } + + final Boolean isSeniorCitizen = command.booleanObjectValueOfParameterNamed(ClientApiConstants.isSeniorCitizenParamName); + newClient.setIsSeniorCitizen(Boolean.TRUE.equals(isSeniorCitizen)); + + final Boolean isPwd = command.booleanObjectValueOfParameterNamed(ClientApiConstants.isPwdParamName); + newClient.setIsPwd(Boolean.TRUE.equals(isPwd)); + + if (pwdCategory != null) { + newClient.updatePwdCategory(pwdCategory); + } + final java.math.BigDecimal agricultureIncome = command.bigDecimalValueOfParameterNamed(ClientApiConstants.agricultureIncomeParamName); if (agricultureIncome != null) { newClient.setAgricultureIncome(agricultureIncome); @@ -724,6 +771,42 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP return element.toString().trim().length() < 4; } + /** + * Gate a mobileNo/emailAddress change on dual OTP verification: both the OLD destination (the + * client's current DB value, re-derived here — never trusted from client-supplied JSON) and the + * NEW destination (the incoming command value) must each be backed by an already-VERIFIED + * {@code m_otp_log} row of purpose {@code CLIENT_CONTACT_CHANGE}. {@code ClientDataValidator} + * guarantees the relevant field-specific old/new otpId params are structurally present whenever + * mobileNo/emailAddress appears in the request; this is the semantic check that they actually + * prove ownership of the right destinations. Param pairs are field-specific + * ({@code mobileOldOtpId}/{@code mobileNewOtpId}, {@code emailOldOtpId}/{@code emailNewOtpId}) + * rather than one generic pair, so mobileNo and emailAddress can both change in the same request + * without ambiguity. + * + *

+ * The OLD leg is skipped when the client doesn't currently have a value for this field (e.g. a + * client onboarded without a mobile number is having one set for the first time) — there is no + * existing verified channel to prove possession of in that case. + */ + private void verifyDualOtpForContactChange(final JsonCommand command, final String changedParamName, final String oldValue, + final String newValue) { + if (ClientApiConstants.mobileNoParamName.equals(changedParamName)) { + final Long mobileOldOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileOldOtpIdParamName); + final Long mobileNewOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileNewOtpIdParamName); + if (StringUtils.isNotBlank(oldValue)) { + this.clientContactOtpService.assertVerifiedMobile(mobileOldOtpId, oldValue, ClientApiConstants.mobileOldOtpIdParamName); + } + this.clientContactOtpService.assertVerifiedMobile(mobileNewOtpId, newValue, ClientApiConstants.mobileNewOtpIdParamName); + } else if (ClientApiConstants.emailAddressParamName.equals(changedParamName)) { + final Long emailOldOtpId = command.longValueOfParameterNamed(ClientApiConstants.emailOldOtpIdParamName); + final Long emailNewOtpId = command.longValueOfParameterNamed(ClientApiConstants.emailNewOtpIdParamName); + if (StringUtils.isNotBlank(oldValue)) { + this.clientContactOtpService.assertVerifiedEmail(emailOldOtpId, oldValue, ClientApiConstants.emailOldOtpIdParamName); + } + this.clientContactOtpService.assertVerifiedEmail(emailNewOtpId, newValue, ClientApiConstants.emailNewOtpIdParamName); + } + } + @Transactional @Override public CommandProcessingResult updateClient(final Long clientId, final JsonCommand command) { @@ -761,10 +844,30 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP clientForUpdate.setExternalId(externalId); } + // Dual-OTP contact-change gate — see ClientContactOtpService / ClientDataValidator. + // Track what actually changed here so we can fire the customer notification (SMS+email) + // AFTER the row is successfully saved, further down this method. + // Snapshotted BEFORE any field below is mutated — if mobile AND email are both changed in + // this same request, the mobile-change alert must still reach this original address, not + // whatever the client's email gets updated to a few lines down. See + // ClientChangeNotificationService#notifyContactOrAddressChanged Javadoc. + final String emailAddressBeforeUpdate = clientForUpdate.getEmailAddress(); + String oldMobileNoForNotification = null; + String newMobileNoForNotification = null; + boolean mobileNoChanged = false; + String oldEmailAddressForNotification = null; + String newEmailAddressForNotification = null; + boolean emailAddressChanged = false; + if (command.isChangeInStringParameterNamed(ClientApiConstants.mobileNoParamName, clientForUpdate.getMobileNo())) { final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.mobileNoParamName); + final String oldValue = clientForUpdate.getMobileNo(); + verifyDualOtpForContactChange(command, ClientApiConstants.mobileNoParamName, oldValue, newValue); changes.put(ClientApiConstants.mobileNoParamName, newValue); clientForUpdate.setMobileNo(StringUtils.defaultIfEmpty(newValue, null)); + oldMobileNoForNotification = oldValue; + newMobileNoForNotification = newValue; + mobileNoChanged = true; } if (command.isChangeInStringParameterNamed(ClientApiConstants.alternateMobileNoParamName, clientForUpdate.getAlternateMobileNo())) { @@ -775,8 +878,13 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP if (command.isChangeInStringParameterNamed(ClientApiConstants.emailAddressParamName, clientForUpdate.getEmailAddress())) { final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.emailAddressParamName); + final String oldValue = clientForUpdate.getEmailAddress(); + verifyDualOtpForContactChange(command, ClientApiConstants.emailAddressParamName, oldValue, newValue); changes.put(ClientApiConstants.emailAddressParamName, newValue); clientForUpdate.setEmailAddress(StringUtils.defaultIfEmpty(newValue, null)); + oldEmailAddressForNotification = oldValue; + newEmailAddressForNotification = newValue; + emailAddressChanged = true; } // Title-case incoming names so an update with "ADITYA" or "aditya" still ends up @@ -884,12 +992,47 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP clientForUpdate.setMonthlyIncome(newValue); } + if (command.isChangeInBigDecimalParameterNamed(ClientApiConstants.annualIncomeParamName, clientForUpdate.getAnnualIncome())) { + final java.math.BigDecimal newValue = command.bigDecimalValueOfParameterNamed(ClientApiConstants.annualIncomeParamName); + changes.put(ClientApiConstants.annualIncomeParamName, newValue); + clientForUpdate.setAnnualIncome(newValue); + } + if (command.isChangeInBigDecimalParameterNamed(ClientApiConstants.annualTurnoverParamName, clientForUpdate.getAnnualTurnover())) { final java.math.BigDecimal newValue = command.bigDecimalValueOfParameterNamed(ClientApiConstants.annualTurnoverParamName); changes.put(ClientApiConstants.annualTurnoverParamName, newValue); clientForUpdate.setAnnualTurnover(newValue); } + if (command.isChangeInBigDecimalParameterNamed(ClientApiConstants.monthlyTurnoverParamName, clientForUpdate.getMonthlyTurnover())) { + final java.math.BigDecimal newValue = command.bigDecimalValueOfParameterNamed(ClientApiConstants.monthlyTurnoverParamName); + changes.put(ClientApiConstants.monthlyTurnoverParamName, newValue); + clientForUpdate.setMonthlyTurnover(newValue); + } + + if (command.isChangeInBooleanParameterNamed(ClientApiConstants.isSeniorCitizenParamName, clientForUpdate.getIsSeniorCitizen())) { + final Boolean newValue = command.booleanObjectValueOfParameterNamed(ClientApiConstants.isSeniorCitizenParamName); + changes.put(ClientApiConstants.isSeniorCitizenParamName, newValue); + clientForUpdate.setIsSeniorCitizen(Boolean.TRUE.equals(newValue)); + } + + if (command.isChangeInBooleanParameterNamed(ClientApiConstants.isPwdParamName, clientForUpdate.getIsPwd())) { + final Boolean newValue = command.booleanObjectValueOfParameterNamed(ClientApiConstants.isPwdParamName); + changes.put(ClientApiConstants.isPwdParamName, newValue); + clientForUpdate.setIsPwd(Boolean.TRUE.equals(newValue)); + } + + if (command.isChangeInLongParameterNamed(ClientApiConstants.pwdCategoryIdParamName, clientForUpdate.pwdCategoryId())) { + final Long newValue = command.longValueOfParameterNamed(ClientApiConstants.pwdCategoryIdParamName); + changes.put(ClientApiConstants.pwdCategoryIdParamName, newValue); + CodeValue newCodeVal = null; + if (newValue != null) { + newCodeVal = this.codeValueRepository + .findOneByCodeNameAndIdWithNotFoundDetection(ClientApiConstants.PWD_CATEGORY, newValue); + } + clientForUpdate.updatePwdCategory(newCodeVal); + } + if (command.isChangeInBigDecimalParameterNamed(ClientApiConstants.agricultureIncomeParamName, clientForUpdate.getAgricultureIncome())) { final java.math.BigDecimal newValue = command.bigDecimalValueOfParameterNamed(ClientApiConstants.agricultureIncomeParamName); changes.put(ClientApiConstants.agricultureIncomeParamName, newValue); @@ -1184,6 +1327,20 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP this.clientRepository.saveAndFlush(clientForUpdate); } + // Customer notification (SMS + email) — fired only after the row is durably saved, so we + // never notify about a change that ultimately failed to persist. Address changes are + // notified from AddressWritePlatformServiceImpl instead (no OTP gate there). + if (mobileNoChanged) { + this.clientChangeNotificationService.notifyContactOrAddressChanged(clientForUpdate, + ClientChangeNotificationService.FIELD_LABEL_MOBILE_NO, oldMobileNoForNotification, newMobileNoForNotification, + emailAddressBeforeUpdate); + } + if (emailAddressChanged) { + this.clientChangeNotificationService.notifyContactOrAddressChanged(clientForUpdate, + ClientChangeNotificationService.FIELD_LABEL_EMAIL_ADDRESS, oldEmailAddressForNotification, + newEmailAddressForNotification, emailAddressBeforeUpdate); + } + if (changes.containsKey(ClientApiConstants.legalFormIdParamName)) { Integer legalFormValue = clientForUpdate.getLegalForm(); boolean isChangedToEntity = false; @@ -1293,6 +1450,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId, true); validateParentGroupRulesBeforeClientActivation(client); + this.clientFamilyMembersWritePlatformService.validateFamilyRequirementsForActivation(client); final Locale locale = command.extractLocale(); final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale); final LocalDate activationDate = command.localDateValueOfParameterNamed("activationDate"); @@ -1300,6 +1458,12 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP runEntityDatatableCheck(clientId, client.getLegalForm()); final AppUser currentUser = this.context.authenticatedUser(); + // Four-eyes principle: whoever created this client (the maker) cannot also be the one + // who activates it (the checker) — no bypass, including for CHECKER_SUPER_USER, since + // that would recreate the exact "role bypasses the hold" loophole this closes. + if (client.getCreatedBy().isPresent() && client.getCreatedBy().get().equals(currentUser.getId())) { + throw new ClientActivationSelfApprovalNotAllowedException(); + } client.activate(currentUser, fmt, activationDate); CommandProcessingResult result = openSavingsAccount(client, fmt); clientRepository.saveAndFlush(client); @@ -1692,6 +1856,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP * verifying KYC names against ID documents). Null / blank → null so we don't accidentally * persist an empty string in place of an unset field. */ + @SuppressWarnings("StringSplitter") private static String toTitleCase(final String raw) { if (raw == null) { return null; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformService.java new file mode 100644 index 000000000..8fa61d5bb --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformService.java @@ -0,0 +1,26 @@ +/** + * 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.KycRiskRuleData; + +public interface KycRiskRulesReadPlatformService { + List retrieveAllRules(); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformServiceImpl.java new file mode 100644 index 000000000..618762d97 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformServiceImpl.java @@ -0,0 +1,68 @@ +/** + * 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 java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.client.data.KycRiskRuleData; +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 +@Transactional(readOnly = true) +public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlatformService { + + private final ClientRiskRuleRepository repository; + + @Override + public List retrieveAllRules() { + final List rules = this.repository.findAllOrderedByOrderPosition(); + return rules.stream().map(this::mapToData).collect(Collectors.toList()); + } + + private KycRiskRuleData mapToData(final ClientRiskRule rule) { + final Long occCatId = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getId() : null; + final String occCatName = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getLabel() : null; + final Long occId = rule.getOccupation() != null ? rule.getOccupation().getId() : null; + final String occName = rule.getOccupation() != null ? rule.getOccupation().getLabel() : null; + + return new KycRiskRuleData( + rule.getId(), + rule.getRuleName(), + rule.getLegalFormId(), + occCatId, + occCatName, + occId, + occName, + rule.getMinMonthlyIncome(), + rule.getMaxMonthlyIncome(), + rule.getMinAnnualIncome(), + rule.getMaxAnnualIncome(), + rule.getMinAnnualTurnover(), + rule.getMaxAnnualTurnover(), + rule.getRiskCategory(), + rule.getReason(), + rule.getOrderPosition() + ); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformServiceImpl.java index aa91fbebe..1dec7cda0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformServiceImpl.java @@ -582,21 +582,6 @@ public class CtsClearingWritePlatformServiceImpl implements CtsClearingWritePlat } } - /** SHA-256 hex digest of the given content, used as a visible integrity fingerprint in the CTS header. */ - private String sha256Hex(final String content) { - try { - final java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); - final byte[] hash = digest.digest(content.getBytes(java.nio.charset.StandardCharsets.UTF_8)); - final StringBuilder hex = new StringBuilder(); - for (final byte b : hash) { - hex.append(String.format("%02x", b)); - } - return hex.toString(); - } catch (final java.security.NoSuchAlgorithmException e) { - throw new IllegalStateException("SHA-256 is not available", e); - } - } - /** Masks all but the last 4 characters of an account number (matches the CTS file convention of not exposing full numbers). */ private String maskAccountNumber(final String accountNumber) { if (accountNumber == null || accountNumber.isBlank()) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/api/CustomerSearchApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/api/CustomerSearchApiResource.java new file mode 100644 index 000000000..9592300e9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/api/CustomerSearchApiResource.java @@ -0,0 +1,84 @@ +/** + * 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.customersearch.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.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.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.function.Supplier; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.customersearch.data.CustomerSearchResultData; +import org.apache.fineract.portfolio.customersearch.service.CustomerSearchReadPlatformService; +import org.springframework.stereotype.Component; + +@Path("/v1/customer-search") +@Component +@Tag(name = "Customer Search", description = "Multi-field customer lookup by name, account number (savings/current/FD/RD), Aadhaar, PAN, and/or address.") +@RequiredArgsConstructor +public class CustomerSearchApiResource { + + private final CustomerSearchReadPlatformService readPlatformService; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Search for a customer", description = "AND-combines whichever of name/accountNumber/aadhaarNumber/panNumber/address/accountTypes are supplied - at least one is required. accountTypes is a comma-separated list (SAVINGS,CURRENT,FIXED_DEPOSIT,RECURRING_DEPOSIT).") + public Collection search(@QueryParam("name") final String name, + @QueryParam("accountNumber") final String accountNumber, @QueryParam("aadhaarNumber") final String aadhaarNumber, + @QueryParam("panNumber") final String panNumber, @QueryParam("address") final String address, + @QueryParam("accountTypes") final String accountTypes) { + final List accountTypeList = accountTypes == null || accountTypes.isBlank() ? List.of() + : Arrays.asList(accountTypes.split(",")); + return safely(() -> this.readPlatformService.search(name, accountNumber, aadhaarNumber, panNumber, address, accountTypeList)); + } + + /** Surfaces IllegalArgumentException as a clean 400 instead of an opaque 500. */ + private T safely(final Supplier action) { + try { + return action.get(); + } catch (final IllegalArgumentException e) { + final JsonObject body = new JsonObject(); + body.addProperty("message", e.getMessage()); + body.addProperty("defaultUserMessage", e.getMessage()); + body.addProperty("httpStatus", 400); + final JsonArray errors = new JsonArray(); + final JsonObject err = new JsonObject(); + err.addProperty("defaultUserMessage", e.getMessage()); + errors.add(err); + body.add("errors", errors); + throw new WebApplicationException( + Response.status(400).entity(this.gson.toJson(body)).type(MediaType.APPLICATION_JSON).build()); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/data/CustomerSearchResultData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/data/CustomerSearchResultData.java new file mode 100644 index 000000000..e1c318ab5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/data/CustomerSearchResultData.java @@ -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.customersearch.data; + +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * One matching (client, account) combination from a multi-field customer search. A client with + * several accounts appears once per matching account; a client with no accounts at all still + * appears once, with accountNo/accountType left null. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CustomerSearchResultData implements Serializable { + private static final long serialVersionUID = 1L; + + private Long clientId; + private String displayName; + private String mobileNo; + private String officeName; + private String accountNo; + /** SAVINGS, CURRENT, FIXED_DEPOSIT, or RECURRING_DEPOSIT. */ + private String accountType; + private String panNumber; + private String aadhaarNumber; + private String address; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/service/CustomerSearchReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/service/CustomerSearchReadPlatformService.java new file mode 100644 index 000000000..7a4eaab66 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/service/CustomerSearchReadPlatformService.java @@ -0,0 +1,38 @@ +/** + * 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.customersearch.service; + +import java.util.Collection; +import java.util.List; +import org.apache.fineract.portfolio.customersearch.data.CustomerSearchResultData; + +public interface CustomerSearchReadPlatformService { + + /** + * Multi-field AND search - only non-blank parameters are applied. At least one must be + * supplied. name/address are partial, case-insensitive matches; accountNumber/aadhaarNumber/ + * panNumber are exact matches (they're precise identifiers, not free text). accountTypes, if + * non-empty, restricts results to those account types only (SAVINGS/CURRENT/FIXED_DEPOSIT/ + * RECURRING_DEPOSIT) - a client with no account, or only accounts of other types, is excluded + * entirely when this filter is active (unlike the unfiltered case, where a client with no + * account at all still appears once for informational purposes). + */ + Collection search(String name, String accountNumber, String aadhaarNumber, String panNumber, + String address, List accountTypes); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/service/CustomerSearchReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/service/CustomerSearchReadPlatformServiceImpl.java new file mode 100644 index 000000000..5ed94011b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/service/CustomerSearchReadPlatformServiceImpl.java @@ -0,0 +1,159 @@ +/** + * 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.customersearch.service; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.customersearch.data.CustomerSearchResultData; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchReadPlatformService { + + private final JdbcTemplate jdbcTemplate; + + private static final Set VALID_ACCOUNT_TYPES = Set.of("SAVINGS", "CURRENT", "FIXED_DEPOSIT", "RECURRING_DEPOSIT"); + + private static final String BASE_SELECT = """ + SELECT DISTINCT c.id AS client_id, c.display_name, c.mobile_no, o.name AS office_name, + acct.account_no, acct.account_type, + pan.document_key AS pan_number, aad.document_key AS aadhaar_number, + addr.address_line_1, addr.address_line_2, addr.town_village, addr.city, + addr.county_district, addr.postal_code + FROM m_client c + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_client_identifier pan ON pan.client_id = c.id AND pan.document_type_id = ( + SELECT id FROM m_code_value WHERE code_value = 'PAN Card' + AND code_id = (SELECT id FROM m_code WHERE code_name = 'KYCIdProofType')) + LEFT JOIN m_client_identifier aad ON aad.client_id = c.id AND aad.document_type_id = ( + SELECT id FROM m_code_value WHERE code_value = 'Aadhaar Card' + AND code_id = (SELECT id FROM m_code WHERE code_name = 'KYCIdProofType')) + LEFT JOIN m_address addr ON addr.client_id = c.id + LEFT JOIN ( + SELECT client_id, account_no, + CASE deposit_type_enum WHEN 100 THEN 'SAVINGS' WHEN 200 THEN 'FIXED_DEPOSIT' + WHEN 300 THEN 'RECURRING_DEPOSIT' ELSE 'SAVINGS' END AS account_type + FROM m_savings_account + UNION ALL + SELECT client_id, account_no, 'CURRENT' AS account_type FROM m_current_account + ) acct ON acct.client_id = c.id + """; + + @Override + public Collection search(final String name, final String accountNumber, final String aadhaarNumber, + final String panNumber, final String address, final List accountTypes) { + final boolean hasName = name != null && !name.isBlank(); + final boolean hasAccountNumber = accountNumber != null && !accountNumber.isBlank(); + final boolean hasAadhaar = aadhaarNumber != null && !aadhaarNumber.isBlank(); + final boolean hasPan = panNumber != null && !panNumber.isBlank(); + final boolean hasAddress = address != null && !address.isBlank(); + final List effectiveAccountTypes = accountTypes == null ? List.of() + : accountTypes.stream().filter(t -> t != null && !t.isBlank()).map(String::trim).map(String::toUpperCase).toList(); + + if (!hasName && !hasAccountNumber && !hasAadhaar && !hasPan && !hasAddress && effectiveAccountTypes.isEmpty()) { + throw new IllegalArgumentException("Enter at least one search field - name, account number, Aadhaar, PAN, or address."); + } + for (final String type : effectiveAccountTypes) { + if (!VALID_ACCOUNT_TYPES.contains(type)) { + throw new IllegalArgumentException( + "Unknown account type: " + type + ". Valid values: SAVINGS, CURRENT, FIXED_DEPOSIT, RECURRING_DEPOSIT."); + } + } + + final StringBuilder sql = new StringBuilder(BASE_SELECT).append(" WHERE 1=1 "); + final List params = new ArrayList<>(); + + if (hasName) { + sql.append(" AND LOWER(c.display_name) LIKE LOWER(?) "); + params.add("%" + name.trim() + "%"); + } + if (hasAccountNumber) { + sql.append(" AND acct.account_no = ? "); + params.add(accountNumber.trim()); + } + if (hasAadhaar) { + sql.append(" AND aad.document_key = ? "); + params.add(aadhaarNumber.trim()); + } + if (hasPan) { + sql.append(" AND UPPER(pan.document_key) = UPPER(?) "); + params.add(panNumber.trim()); + } + if (hasAddress) { + sql.append(" AND (LOWER(addr.address_line_1) LIKE LOWER(?) OR LOWER(addr.address_line_2) LIKE LOWER(?) " + + "OR LOWER(addr.town_village) LIKE LOWER(?) OR LOWER(addr.city) LIKE LOWER(?) " + + "OR LOWER(addr.county_district) LIKE LOWER(?) OR LOWER(addr.postal_code) LIKE LOWER(?)) "); + final String likeAddress = "%" + address.trim() + "%"; + for (int i = 0; i < 6; i++) { + params.add(likeAddress); + } + } + if (!effectiveAccountTypes.isEmpty()) { + // Restricting to specific account types excludes both non-matching-type accounts AND + // clients with no account at all (acct.account_type would be NULL for those, which can + // never equal one of the listed types) - that's the point of the filter. + final String placeholders = String.join(",", effectiveAccountTypes.stream().map(t -> "?").toList()); + sql.append(" AND acct.account_type IN (").append(placeholders).append(") "); + params.addAll(effectiveAccountTypes); + } + sql.append(" ORDER BY c.display_name ASC LIMIT 100"); + + return this.jdbcTemplate.query(sql.toString(), new CustomerSearchResultMapper(), params.toArray()); + } + + private static final class CustomerSearchResultMapper implements RowMapper { + @Override + public CustomerSearchResultData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long clientId = rs.getLong("client_id"); + final String displayName = rs.getString("display_name"); + final String mobileNo = rs.getString("mobile_no"); + final String officeName = rs.getString("office_name"); + final String accountNo = rs.getString("account_no"); + final String accountType = rs.getString("account_type"); + final String panNumber = rs.getString("pan_number"); + final String aadhaarNumber = rs.getString("aadhaar_number"); + + final List addressParts = new ArrayList<>(); + addPart(addressParts, rs.getString("address_line_1")); + addPart(addressParts, rs.getString("address_line_2")); + addPart(addressParts, rs.getString("town_village")); + addPart(addressParts, rs.getString("city")); + addPart(addressParts, rs.getString("county_district")); + addPart(addressParts, rs.getString("postal_code")); + final String address = addressParts.isEmpty() ? null : String.join(", ", addressParts); + + return new CustomerSearchResultData(clientId, displayName, mobileNo, officeName, accountNo, accountType, panNumber, + aadhaarNumber, address); + } + + private void addPart(final List parts, final String value) { + if (value != null && !value.isBlank()) { + parts.add(value); + } + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/api/DenominationMasterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/api/DenominationMasterApiResource.java index 36166bffa..ce5115817 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/api/DenominationMasterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/api/DenominationMasterApiResource.java @@ -26,6 +26,7 @@ import org.apache.fineract.portfolio.denominationmaster.domain.DenominationMaste import org.apache.fineract.portfolio.denominationmaster.domain.DenominationMasterRepository; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.stereotype.Component; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Path("/v1/denominationmaster") @Component @@ -105,7 +106,7 @@ public class DenominationMasterApiResource { writeAudit("ACTIVATE", dm, null); } else if ("deactivate".equals(command)) { dm.setIsActive(false); - dm.setDiscontinuedOn(LocalDate.now()); + dm.setDiscontinuedOn(LocalDate.now(DateUtils.getDateTimeZoneOfTenant())); repository.save(dm); writeAudit("DEACTIVATE", dm, null); } else if ("restore".equals(command)) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/domain/DenominationMasterAudit.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/domain/DenominationMasterAudit.java index 1e9d05c1a..6c1ea416c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/domain/DenominationMasterAudit.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/domain/DenominationMasterAudit.java @@ -15,6 +15,7 @@ import java.time.LocalDateTime; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Entity @Table(name = "m_denomination_master_audit") @@ -77,7 +78,7 @@ public class DenominationMasterAudit { a.changes = changes; a.performedBy = performedBy; a.performedByUsername = performedByUsername; - a.performedOn = LocalDateTime.now(); + a.performedOn = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); return a; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java index 67002369d..bf72dd855 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java @@ -26,6 +26,7 @@ import org.apache.fineract.portfolio.denominationmaster.domain.SavingsTxnDenomin import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** * Validates and persists the per-transaction cash denomination breakdown sent by the UI @@ -163,7 +164,7 @@ public class DenominationBreakdownWriter { } if (cashierId == null) return; - final String today = java.time.LocalDate.now().toString(); + final String today = java.time.LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); final String sql = "SELECT COUNT(*) FROM m_cash_transfer ct " + "WHERE ct.to_cashier_id = ? " + " AND ct.transfer_date < CAST(? AS DATE) " @@ -203,7 +204,7 @@ public class DenominationBreakdownWriter { } if (cashierId == null) return; - final String today = java.time.LocalDate.now().toString(); + final String today = java.time.LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); final String sql = "SELECT COUNT(*) FROM m_cash_transfer ct " + "WHERE ct.to_cashier_id = ? " + " AND ct.transfer_date = CAST(? AS DATE) " @@ -511,7 +512,7 @@ public class DenominationBreakdownWriter { .count(count) .totalAmount(lineTotal) .txnDate(txnDate) - .createdAt(LocalDateTime.now()) + .createdAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())) .txnType(resolvedTxnType) .officerId(officerId) .officerUsername(officerUsername) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/CommitteeApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/CommitteeApiResource.java index 04b2754b9..c0428ced4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/CommitteeApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/CommitteeApiResource.java @@ -1,6 +1,13 @@ package org.apache.fineract.portfolio.election.api; -import jakarta.ws.rs.*; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/CommitteeMeetingApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/CommitteeMeetingApiResource.java index 0fbf934bb..044b675e6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/CommitteeMeetingApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/CommitteeMeetingApiResource.java @@ -3,7 +3,17 @@ package org.apache.fineract.portfolio.election.api; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; -import jakarta.ws.rs.*; +import jakarta.ws.rs.Consumes; +import com.google.common.collect.Iterables; +import com.google.common.base.Splitter; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PATCH; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; @@ -138,6 +148,6 @@ public class CommitteeMeetingApiResource { private String clientIp(HttpServletRequest req) { String ip = req.getHeader("X-Forwarded-For"); - return (ip != null && !ip.isBlank()) ? ip.split(",")[0].trim() : req.getRemoteAddr(); + return (ip != null && !ip.isBlank()) ? Iterables.get(Splitter.on(',').split(ip), 0).trim() : req.getRemoteAddr(); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/DirectorAppointmentApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/DirectorAppointmentApiResource.java index 9e422d57c..206eb5b95 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/DirectorAppointmentApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/DirectorAppointmentApiResource.java @@ -4,7 +4,18 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; -import jakarta.ws.rs.*; +import jakarta.ws.rs.Consumes; +import com.google.common.collect.Iterables; +import com.google.common.base.Splitter; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; @@ -125,6 +136,6 @@ public class DirectorAppointmentApiResource { private String clientIp(HttpServletRequest req) { String ip = req.getHeader("X-Forwarded-For"); - return (ip != null && !ip.isBlank()) ? ip.split(",")[0].trim() : req.getRemoteAddr(); + return (ip != null && !ip.isBlank()) ? Iterables.get(Splitter.on(',').split(ip), 0).trim() : req.getRemoteAddr(); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionCandidateApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionCandidateApiResource.java index ebf007aaa..3764721b8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionCandidateApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionCandidateApiResource.java @@ -15,6 +15,8 @@ import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import com.google.common.base.Splitter; +import com.google.common.collect.Iterables; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; @@ -123,7 +125,9 @@ public class ElectionCandidateApiResource { if (existing != null && !existing.startsWith("data:") && existing.contains("|")) { String[] ep = existing.split("\\|", 2); if (ep.length == 2) { - try { contentStoreService.delete(ep[1]); } catch (Exception ignored) {} + try { contentStoreService.delete(ep[1]); } catch (Exception ignored) { + // intentionally ignored + } } } @@ -218,7 +222,7 @@ public class ElectionCandidateApiResource { private static String clientIp(HttpServletRequest req) { if (req == null) return null; String fwd = req.getHeader("X-Forwarded-For"); - if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim(); + if (fwd != null && !fwd.isBlank()) return Iterables.get(Splitter.on(',').split(fwd), 0).trim(); return req.getRemoteAddr(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionConfigApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionConfigApiResource.java index 04cc25edd..d6a98c762 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionConfigApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionConfigApiResource.java @@ -5,7 +5,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.PUT; @@ -17,6 +16,8 @@ import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import com.google.common.base.Splitter; +import com.google.common.collect.Iterables; import lombok.RequiredArgsConstructor; import org.apache.fineract.portfolio.election.data.ElectionConfigData; import org.apache.fineract.portfolio.election.data.ElectionConfigRequest; @@ -89,7 +90,9 @@ public class ElectionConfigApiResource { throw e; } String updateDetails = null; - try { updateDetails = objectMapper.writeValueAsString(request); } catch (Exception ignored) {} + try { updateDetails = objectMapper.writeValueAsString(request); } catch (Exception ignored) { + // intentionally ignored + } electionAuditService.log(ElectionAuditAction.UPDATE_ELECTION, "ELECTION", electionId, electionId, "Election updated: " + request.getTitle(), updateDetails, clientIp(httpRequest)); return Response.ok(Map.of("resourceId", electionId)).build(); @@ -214,7 +217,7 @@ public class ElectionConfigApiResource { private static String clientIp(HttpServletRequest req) { if (req == null) return null; String fwd = req.getHeader("X-Forwarded-For"); - if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim(); + if (fwd != null && !fwd.isBlank()) return Iterables.get(Splitter.on(',').split(fwd), 0).trim(); return req.getRemoteAddr(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionNominationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionNominationApiResource.java index f969b601d..871197201 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionNominationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionNominationApiResource.java @@ -15,6 +15,8 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; import org.apache.fineract.portfolio.election.data.ElectionApprovalActionRequest; +import com.google.common.base.Splitter; +import com.google.common.collect.Iterables; import org.apache.fineract.portfolio.election.data.ElectionApprovalStepData; import org.apache.fineract.portfolio.election.data.ElectionNominationData; import org.apache.fineract.portfolio.election.data.ElectionNominationRequest; @@ -295,7 +297,7 @@ public class ElectionNominationApiResource { private static String clientIp(HttpServletRequest req) { if (req == null) return null; String fwd = req.getHeader("X-Forwarded-For"); - if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim(); + if (fwd != null && !fwd.isBlank()) return Iterables.get(Splitter.on(',').split(fwd), 0).trim(); return req.getRemoteAddr(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionVotingApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionVotingApiResource.java index c21a700e0..078f53a6e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionVotingApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionVotingApiResource.java @@ -15,6 +15,8 @@ import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; +import com.google.common.base.Splitter; +import com.google.common.collect.Iterables; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; @@ -104,10 +106,10 @@ public class ElectionVotingApiResource { } winDet.append("]"); String declareDetails = wFirst ? null : winDet.toString(); - String declareDesc = "Results declared" + (declareDetails != null ? " — " + (results.stream() + String declareDesc = "Results declared" + (declareDetails != null ? " — " + results.stream() .filter(r -> Integer.valueOf(1).equals(r.getRankPosition()) || "WON".equalsIgnoreCase(r.getResult())) .map(r -> r.getCandidateName() + " won " + r.getPostName()) - .reduce((a, b) -> a + "; " + b).orElse("")) : ""); + .reduce((a, b) -> a + "; " + b).orElse("") : ""); electionAuditService.log(ElectionAuditAction.DECLARE_RESULTS, "RESULT", electionId, electionId, declareDesc, declareDetails, clientIp(httpRequest)); return Response.ok(results).build(); @@ -127,7 +129,7 @@ public class ElectionVotingApiResource { private static String clientIp(HttpServletRequest req) { if (req == null) return null; String fwd = req.getHeader("X-Forwarded-For"); - if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim(); + if (fwd != null && !fwd.isBlank()) return Iterables.get(Splitter.on(',').split(fwd), 0).trim(); return req.getRemoteAddr(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/MeetingPublicVoteApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/MeetingPublicVoteApiResource.java index 428cff9fd..fd0ff7bd6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/MeetingPublicVoteApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/MeetingPublicVoteApiResource.java @@ -2,7 +2,12 @@ package org.apache.fineract.portfolio.election.api; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.ws.rs.*; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/MeetingVoteAdminApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/MeetingVoteAdminApiResource.java index a353257c8..05903186f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/MeetingVoteAdminApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/MeetingVoteAdminApiResource.java @@ -2,7 +2,13 @@ package org.apache.fineract.portfolio.election.api; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.ws.rs.*; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/Committee.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/Committee.java index 6a394a65d..237ddee14 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/Committee.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/Committee.java @@ -1,6 +1,13 @@ package org.apache.fineract.portfolio.election.domain; -import jakarta.persistence.*; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMeeting.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMeeting.java index 480b92e9d..7d65e8e37 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMeeting.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMeeting.java @@ -1,6 +1,13 @@ package org.apache.fineract.portfolio.election.domain; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; +import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMeetingAgenda.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMeetingAgenda.java index c3f2da500..211cfdfc5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMeetingAgenda.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMeetingAgenda.java @@ -1,6 +1,14 @@ package org.apache.fineract.portfolio.election.domain; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +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 lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMember.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMember.java index b65c19f48..8d13ffc01 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMember.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMember.java @@ -26,7 +26,7 @@ public class CommitteeMember { private Long directorAppointmentId; @Column(name = "added_date", nullable = false) - private LocalDate addedDate = LocalDate.now(); + private LocalDate addedDate; public static CommitteeMember create(Committee committee, Long directorAppointmentId) { CommitteeMember m = new CommitteeMember(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/DirectorAppointment.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/DirectorAppointment.java index 380d80935..aaa66fc33 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/DirectorAppointment.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/DirectorAppointment.java @@ -1,6 +1,12 @@ package org.apache.fineract.portfolio.election.domain; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -8,6 +14,7 @@ import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; import java.time.LocalDate; import java.time.LocalDateTime; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Entity @Table(name = "m_election_director_appointment") @@ -58,12 +65,12 @@ public class DirectorAppointment extends AbstractPersistableCustom { @PrePersist void onPersist() { - this.createdDate = LocalDateTime.now(); + this.createdDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); } @PreUpdate void onUpdate() { - this.lastModifiedDate = LocalDateTime.now(); + this.lastModifiedDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); } public static DirectorAppointment create(Long electionId, Long candidateId, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/DirectorAppointmentRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/DirectorAppointmentRepository.java index d4e8e02c9..e09436211 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/DirectorAppointmentRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/DirectorAppointmentRepository.java @@ -14,6 +14,7 @@ import java.util.List; @Repository public interface DirectorAppointmentRepository extends JpaRepository { + @Override Page findAll(Pageable pageable); Page findByStatus(DirectorAppointmentStatus status, Pageable pageable); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionApprovalStep.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionApprovalStep.java index e727a3a08..70236d8c0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionApprovalStep.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionApprovalStep.java @@ -1,12 +1,20 @@ package org.apache.fineract.portfolio.election.domain; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; import java.time.LocalDateTime; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Entity @Table(name = "m_election_approval_step") @@ -63,7 +71,7 @@ public class ElectionApprovalStep extends AbstractPersistableCustom { this.approverUserId = approverUserId; this.status = ApprovalStepStatus.APPROVED; this.remarks = remarks; - this.actionDate = LocalDateTime.now(); + this.actionDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); this.familyDetailsVerified = familyVerified; this.familyDetailsJson = familyJson; } @@ -72,6 +80,6 @@ public class ElectionApprovalStep extends AbstractPersistableCustom { this.approverUserId = approverUserId; this.status = ApprovalStepStatus.REJECTED; this.remarks = remarks; - this.actionDate = LocalDateTime.now(); + this.actionDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionAuditLog.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionAuditLog.java index 2312901ef..cd701b6be 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionAuditLog.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionAuditLog.java @@ -14,6 +14,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; import java.time.LocalDateTime; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Entity @Table(name = "m_election_audit_log") @@ -63,6 +64,6 @@ public class ElectionAuditLog { @PrePersist void prePersist() { - if (createdAt == null) createdAt = LocalDateTime.now(); + if (createdAt == null) createdAt = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCandidate.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCandidate.java index 2e34798f9..b8938d086 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCandidate.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCandidate.java @@ -1,12 +1,19 @@ package org.apache.fineract.portfolio.election.domain; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; import java.time.LocalDateTime; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Entity @Table(name = "m_election_candidate") @@ -67,7 +74,7 @@ public class ElectionCandidate extends AbstractPersistableCustom { candidate.candidateName = candidateName; candidate.serialNumber = serialNumber; candidate.approvedBy = approvedBy; - candidate.approvedDate = LocalDateTime.now(); + candidate.approvedDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); candidate.isActive = true; return candidate; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCeoPrecheck.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCeoPrecheck.java index 505303480..65f7c6f5f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCeoPrecheck.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCeoPrecheck.java @@ -1,6 +1,13 @@ package org.apache.fineract.portfolio.election.domain; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -8,6 +15,7 @@ import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; import java.math.BigDecimal; import java.time.LocalDateTime; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Entity @Table(name = "m_election_ceo_precheck") @@ -152,13 +160,13 @@ public class ElectionCeoPrecheck extends AbstractPersistableCustom { public void generateNoDueCertificate(String certificateNumber) { this.noDueCertificateNumber = certificateNumber; - this.noDueGeneratedDate = LocalDateTime.now(); + this.noDueGeneratedDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); this.noDueCheckResult = CheckResult.PASS; } public void finalise(Long checkedBy, String remarks) { this.checkedBy = checkedBy; - this.checkedDate = LocalDateTime.now(); + this.checkedDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); this.remarks = remarks; // NDC is generated manually after pre-check; only automated checks determine overall result boolean autoChecksPass = directorLoanCheckResult == CheckResult.PASS diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionConfig.java index 34fde4e50..b092fbe0a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionConfig.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionConfig.java @@ -1,6 +1,13 @@ package org.apache.fineract.portfolio.election.domain; -import jakarta.persistence.*; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -9,6 +16,7 @@ import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDa import java.time.LocalDate; import java.util.ArrayList; import java.util.List; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Entity @Table(name = "m_election_config") @@ -78,7 +86,7 @@ public class ElectionConfig extends AbstractAuditableWithUTCDateTimeCustom } public boolean isNominationOpen() { - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); return status == ElectionStatus.ACTIVE && !today.isBefore(nominationStartDate) && !today.isAfter(nominationEndDate); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionEligibilityCheck.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionEligibilityCheck.java index e03b83dba..27a971187 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionEligibilityCheck.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionEligibilityCheck.java @@ -1,12 +1,20 @@ package org.apache.fineract.portfolio.election.domain; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; import java.time.LocalDateTime; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Entity @Table(name = "m_election_eligibility_check") @@ -71,7 +79,7 @@ public class ElectionEligibilityCheck extends AbstractPersistableCustom { public void complete(Long checkedBy, String remarks, boolean passed) { this.checkedBy = checkedBy; - this.checkedDate = LocalDateTime.now(); + this.checkedDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); this.remarks = remarks; this.overallResult = passed ? CheckResult.PASS : CheckResult.FAIL; this.ageEligible = passed; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionNomination.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionNomination.java index 7c118aa1a..5ead0d6fb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionNomination.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionNomination.java @@ -1,6 +1,16 @@ package org.apache.fineract.portfolio.election.domain; -import jakarta.persistence.*; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -10,6 +20,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Entity @Table(name = "m_election_nomination", @@ -121,7 +132,7 @@ public class ElectionNomination extends AbstractAuditableWithUTCDateTimeCustom { r.result = result; r.rankPosition = rankPosition; r.declaredBy = declaredBy; - r.declaredDate = LocalDateTime.now(); + r.declaredDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); return r; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionVote.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionVote.java index 329e56082..0f42b7edb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionVote.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionVote.java @@ -1,12 +1,19 @@ package org.apache.fineract.portfolio.election.domain; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; import java.time.LocalDateTime; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Entity @Table(name = "m_election_vote", @@ -50,7 +57,7 @@ public class ElectionVote extends AbstractPersistableCustom { vote.electionPost = post; vote.voterMemberId = voterMemberId; vote.candidate = candidate; - vote.voteTimestamp = LocalDateTime.now(); + vote.voteTimestamp = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); vote.boothId = boothId; vote.ipAddress = ipAddress; return vote; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingAgendaResolution.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingAgendaResolution.java index fcb700643..32ff0973c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingAgendaResolution.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingAgendaResolution.java @@ -1,11 +1,19 @@ package org.apache.fineract.portfolio.election.domain; -import jakarta.persistence.*; +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.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import java.time.LocalDateTime; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Entity @Table(name = "m_election_meeting_agenda_resolution") @@ -41,7 +49,7 @@ public class MeetingAgendaResolution { r.agendaId = agendaId; r.resolution = resolution; r.discussionNotes = notes; - r.resolvedAt = LocalDateTime.now(); + r.resolvedAt = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); return r; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingMemberVote.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingMemberVote.java index b8a6a4fba..3091d23bb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingMemberVote.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingMemberVote.java @@ -1,11 +1,20 @@ package org.apache.fineract.portfolio.election.domain; -import jakarta.persistence.*; +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.Table; +import jakarta.persistence.UniqueConstraint; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import java.time.LocalDateTime; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Entity @Table(name = "m_election_meeting_member_vote", @@ -46,7 +55,7 @@ public class MeetingMemberVote { v.agendaId = agendaId; v.memberId = memberId; v.vote = vote; - v.votedAt = LocalDateTime.now(); + v.votedAt = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); v.remark = (remark != null && !remark.isBlank()) ? remark.trim() : null; return v; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingVoteToken.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingVoteToken.java index b8c66eaba..e53291a93 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingVoteToken.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingVoteToken.java @@ -1,12 +1,16 @@ package org.apache.fineract.portfolio.election.domain; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import java.time.LocalDateTime; import java.util.UUID; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Entity @Table(name = "m_election_meeting_vote_token") @@ -49,12 +53,12 @@ public class MeetingVoteToken { t.memberName = memberName; t.designation = designation; t.used = false; - t.createdAt = LocalDateTime.now(); + t.createdAt = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); t.expiresAt = t.createdAt.plusDays(validDays); return t; } public boolean isExpired() { - return LocalDateTime.now().isAfter(expiresAt); + return LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()).isAfter(expiresAt); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/CommitteeMeetingServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/CommitteeMeetingServiceImpl.java index f63702e0e..d0e229aaf 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/CommitteeMeetingServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/CommitteeMeetingServiceImpl.java @@ -32,6 +32,7 @@ import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Slf4j @Service @@ -215,7 +216,7 @@ public class CommitteeMeetingServiceImpl implements CommitteeMeetingService { private String buildMeetingInviteHtml(String recipientName, String committeeName, CommitteeMeeting meeting, boolean isUpdate) { DateTimeFormatter dateFmt = DateTimeFormatter.ofPattern("dd MMMM yyyy"); - String sentAt = java.time.LocalDateTime.now().format(DateTimeFormatter.ofPattern("dd MMM yyyy, hh:mm a")); + String sentAt = java.time.LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMM yyyy, hh:mm a")); String dateStr = meeting.getMeetingDate() != null ? meeting.getMeetingDate().format(dateFmt) : "—"; String timeStr = meeting.getMeetingTime() != null ? meeting.getMeetingTime() : "—"; boolean isOnline = meeting.getMode() == CommitteeMeetingMode.ONLINE; @@ -381,7 +382,7 @@ public class CommitteeMeetingServiceImpl implements CommitteeMeetingService { if (date == null) { throw new IllegalArgumentException("Meeting date is required"); } - if (date.isBefore(LocalDate.now())) { + if (date.isBefore(LocalDate.now(DateUtils.getDateTimeZoneOfTenant()))) { throw new IllegalArgumentException("Meeting date cannot be in the past"); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/CommitteeServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/CommitteeServiceImpl.java index 1edaa3044..c90429fe0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/CommitteeServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/CommitteeServiceImpl.java @@ -26,6 +26,7 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Service @RequiredArgsConstructor @@ -132,7 +133,7 @@ public class CommitteeServiceImpl implements CommitteeService { @Override public List getCurrentElectionDirectors() { - var currentElection = electionConfigRepository.findFirstWithActiveDuration(LocalDate.now()); + var currentElection = electionConfigRepository.findFirstWithActiveDuration(LocalDate.now(DateUtils.getDateTimeZoneOfTenant())); // fallback: most recent RESULT_DECLARED election if none has active duration if (currentElection.isEmpty()) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DirectorAppointmentServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DirectorAppointmentServiceImpl.java index a6950b779..21f08eb22 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DirectorAppointmentServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DirectorAppointmentServiceImpl.java @@ -28,6 +28,7 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Service @RequiredArgsConstructor @@ -109,12 +110,12 @@ public class DirectorAppointmentServiceImpl implements DirectorAppointmentServic @Transactional @Scheduled(cron = "0 0 0 * * *") public void autoExpireByTenure() { - int updated = repository.expireByTenureDate(LocalDate.now()); + int updated = repository.expireByTenureDate(LocalDate.now(DateUtils.getDateTimeZoneOfTenant())); if (updated > 0) { log.info("Auto-expired {} director appointment(s) whose tenure ended.", updated); auditService.log(ElectionAuditAction.UPDATE_DIRECTOR_APPOINTMENT, "DIRECTOR_APPOINTMENT", null, null, "Scheduler auto-expired " + updated + " director appointment(s)", - "expiredCount=" + updated + ", date=" + LocalDate.now(), null); + "expiredCount=" + updated + ", date=" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()), null); } } @@ -126,7 +127,9 @@ public class DirectorAppointmentServiceImpl implements DirectorAppointmentServic DirectorAppointmentStatus statusEnum = null; if (status != null && !status.isBlank()) { - try { statusEnum = DirectorAppointmentStatus.valueOf(status.toUpperCase()); } catch (Exception ignored) {} + try { statusEnum = DirectorAppointmentStatus.valueOf(status.toUpperCase()); } catch (Exception ignored) { + // intentionally ignored + } } if (electionId != null && statusEnum != null) { @@ -177,7 +180,9 @@ public class DirectorAppointmentServiceImpl implements DirectorAppointmentServic if (req.getAppointmentDate() != null) d.setAppointmentDate(req.getAppointmentDate()); if (req.getRemarks() != null) d.setRemarks(req.getRemarks()); if (req.getStatus() != null) { - try { d.setStatus(DirectorAppointmentStatus.valueOf(req.getStatus().toUpperCase())); } catch (Exception ignored) {} + try { d.setStatus(DirectorAppointmentStatus.valueOf(req.getStatus().toUpperCase())); } catch (Exception ignored) { + // intentionally ignored + } } if (req.getCandidateName() != null) d.setCandidateName(req.getCandidateName()); if (req.getPostName() != null) d.setPostName(req.getPostName()); @@ -234,10 +239,6 @@ public class DirectorAppointmentServiceImpl implements DirectorAppointmentServic return toData(d, null, null); } - private DirectorAppointmentData toData(DirectorAppointment d, String electionTitle) { - return toData(d, electionTitle, null); - } - private DirectorAppointmentData toData(DirectorAppointment d, String electionTitle, Long memberId) { return DirectorAppointmentData.builder() .id(d.getId()) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionAnalyticsServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionAnalyticsServiceImpl.java index 9b8486b55..135150415 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionAnalyticsServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionAnalyticsServiceImpl.java @@ -15,6 +15,7 @@ import java.time.Period; import java.util.ArrayList; import java.util.List; import java.util.Map; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Slf4j @Service @@ -318,7 +319,7 @@ public class ElectionAnalyticsServiceImpl implements ElectionAnalyticsService { Object dob = info.get("date_of_birth"); if (dob instanceof java.sql.Date) { LocalDate dobDate = ((java.sql.Date) dob).toLocalDate(); - candidateAge = Period.between(dobDate, LocalDate.now()).getYears(); + candidateAge = Period.between(dobDate, LocalDate.now(DateUtils.getDateTimeZoneOfTenant())).getYears(); } // ── Voter pool ───────────────────────────────────────────────────────── diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionCeoPreCheckServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionCeoPreCheckServiceImpl.java index 86fdac62f..7dc3d6122 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionCeoPreCheckServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionCeoPreCheckServiceImpl.java @@ -16,6 +16,7 @@ import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.Map; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Slf4j @Service @@ -104,7 +105,7 @@ public class ElectionCeoPreCheckServiceImpl implements ElectionCeoPreCheckServic if (status == null || status != 300) { String label = statusLabel(status); precheck.completeKycCheck(CheckResult.FAIL, "KYC not verified (status: " + label + ")"); - } else if (expiry != null && expiry.isBefore(LocalDate.now())) { + } else if (expiry != null && expiry.isBefore(LocalDate.now(DateUtils.getDateTimeZoneOfTenant()))) { precheck.completeKycCheck(CheckResult.FAIL, "KYC expired on " + expiry); } else { String expiryNote = expiry != null ? ", expires " + expiry : ""; @@ -155,7 +156,7 @@ public class ElectionCeoPreCheckServiceImpl implements ElectionCeoPreCheckServic issues.add(name + ": No KYC record"); } else if (status != 300) { issues.add(name + ": KYC not verified (" + statusLabel(status) + ")"); - } else if (expiry != null && expiry.isBefore(LocalDate.now())) { + } else if (expiry != null && expiry.isBefore(LocalDate.now(DateUtils.getDateTimeZoneOfTenant()))) { issues.add(name + ": KYC expired on " + expiry); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionNoDueCertificateServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionNoDueCertificateServiceImpl.java index e1fa01603..f927a0462 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionNoDueCertificateServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionNoDueCertificateServiceImpl.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Service; import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Slf4j @Service @@ -18,7 +19,7 @@ public class ElectionNoDueCertificateServiceImpl implements ElectionNoDueCertifi @Override public String generateCertificateNumber(ElectionNomination nomination) { - String datePart = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String datePart = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("yyyyMMdd")); return "NDC-" + datePart + "-" + String.format("%06d", nomination.getId()); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformServiceImpl.java index ac72a3dd5..9fdf3c513 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformServiceImpl.java @@ -36,6 +36,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Service @RequiredArgsConstructor @@ -315,7 +316,7 @@ public class ElectionReadPlatformServiceImpl implements ElectionReadPlatformServ private ElectionNominationData toNominationData(ElectionNomination n) { Integer age = null; if (n.getDateOfBirth() != null) { - age = Period.between(n.getDateOfBirth(), LocalDate.now()).getYears(); + age = Period.between(n.getDateOfBirth(), LocalDate.now(DateUtils.getDateTimeZoneOfTenant())).getYears(); } return ElectionNominationData.builder() .id(n.getId()) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformServiceImpl.java index bef267c0e..51e727516 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformServiceImpl.java @@ -25,7 +25,6 @@ import org.apache.fineract.portfolio.election.domain.ElectionResult; import org.apache.fineract.portfolio.election.domain.ElectionResultRepository; import org.apache.fineract.portfolio.election.domain.ElectionStatus; import org.apache.fineract.portfolio.election.domain.ElectionType; -import org.apache.fineract.portfolio.election.domain.ElectionVote; import org.apache.fineract.portfolio.election.domain.ElectionVoteRepository; import org.apache.fineract.portfolio.election.domain.NominationStatus; import org.apache.fineract.portfolio.election.exception.ElectionAlreadyOngoingException; @@ -36,7 +35,6 @@ import org.apache.fineract.portfolio.election.exception.ElectionNominationNotFou import org.apache.fineract.portfolio.election.exception.ElectionNotActiveException; import org.apache.fineract.portfolio.election.exception.InvalidNominationStateException; import org.apache.fineract.portfolio.election.exception.NominationAlreadyExistsException; -import org.apache.fineract.portfolio.election.exception.VoteAlreadyCastException; import org.apache.fineract.portfolio.election.exception.VoteCountExceedsShareAccountsException; import org.apache.fineract.portfolio.election.exception.VoteCountsNotSavedException; import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService; @@ -49,6 +47,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Slf4j @Service @@ -93,7 +92,7 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe throw new ElectionAlreadyOngoingException(); } - electionConfigRepository.findFirstWithActiveDuration(LocalDate.now()).ifPresent(prev -> { + electionConfigRepository.findFirstWithActiveDuration(LocalDate.now(DateUtils.getDateTimeZoneOfTenant())).ifPresent(prev -> { LocalDate expiryDate = prev.getElectionDate().plusYears(prev.getDurationYears()); throw new ElectionDurationActiveException(expiryDate); }); @@ -158,7 +157,7 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe context.authenticatedUser().validateHasPermissionTo("UPDATE_ELECTION"); ElectionConfig election = electionConfigRepository.findById(electionId) .orElseThrow(() -> new ElectionNotFoundException(electionId)); - if (LocalDate.now().isBefore(election.getNominationEndDate())) { + if (LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).isBefore(election.getNominationEndDate())) { throw new ElectionDateViolationException( "Cannot close nominations before the nomination end date " + election.getNominationEndDate()); } @@ -172,7 +171,7 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe context.authenticatedUser().validateHasPermissionTo("UPDATE_ELECTION"); ElectionConfig election = electionConfigRepository.findById(electionId) .orElseThrow(() -> new ElectionNotFoundException(electionId)); - if (LocalDate.now().isBefore(election.getElectionDate())) { + if (LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).isBefore(election.getElectionDate())) { throw new ElectionDateViolationException( "Cannot open voting before election date " + election.getElectionDate()); } @@ -187,7 +186,7 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe ElectionConfig election = electionConfigRepository.findById(electionId) .orElseThrow(() -> new ElectionNotFoundException(electionId)); - if (LocalDate.now().isBefore(election.getElectionDate())) { + if (LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).isBefore(election.getElectionDate())) { throw new ElectionDateViolationException( "Cannot close voting before election date " + election.getElectionDate()); } @@ -273,7 +272,7 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe throw new InvalidNominationStateException("withdraw", nomination.getStatus().name()); } ElectionConfig election = nomination.getElectionConfig(); - if (LocalDate.now().isAfter(election.getNominationEndDate())) { + if (LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).isAfter(election.getNominationEndDate())) { throw new ElectionDateViolationException( "Nomination withdrawal period has ended. Nominations closed on " + election.getNominationEndDate()); } @@ -290,7 +289,7 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe throw new InvalidNominationStateException("resubmit", nomination.getStatus().name()); } ElectionConfig election = nomination.getElectionConfig(); - if (LocalDate.now().isAfter(election.getNominationEndDate())) { + if (LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).isAfter(election.getNominationEndDate())) { throw new ElectionDateViolationException( "Nomination resubmission period has ended. Nominations closed on " + election.getNominationEndDate()); } @@ -466,7 +465,9 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe String symbol = candidate.getSymbol(); if (symbol != null && !symbol.startsWith("data:") && symbol.contains("|")) { - try { contentStoreService.delete(symbol.split("\\|", 2)[1]); } catch (Exception ignored) {} + try { contentStoreService.delete(symbol.split("\\|", 2)[1]); } catch (Exception ignored) { + // intentionally ignored + } } candidate.deactivate(); @@ -584,7 +585,7 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe ElectionConfig election = electionConfigRepository.findById(electionId) .orElseThrow(() -> new ElectionNotFoundException(electionId)); - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); LocalDate effectiveDate = election.getResultDate() != null ? election.getResultDate() : election.getElectionDate(); if (today.isBefore(effectiveDate)) { throw new ElectionDateViolationException( diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/MeetingVoteService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/MeetingVoteService.java index 742219218..aa3b48aec 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/MeetingVoteService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/MeetingVoteService.java @@ -5,14 +5,41 @@ import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.email.service.EmailSenderService; import org.springframework.beans.factory.annotation.Value; import org.apache.fineract.portfolio.client.domain.ClientRepository; -import org.apache.fineract.portfolio.election.data.*; -import org.apache.fineract.portfolio.election.domain.*; +import org.apache.fineract.portfolio.election.data.AgendaResolutionRequest; +import org.apache.fineract.portfolio.election.data.AgendaVoteRequest; +import org.apache.fineract.portfolio.election.data.AgendaVoteSummaryData; +import org.apache.fineract.portfolio.election.data.MeetingVoteSubmitRequest; +import org.apache.fineract.portfolio.election.data.MeetingVotingPageData; +import org.apache.fineract.portfolio.election.domain.AgendaResolution; +import org.apache.fineract.portfolio.election.domain.AgendaVote; +import org.apache.fineract.portfolio.election.domain.Committee; +import org.apache.fineract.portfolio.election.domain.CommitteeMeeting; +import org.apache.fineract.portfolio.election.domain.CommitteeMeetingAgenda; +import org.apache.fineract.portfolio.election.domain.CommitteeMeetingRepository; +import org.apache.fineract.portfolio.election.domain.CommitteeMember; +import org.apache.fineract.portfolio.election.domain.CommitteeMemberRepository; +import org.apache.fineract.portfolio.election.domain.CommitteeRepository; +import org.apache.fineract.portfolio.election.domain.DirectorAppointment; +import org.apache.fineract.portfolio.election.domain.DirectorAppointmentRepository; +import org.apache.fineract.portfolio.election.domain.ElectionCandidate; +import org.apache.fineract.portfolio.election.domain.ElectionCandidateRepository; +import org.apache.fineract.portfolio.election.domain.MeetingAgendaResolution; +import org.apache.fineract.portfolio.election.domain.MeetingAgendaResolutionRepository; +import org.apache.fineract.portfolio.election.domain.MeetingMemberVote; +import org.apache.fineract.portfolio.election.domain.MeetingMemberVoteRepository; +import org.apache.fineract.portfolio.election.domain.MeetingVoteToken; +import org.apache.fineract.portfolio.election.domain.MeetingVoteTokenRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.format.DateTimeFormatter; -import java.util.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Slf4j @Service @@ -289,7 +316,7 @@ public class MeetingVoteService { String baseUrl = frontendBaseUrl + "/#/vote/" + token; String dateStr = meeting.getMeetingDate() != null ? meeting.getMeetingDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")) : "—"; - String sentAt = java.time.LocalDateTime.now() + String sentAt = java.time.LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()) .format(DateTimeFormatter.ofPattern("dd MMM yyyy, hh:mm a")); StringBuilder sb = new StringBuilder(); @@ -397,7 +424,7 @@ public class MeetingVoteService { Map resMap) { String dateStr = meeting.getMeetingDate() != null ? meeting.getMeetingDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")) : "—"; - String sentAt = java.time.LocalDateTime.now() + String sentAt = java.time.LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()) .format(DateTimeFormatter.ofPattern("dd MMM yyyy, hh:mm a")); StringBuilder sb = new StringBuilder(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/domain/FundTransferEvent.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/domain/FundTransferEvent.java index fdc6bf8da..d2d15bfd0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/domain/FundTransferEvent.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/domain/FundTransferEvent.java @@ -31,6 +31,7 @@ import java.time.LocalDateTime; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** Append-only audit row per state transition on a {@link FundTransfer}. */ @Entity @@ -76,7 +77,7 @@ public class FundTransferEvent { e.toStatus = to; e.event = event; e.payloadJson = payloadJson; - e.createdAt = LocalDateTime.now(); + e.createdAt = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); e.createdBy = createdBy; return e; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/domain/VerificationToken.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/domain/VerificationToken.java index 0bb378575..ab3e92453 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/domain/VerificationToken.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/domain/VerificationToken.java @@ -31,6 +31,7 @@ import java.time.LocalDateTime; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** * Short-lived token issued at /v1/transfers/verify and redeemed at @@ -97,6 +98,6 @@ public class VerificationToken { private Long redeemedFundTransferId; public boolean isExpired() { - return LocalDateTime.now().isAfter(expiresAt); + return LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()).isAfter(expiresAt); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/FundTransferWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/FundTransferWritePlatformServiceImpl.java index 2973e1b3c..cd4547541 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/FundTransferWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/FundTransferWritePlatformServiceImpl.java @@ -54,6 +54,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Service @RequiredArgsConstructor @@ -85,7 +86,7 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl throw new IllegalArgumentException("amount must be > 0"); } TransferChannel channel = TransferChannel.from(request.getChannel()); - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); windowValidator.requireOpen(channel, LocalTime.from(now), now.getDayOfWeek()); AppUser cashier = context.authenticatedUser(); @@ -157,7 +158,7 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl transfer.setPayeeMaskName(token.getPayeeMaskName()); transfer.setAmount(token.getAmount()); transfer.setRemarks(remarks); - transfer.setInitiatedAt(LocalDateTime.now()); + transfer.setInitiatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); if (!needsApproval) { executeStrategy(transfer); @@ -210,7 +211,7 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl t.setStatus(FundTransferStatus.FAILED); t.setResponseCode("REJ"); t.setResponseMessage(reason); - t.setSettledAt(LocalDateTime.now()); + t.setSettledAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); FundTransfer saved = fundTransferRepository.save(t); eventRepository.save(FundTransferEvent.of(saved.getId(), prev, saved.getStatus(), "REJECTED", reason, approver.getId())); return FundTransferReadPlatformServiceImpl.toData(saved); @@ -235,7 +236,7 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl t.setStatus(FundTransferStatus.REVERSED); t.setResponseCode("RCL"); t.setResponseMessage(reason == null ? "Recall initiated by ops" : reason); - t.setSettledAt(LocalDateTime.now()); + t.setSettledAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); FundTransfer saved = fundTransferRepository.save(t); String payload = "reason=" + reason + ", switch=" + switchOutcome; eventRepository.save(FundTransferEvent.of(saved.getId(), prev, saved.getStatus(), "RECALLED", payload, actor.getId())); @@ -290,7 +291,7 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl transfer.setStatus(FundTransferStatus.FAILED); transfer.setResponseCode("ERR"); transfer.setResponseMessage(ex.getMessage()); - transfer.setSettledAt(LocalDateTime.now()); + transfer.setSettledAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); } if (transfer.getDirection() == TransferDirection.OFF_US && suspenseLegId != null @@ -362,6 +363,7 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl * Masks a display name to the IMPS RespValAdd convention: first letter of each * word kept, rest replaced with up to 5 asterisks. "John Doe" → "J*** D**". */ + @SuppressWarnings("StringSplitter") static String maskName(String displayName) { if (displayName == null || displayName.isBlank()) { return null; @@ -409,7 +411,7 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl if (limits.getPerDayMax() == null) { return; } - LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + LocalDateTime startOfDay = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).atStartOfDay(); BigDecimal already = fundTransferRepository.sumActiveOnChannelSince(payerAccountId, channel, startOfDay); if (already.add(amount).compareTo(limits.getPerDayMax()) > 0) { throw new IllegalStateException("daily limit exhausted for channel " + channel + " — already " + already diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/ImpsDeemedReconciliationService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/ImpsDeemedReconciliationService.java index 725144df6..409454e46 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/ImpsDeemedReconciliationService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/ImpsDeemedReconciliationService.java @@ -34,6 +34,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** * Polls the IMPS switch for DEEMED transfers (responseCode=91) to resolve them @@ -62,7 +63,7 @@ public class ImpsDeemedReconciliationService { /** Picks all DEEMED rows in the window and tries to resolve each. Returns count touched. */ public int reconcileAll() { - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); LocalDateTime floor = now.minusHours(MAX_AGE_HOURS); LocalDateTime ceiling = now.minusSeconds(MIN_AGE_SECONDS); List deemed = fundTransferRepository.findDeemedNeedingReconciliation(floor, ceiling); @@ -107,7 +108,7 @@ public class ImpsDeemedReconciliationService { if (resp.getUtr() != null) { transfer.setUtr(resp.getUtr()); } - transfer.setSettledAt(LocalDateTime.now()); + transfer.setSettledAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); if (newStatus == FundTransferStatus.FAILED) { suspenseGl.reverseFromSuspense(transfer); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/NeftBatchProcessorService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/NeftBatchProcessorService.java index 838e1886d..adafb63ba 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/NeftBatchProcessorService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/NeftBatchProcessorService.java @@ -36,6 +36,7 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** * Processes a NEFT batch cycle: picks all rows in QUEUED, sends each to the @@ -100,7 +101,7 @@ public class NeftBatchProcessorService { t.setSwitchTxnId(resp.getTxnId() == null ? t.getTxnRef() : resp.getTxnId()); t.setUtr(resp.getUtr()); if (mapped != FundTransferStatus.DEEMED) { - t.setSettledAt(LocalDateTime.now()); + t.setSettledAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); } if (mapped == FundTransferStatus.FAILED) { suspenseGl.reverseFromSuspense(t); @@ -114,7 +115,7 @@ public class NeftBatchProcessorService { t.setStatus(FundTransferStatus.FAILED); t.setResponseCode("ERR"); t.setResponseMessage(ex.getMessage()); - t.setSettledAt(LocalDateTime.now()); + t.setSettledAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); suspenseGl.reverseFromSuspense(t); fundTransferRepository.save(t); eventRepository.save(FundTransferEvent.of(t.getId(), prev, FundTransferStatus.FAILED, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/SuspenseGlService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/SuspenseGlService.java index d971e9c16..df0a40893 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/SuspenseGlService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/SuspenseGlService.java @@ -32,6 +32,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** * Moves money between the customer's savings account and the bank's "in transit" @@ -129,7 +130,7 @@ public class SuspenseGlService { } private Long move(Long fromAccountId, Long toAccountId, FundTransfer t, String description) { - AccountTransferDTO dto = new AccountTransferDTO(LocalDate.now(), t.getAmount(), PortfolioAccountType.SAVINGS, + AccountTransferDTO dto = new AccountTransferDTO(LocalDate.now(DateUtils.getDateTimeZoneOfTenant()), t.getAmount(), PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, fromAccountId, toAccountId, description, null, null, null, null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, null, null, Boolean.TRUE, Boolean.FALSE); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/TransferChannelWindowValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/TransferChannelWindowValidator.java index a255f22fc..09ac33fc8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/TransferChannelWindowValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/TransferChannelWindowValidator.java @@ -72,6 +72,7 @@ public class TransferChannelWindowValidator { } } + @SuppressWarnings("StringSplitter") private static boolean isHourInWindow(LocalTime now, String spec) { if (spec == null || spec.isBlank()) { return true; @@ -86,6 +87,7 @@ public class TransferChannelWindowValidator { return hour >= start && hour < end; } + @SuppressWarnings("StringSplitter") private static boolean isDayInWindow(DayOfWeek now, String spec) { if (spec == null || spec.isBlank()) { return true; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/TransferLimitResolver.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/TransferLimitResolver.java index 95bf2f0f2..0fa226fec 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/TransferLimitResolver.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/TransferLimitResolver.java @@ -29,6 +29,7 @@ import org.apache.fineract.portfolio.fundtransfer.domain.TransferChannel; import org.apache.fineract.portfolio.fundtransfer.domain.TransferLimitConfig; import org.apache.fineract.portfolio.fundtransfer.domain.TransferLimitConfigRepository; import org.springframework.stereotype.Service; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** * Resolves effective per-customer-per-channel limits by overlaying any @@ -47,7 +48,7 @@ public class TransferLimitResolver { .orElseThrow(() -> new IllegalStateException( "No active m_transfer_limit_config for channel " + channel + " — seed missing?")); - Optional override = customerRepository.findEffective(clientId, channel, LocalDate.now()); + Optional override = customerRepository.findEffective(clientId, channel, LocalDate.now(DateUtils.getDateTimeZoneOfTenant())); BigDecimal perTxnMax = override.map(CustomerTransferLimit::getPerTxnMax).orElse(null); if (perTxnMax == null) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/VerificationTokenCleanupService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/VerificationTokenCleanupService.java index 67f68a200..74aaadee4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/VerificationTokenCleanupService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/VerificationTokenCleanupService.java @@ -26,6 +26,7 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** * Two-phase cleanup of {@code m_verification_token}: @@ -51,7 +52,7 @@ public class VerificationTokenCleanupService { @Transactional public CleanupResult cleanup() { - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); int expired = repository.markExpired(now); int deleted = repository.deleteTerminalOlderThan(now.minusHours(retentionHours)); log.info("[VerificationTokenCleanup] flipped {} → EXPIRED, deleted {} terminal token(s) older than {}h", diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/VerificationTokenService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/VerificationTokenService.java index 327e7c0ce..3c7a5e129 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/VerificationTokenService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/VerificationTokenService.java @@ -29,6 +29,7 @@ import org.apache.fineract.portfolio.fundtransfer.domain.VerificationTokenReposi import org.apache.fineract.portfolio.fundtransfer.domain.VerificationTokenStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** Issues + redeems verification tokens (the BANL → debit bridge). */ @Service @@ -56,7 +57,7 @@ public class VerificationTokenService { t.setPayeeMaskName(payeeMaskName); t.setAmount(amount); t.setStatus(VerificationTokenStatus.ISSUED); - t.setIssuedAt(LocalDateTime.now()); + t.setIssuedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); t.setExpiresAt(t.getIssuedAt().plusSeconds(TTL_SECONDS)); return repository.save(t); } @@ -78,7 +79,7 @@ public class VerificationTokenService { @Transactional public void markRedeemed(VerificationToken t, Long fundTransferId) { t.setStatus(VerificationTokenStatus.REDEEMED); - t.setRedeemedAt(LocalDateTime.now()); + t.setRedeemedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); t.setRedeemedFundTransferId(fundTransferId); repository.save(t); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/strategy/ImpsOutwardStrategy.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/strategy/ImpsOutwardStrategy.java index bd821629c..bd2bee487 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/strategy/ImpsOutwardStrategy.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/strategy/ImpsOutwardStrategy.java @@ -30,6 +30,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** * IMPS outward via LincPay's ReqPay endpoint. Maps the IMPS response code to @@ -64,7 +65,7 @@ public class ImpsOutwardStrategy implements TransferStrategy { transfer.setStatus(FundTransferStatus.FAILED); transfer.setResponseCode("ERR"); transfer.setResponseMessage(ex.getMessage()); - transfer.setSettledAt(LocalDateTime.now()); + transfer.setSettledAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); } } @@ -106,7 +107,7 @@ public class ImpsOutwardStrategy implements TransferStrategy { }; transfer.setStatus(mapped); if (mapped != FundTransferStatus.DEEMED) { - transfer.setSettledAt(LocalDateTime.now()); + transfer.setSettledAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); } log.info("[ImpsOutwardStrategy] txnRef={} responseCode={} → status={} utr={}", transfer.getTxnRef(), code, mapped, transfer.getUtr()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/strategy/InternalTransferStrategy.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/strategy/InternalTransferStrategy.java index 83672ac66..c87327614 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/strategy/InternalTransferStrategy.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/strategy/InternalTransferStrategy.java @@ -33,6 +33,7 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccount; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** * On-us settlement — debits payer + credits payee atomically via Fineract's @@ -54,7 +55,7 @@ public class InternalTransferStrategy implements TransferStrategy { SavingsAccount payee = payeeLookup.findByAccountNumber(transfer.getPayeeAccountNumber()).orElseThrow( () -> new IllegalStateException("Payee account vanished between verify and debit: " + transfer.getPayeeAccountNumber())); - AccountTransferDTO dto = new AccountTransferDTO(LocalDate.now(), transfer.getAmount(), PortfolioAccountType.SAVINGS, + AccountTransferDTO dto = new AccountTransferDTO(LocalDate.now(DateUtils.getDateTimeZoneOfTenant()), transfer.getAmount(), PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, transfer.getPayerAccountId(), payee.getId(), buildDescription(transfer), null, null, null, null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, null, null, @@ -68,7 +69,7 @@ public class InternalTransferStrategy implements TransferStrategy { transfer.setStatus(FundTransferStatus.SUCCESS); transfer.setResponseCode("00"); transfer.setResponseMessage("Internal transfer settled"); - transfer.setSettledAt(LocalDateTime.now()); + transfer.setSettledAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); } private static String buildDescription(FundTransfer t) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/strategy/RtgsOutwardStrategy.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/strategy/RtgsOutwardStrategy.java index 1154cd0af..a394030b1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/strategy/RtgsOutwardStrategy.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/strategy/RtgsOutwardStrategy.java @@ -30,6 +30,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** * RTGS outward — realtime, same shape as IMPS but routed to {@code /RTGS/ReqPay}. @@ -61,7 +62,7 @@ public class RtgsOutwardStrategy implements TransferStrategy { transfer.setStatus(FundTransferStatus.FAILED); transfer.setResponseCode("ERR"); transfer.setResponseMessage(ex.getMessage()); - transfer.setSettledAt(LocalDateTime.now()); + transfer.setSettledAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); } } @@ -103,7 +104,7 @@ public class RtgsOutwardStrategy implements TransferStrategy { }; transfer.setStatus(mapped); if (mapped != FundTransferStatus.DEEMED) { - transfer.setSettledAt(LocalDateTime.now()); + transfer.setSettledAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); } log.info("[RtgsOutwardStrategy] txnRef={} responseCode={} → status={} utr={}", transfer.getTxnRef(), code, mapped, transfer.getUtr()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/api/SanctionsScreeningApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/api/SanctionsScreeningApiResource.java index 2a1e6cb79..38f1c2477 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/api/SanctionsScreeningApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/api/SanctionsScreeningApiResource.java @@ -30,7 +30,6 @@ import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; -import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; import java.io.InputStream; import java.util.HashMap; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsScreeningResult.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsScreeningResult.java index 3da421b0c..ac33a4ad9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsScreeningResult.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsScreeningResult.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.portfolio.sanctions.data; +import java.time.OffsetDateTime; import java.util.List; /** @@ -32,10 +33,11 @@ public final class SanctionsScreeningResult { private final String message; private final int matchCount; private final List matches; + private final OffsetDateTime listDataAsOf; private SanctionsScreeningResult(final Long screeningLogId, final String screenedName, final boolean matchFound, final String status, final String message, final int matchCount, - final List matches) { + final List matches, final OffsetDateTime listDataAsOf) { this.screeningLogId = screeningLogId; this.screenedName = screenedName; this.matchFound = matchFound; @@ -43,20 +45,22 @@ public final class SanctionsScreeningResult { this.message = message; this.matchCount = matchCount; this.matches = matches; + this.listDataAsOf = listDataAsOf; } public static SanctionsScreeningResult matched(final Long screeningLogId, final String screenedName, - final List matches) { + final List matches, final OffsetDateTime listDataAsOf) { return new SanctionsScreeningResult(screeningLogId, screenedName, true, "FLAGGED", "Potential match found in UN Security Council consolidated sanctions list. " + "Manual review required before proceeding.", - matches.size(), matches); + matches.size(), matches, listDataAsOf); } - public static SanctionsScreeningResult clear(final Long screeningLogId, final String screenedName) { + public static SanctionsScreeningResult clear(final Long screeningLogId, final String screenedName, + final OffsetDateTime listDataAsOf) { return new SanctionsScreeningResult(screeningLogId, screenedName, false, "CLEAR", "No match found in UN Security Council consolidated sanctions list.", - 0, List.of()); + 0, List.of(), listDataAsOf); } public Long getScreeningLogId() { return screeningLogId; } @@ -66,4 +70,5 @@ public final class SanctionsScreeningResult { public String getMessage() { return message; } public int getMatchCount() { return matchCount; } public List getMatches() { return matches; } + public OffsetDateTime getListDataAsOf() { return listDataAsOf; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningServiceImpl.java index 111e7003e..5fd41625d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningServiceImpl.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.portfolio.sanctions.service; +import java.time.OffsetDateTime; import java.util.List; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; @@ -54,18 +55,19 @@ public class SanctionsScreeningServiceImpl implements SanctionsScreeningService final Long clientId, final String ipAddress) { if (name == null || name.isBlank()) { final Long logId = saveLog(name, "CLEAR", 0, null, null, source, clientId, ipAddress); - return SanctionsScreeningResult.clear(logId, name); + return SanctionsScreeningResult.clear(logId, name, sanctionsEntryRepository.findListLastLoadedOn().orElse(null)); } final String trimmedName = name.trim(); LOG.info("Screening name against sanctions list: {}", trimmedName); + final OffsetDateTime listDataAsOf = sanctionsEntryRepository.findListLastLoadedOn().orElse(null); final List matches = sanctionsEntryRepository.searchByNameOrAlias(trimmedName); if (matches.isEmpty()) { LOG.info("No sanctions match found for: {}", trimmedName); final Long logId = saveLog(trimmedName, "CLEAR", 0, null, null, source, clientId, ipAddress); - return SanctionsScreeningResult.clear(logId, trimmedName); + return SanctionsScreeningResult.clear(logId, trimmedName, listDataAsOf); } LOG.warn("Sanctions match found for '{}': {} potential match(es)", trimmedName, matches.size()); @@ -84,7 +86,7 @@ public class SanctionsScreeningServiceImpl implements SanctionsScreeningService final Long logId = saveLog(trimmedName, "FLAGGED", matches.size(), matchedEntryIds, matchedNames, source, clientId, ipAddress); - return SanctionsScreeningResult.matched(logId, trimmedName, matchDataList); + return SanctionsScreeningResult.matched(logId, trimmedName, matchDataList, listDataAsOf); } @Override diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountTransactionsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountTransactionsApiResource.java index 5addf6078..8cecd0a1a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountTransactionsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountTransactionsApiResource.java @@ -40,7 +40,6 @@ import java.util.LinkedHashMap; import java.util.Map; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; -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; @@ -55,6 +54,7 @@ import org.apache.fineract.portfolio.savings.DepositsApiConstants; import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Path("/v1/currentaccounts/{accountId}/transactions") @Component @@ -91,7 +91,7 @@ public class CurrentAccountTransactionsApiResource { try { template = jdbcTemplate.queryForObject(sql, (rs, rowNum) -> { final Map map = new LinkedHashMap<>(); - final LocalDate today = LocalDate.now(); + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); map.put("date", new int[] { today.getYear(), today.getMonthValue(), today.getDayOfMonth() }); final Map currency = new LinkedHashMap<>(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountsApiResource.java index a0310f85a..61cc740d4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountsApiResource.java @@ -37,25 +37,30 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.UriInfo; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.google.gson.JsonParser; import java.time.LocalDate; +import java.util.Collection; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper; +import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException; import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings; import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.SqlValidator; +import org.apache.fineract.portfolio.currentaccount.data.CurrentAccountPendingEditData; import org.apache.fineract.portfolio.savings.DepositsApiConstants; import org.apache.fineract.portfolio.savings.data.SavingsAccountData; import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountReadPlatformService; 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.portfolio.currentaccount.service.CurrentAccountEditWritePlatformService; import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService; import org.apache.fineract.portfolio.savings.service.CurrentAccountTemplateReadPlatformService; import org.springframework.stereotype.Component; @@ -76,6 +81,9 @@ public class CurrentAccountsApiResource { private final CurrentAccountWritePlatformService currentAccountWritePlatformService; private final CurrentAccountReadPlatformService currentAccountReadPlatformService; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final CurrentAccountEditWritePlatformService currentAccountEditWritePlatformService; + private final FromJsonHelper fromApiJsonHelper; + private final DefaultToApiJsonSerializer pendingEditApiJsonSerializer; @GET @Path("template") @@ -229,4 +237,104 @@ public class CurrentAccountsApiResource { return StringUtils.isNotBlank(commandParam) && commandParam.trim().equalsIgnoreCase(commandValue); } + @POST + @Path("{accountId}/edit") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Submit a Nominee/Digital-Feature edit for a Current Account", + description = "Edits are held in m_current_account_pending_edit (a pending row there is itself the lock) and only " + + "applied to the real account once a checker approves - see /currentaccounts/{accountId}/edit/approve and /reject below.") + @ApiResponse(responseCode = "200", description = "OK") + public String submitEdit(@PathParam("accountId") final Long accountId, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + + this.context.authenticatedUser().validateHasPermissionTo("SUBMITEDIT_CURRENTACCOUNT"); + + final JsonCommand command = JsonCommand.fromJsonElement(accountId, this.fromApiJsonHelper.parse(apiRequestBodyAsJson), + this.fromApiJsonHelper); + + final CommandProcessingResult result = this.currentAccountEditWritePlatformService.submitEditForApproval(accountId, command); + + return this.toApiJsonSerializer.serialize(result); + } + + @GET + @Path("edit/pending") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "List Current Account Nominee/Digital-Feature edits pending approval") + @ApiResponse(responseCode = "200", description = "OK") + public String retrieveAllPendingEdits(@Context final UriInfo uriInfo) { + + context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.CURRENT_ACCOUNT_RESOURCE_NAME); + + final Collection pendingEdits = this.currentAccountEditWritePlatformService + .retrieveAllPendingEdits(); + + final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters()); + return this.pendingEditApiJsonSerializer.serialize(settings, pendingEdits); + } + + @GET + @Path("{accountId}/edit/pending") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve the pending Nominee/Digital-Feature edit for a Current Account, if any") + @ApiResponse(responseCode = "200", description = "OK") + public String retrievePendingEdit(@PathParam("accountId") final Long accountId, @Context final UriInfo uriInfo) { + + context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.CURRENT_ACCOUNT_RESOURCE_NAME); + + final CurrentAccountPendingEditData pendingEdit = this.currentAccountEditWritePlatformService.retrievePendingEdit(accountId); + + final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters()); + return this.pendingEditApiJsonSerializer.serialize(settings, pendingEdit); + } + + @POST + @Path("{accountId}/edit/approve") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Approve a pending Current Account Nominee/Digital-Feature edit", + description = "Releases the submission-time hold, posts any newly-enabled feature charges, and applies the " + + "Nominee/Digital-Feature changes to the account. The maker of the edit cannot also approve it.") + @ApiResponse(responseCode = "200", description = "OK") + public String approvePendingEdit(@PathParam("accountId") final Long accountId) { + + if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVEEDIT_CURRENTACCOUNT")) { + throw new NoAuthorizationException("User has no authority to approve current account edits"); + } + + final CommandProcessingResult result = this.currentAccountEditWritePlatformService.approveEdit(accountId); + + return this.toApiJsonSerializer.serialize(result); + } + + @POST + @Path("{accountId}/edit/reject") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Reject a pending Current Account Nominee/Digital-Feature edit", + description = "Releases the submission-time hold without applying any change. The maker of the edit cannot also reject it.") + @ApiResponse(responseCode = "200", description = "OK") + public String rejectPendingEdit(@PathParam("accountId") final Long accountId, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + + if (this.context.authenticatedUser().hasNotPermissionForAnyOf("REJECTEDIT_CURRENTACCOUNT")) { + throw new NoAuthorizationException("User has no authority to reject current account edits"); + } + + String rejectReason = null; + if (apiRequestBodyAsJson != null && !apiRequestBodyAsJson.isBlank()) { + final var parsed = this.fromApiJsonHelper.parse(apiRequestBodyAsJson); + if (parsed != null && parsed.isJsonObject() && parsed.getAsJsonObject().has("rejectReason")) { + rejectReason = parsed.getAsJsonObject().get("rejectReason").getAsString(); + } + } + + final CommandProcessingResult result = this.currentAccountEditWritePlatformService.rejectEdit(accountId, rejectReason); + + return this.toApiJsonSerializer.serialize(result); + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java index 16b8d270c..e2e480614 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java @@ -58,8 +58,11 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.UploadRequest; import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException; +import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException; import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings; import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; import org.apache.fineract.infrastructure.core.service.Page; import org.apache.fineract.infrastructure.core.service.SearchParameters; @@ -69,9 +72,11 @@ import org.apache.fineract.portfolio.savings.DepositAccountType; import org.apache.fineract.portfolio.savings.SavingsApiConstants; import org.apache.fineract.portfolio.savings.data.SavingsAccountChargeData; import org.apache.fineract.portfolio.savings.data.SavingsAccountData; +import org.apache.fineract.portfolio.savings.data.SavingsAccountPendingEditData; import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData; import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException; import org.apache.fineract.portfolio.savings.service.SavingsAccountChargeReadPlatformService; +import org.apache.fineract.portfolio.savings.service.SavingsAccountEditWritePlatformService; import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService; import org.apache.fineract.portfolio.savings.service.SavingsAccountTemplateReadPlatformService; import org.glassfish.jersey.media.multipart.FormDataContentDisposition; @@ -95,6 +100,9 @@ public class SavingsAccountsApiResource { private final BulkImportWorkbookService bulkImportWorkbookService; private final BulkImportWorkbookPopulatorService bulkImportWorkbookPopulatorService; private final SqlValidator sqlValidator; + private final SavingsAccountEditWritePlatformService savingsAccountEditWritePlatformService; + private final FromJsonHelper fromApiJsonHelper; + private final DefaultToApiJsonSerializer pendingEditApiJsonSerializer; @GET @Path("template") @@ -413,6 +421,102 @@ public class SavingsAccountsApiResource { return StringUtils.isNotBlank(commandParam) && commandParam.trim().equalsIgnoreCase(commandValue); } + @POST + @Path("{accountId}/edit") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Submit a Nominee/Digital-Feature edit for a Savings Account", + description = "Edits are held in m_savings_account_pending_edit (a pending row there is itself the lock) and only " + + "applied to the real account once a checker approves - see /savingsaccounts/{accountId}/edit/approve and /reject below.") + public String submitEdit(@PathParam("accountId") @Parameter(description = "accountId") final Long accountId, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + + this.context.authenticatedUser().validateHasPermissionTo("SUBMITEDIT_SAVINGSACCOUNT"); + + final JsonCommand command = JsonCommand.fromJsonElement(accountId, this.fromApiJsonHelper.parse(apiRequestBodyAsJson), + this.fromApiJsonHelper); + + final CommandProcessingResult result = this.savingsAccountEditWritePlatformService.submitEditForApproval(accountId, command); + + return this.toApiJsonSerializer.serialize(result); + } + + @GET + @Path("edit/pending") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "List Savings Account Nominee/Digital-Feature edits pending approval") + public String retrieveAllPendingEdits(@Context final UriInfo uriInfo) { + + this.context.authenticatedUser().validateHasReadPermission(SavingsApiConstants.SAVINGS_ACCOUNT_RESOURCE_NAME); + + final Collection pendingEdits = this.savingsAccountEditWritePlatformService + .retrieveAllPendingEdits(); + + final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters()); + return this.pendingEditApiJsonSerializer.serialize(settings, pendingEdits); + } + + @GET + @Path("{accountId}/edit/pending") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve the pending Nominee/Digital-Feature edit for a Savings Account, if any") + public String retrievePendingEdit(@PathParam("accountId") @Parameter(description = "accountId") final Long accountId, + @Context final UriInfo uriInfo) { + + this.context.authenticatedUser().validateHasReadPermission(SavingsApiConstants.SAVINGS_ACCOUNT_RESOURCE_NAME); + + final SavingsAccountPendingEditData pendingEdit = this.savingsAccountEditWritePlatformService.retrievePendingEdit(accountId); + + final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters()); + return this.pendingEditApiJsonSerializer.serialize(settings, pendingEdit); + } + + @POST + @Path("{accountId}/edit/approve") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Approve a pending Savings Account Nominee/Digital-Feature edit", + description = "Releases the submission-time hold, posts any newly-enabled feature charges, and applies the " + + "Nominee/Digital-Feature changes to the account. The maker of the edit cannot also approve it.") + public String approvePendingEdit(@PathParam("accountId") @Parameter(description = "accountId") final Long accountId) { + + if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVEEDIT_SAVINGSACCOUNT")) { + throw new NoAuthorizationException("User has no authority to approve savings account edits"); + } + + final CommandProcessingResult result = this.savingsAccountEditWritePlatformService.approveEdit(accountId); + + return this.toApiJsonSerializer.serialize(result); + } + + @POST + @Path("{accountId}/edit/reject") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Reject a pending Savings Account Nominee/Digital-Feature edit", + description = "Releases the submission-time hold without applying any change. The maker of the edit cannot also reject it.") + public String rejectPendingEdit(@PathParam("accountId") @Parameter(description = "accountId") final Long accountId, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + + if (this.context.authenticatedUser().hasNotPermissionForAnyOf("REJECTEDIT_SAVINGSACCOUNT")) { + throw new NoAuthorizationException("User has no authority to reject savings account edits"); + } + + String rejectReason = null; + if (apiRequestBodyAsJson != null && !apiRequestBodyAsJson.isBlank()) { + final var parsed = this.fromApiJsonHelper.parse(apiRequestBodyAsJson); + if (parsed != null && parsed.isJsonObject() && parsed.getAsJsonObject().has("rejectReason")) { + rejectReason = parsed.getAsJsonObject().get("rejectReason").getAsString(); + } + } + + final CommandProcessingResult result = this.savingsAccountEditWritePlatformService.rejectEdit(accountId, rejectReason); + + return this.toApiJsonSerializer.serialize(result); + } + @DELETE @Path("{accountId}") @Consumes({ MediaType.APPLICATION_JSON }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FdBaseInterestRateEffective.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FdBaseInterestRateEffective.java index e5891c0f3..775a3dfa1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FdBaseInterestRateEffective.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FdBaseInterestRateEffective.java @@ -20,6 +20,7 @@ import jakarta.persistence.Table; import java.time.LocalDate; import lombok.Getter; import lombok.NoArgsConstructor; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Entity @Table(name = "m_cbs_base_interest_rate_effective") @@ -64,7 +65,7 @@ public class FdBaseInterestRateEffective { e.isActive = true; e.remarks = remarks; e.createdById = createdById; - e.createdDate = LocalDate.now(); + e.createdDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); return e; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FdBaseInterestRateSlabHistory.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FdBaseInterestRateSlabHistory.java index 2b6e842f1..ae4822687 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FdBaseInterestRateSlabHistory.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FdBaseInterestRateSlabHistory.java @@ -21,6 +21,7 @@ import java.math.BigDecimal; import java.time.LocalDate; import lombok.Getter; import lombok.NoArgsConstructor; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Entity @Table(name = "m_cbs_base_interest_rate_slab_history") @@ -70,7 +71,7 @@ public class FdBaseInterestRateSlabHistory { s.toPeriod = toPeriod; s.annualInterestRate = annualInterestRate; s.seniorCitizenRate = seniorCitizenRate; - s.createdDate = LocalDate.now(); + s.createdDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); return s; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RdBaseInterestRateEffective.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RdBaseInterestRateEffective.java index 5bf874c92..e2b57770f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RdBaseInterestRateEffective.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RdBaseInterestRateEffective.java @@ -20,6 +20,7 @@ import jakarta.persistence.Table; import java.time.LocalDate; import lombok.Getter; import lombok.NoArgsConstructor; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Entity @Table(name = "m_rd_base_interest_rate_effective") @@ -59,7 +60,7 @@ public class RdBaseInterestRateEffective { e.isActive = true; e.remarks = remarks; e.createdById = createdById; - e.createdDate = LocalDate.now(); + e.createdDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); return e; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RdInterestRateEffective.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RdInterestRateEffective.java index 11836f623..1f826d3cd 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RdInterestRateEffective.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RdInterestRateEffective.java @@ -20,6 +20,7 @@ import jakarta.persistence.Table; import java.time.LocalDate; import lombok.Getter; import lombok.NoArgsConstructor; +import org.apache.fineract.infrastructure.core.service.DateUtils; /** * Maps to: m_rd_interest_rate_effective @@ -69,7 +70,7 @@ public class RdInterestRateEffective { e.isActive = true; e.remarks = remarks; e.createdById = createdById; - e.createdDate = LocalDate.now(); + e.createdDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); return e; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountAssembler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountAssembler.java index 571ad5d98..95d28d2d9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountAssembler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountAssembler.java @@ -72,7 +72,6 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withHold import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withdrawalFeeForTransfersParamName; import com.google.gson.Gson; -import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import java.math.BigDecimal; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java new file mode 100644 index 000000000..34f73ee2e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java @@ -0,0 +1,569 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +import com.google.gson.JsonObject; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandProcessingResultType; +import org.apache.fineract.commands.domain.CommandSource; +import org.apache.fineract.commands.domain.CommandSourceRepository; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.client.service.ClientNomineeWritePlatformService; +import org.apache.fineract.portfolio.currentaccount.data.CurrentAccountPendingEditData; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfigRepository; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountPendingEdit; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountPendingEditRepository; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; +import org.apache.fineract.portfolio.currentaccount.exception.CurrentAccountEditInsufficientBalanceException; +import org.apache.fineract.portfolio.currentaccount.exception.CurrentAccountEditPendingApprovalException; +import org.apache.fineract.portfolio.currentaccount.exception.CurrentAccountEditSelfApprovalNotAllowedException; +import org.apache.fineract.portfolio.currentaccount.exception.CurrentAccountNoPendingEditException; +import org.apache.fineract.portfolio.currentaccount.exception.CurrentAccountNotFoundException; +import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountEditWritePlatformService; +import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountTransactionWritePlatformService; +import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.transaction.annotation.Transactional; + +/** + * Maker-checker edit flow for an ACTIVE current account's Nominee + Digital & Account Features. Mirrors + * {@code SavingsAccountEditWritePlatformServiceJpaRepositoryImpl}'s submitEditForApproval/approveEdit/rejectEdit + * pattern: a row in {@code m_current_account_pending_edit} is itself the lock, and nothing is applied to the real + * {@code m_current_account_facility_config} / {@code m_client_nominee} tables until a checker approves. + * + *

+ * Charge handling for a newly-enabled facility (one with a mapped charge in m_facility_charge_mapping): at + * submission a HOLD is placed for the total charge amount (blocking that much of the available balance without + * touching it); on approval the hold is released and the charge is posted for real (via + * {@link CurrentAccountWritePlatformService#applyAndCollectFacilityCharges(Long, Set)}); on rejection the hold is + * simply released. + */ +@RequiredArgsConstructor +public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements CurrentAccountEditWritePlatformService { + + /** field name (as sent in accountFacilityConfig JSON / stored on the pending edit) -> facility_type code. */ + private static final Map FACILITY_TYPES = new LinkedHashMap<>(); + static { + FACILITY_TYPES.put("atmFacility", "ATM_FACILITY"); + FACILITY_TYPES.put("atmWithdrawalFacility", "ATM_WITHDRAWAL"); + FACILITY_TYPES.put("upiFacility", "UPI_FACILITY"); + FACILITY_TYPES.put("posTransactionsFacility", "POS_TRANSACTIONS"); + FACILITY_TYPES.put("chequeBook", "CHEQUE_BOOK"); + FACILITY_TYPES.put("impsFacility", "IMPS_FACILITY"); + FACILITY_TYPES.put("smsAlerts", "SMS_ALERTS"); + FACILITY_TYPES.put("bbpsFacility", "BBPS_FACILITY"); + FACILITY_TYPES.put("insuranceCover", "INSURANCE_COVER"); + FACILITY_TYPES.put("bepgFacility", "BEPG_FACILITY"); + FACILITY_TYPES.put("neftFacility", "NEFT_FACILITY"); + FACILITY_TYPES.put("rtgsFacility", "RTGS_FACILITY"); + FACILITY_TYPES.put("interBankTotalCapFacility", "INTER_BANK_TOTAL_CAP"); + FACILITY_TYPES.put("withdrawalFacility", "WITHDRAWAL_FACILITY"); + FACILITY_TYPES.put("depositFacilityKyc", "DEPOSIT_FACILITY_KYC"); + FACILITY_TYPES.put("virtualDebitCard", "VIRTUAL_DEBIT_CARD"); + FACILITY_TYPES.put("internetBanking", "INTERNET_BANKING"); + FACILITY_TYPES.put("mobileBanking", "MOBILE_BANKING"); + } + + private final PlatformSecurityContext context; + private final CurrentAccountRepository currentAccountRepository; + private final CurrentAccountPendingEditRepository pendingEditRepository; + private final CurrentAccountFacilityConfigRepository facilityConfigRepository; + private final org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository facilityChargeMappingRepository; + private final CurrentAccountTransactionWritePlatformService currentAccountTransactionWritePlatformService; + private final CurrentAccountWritePlatformService currentAccountWritePlatformService; + private final ClientNomineeWritePlatformService clientNomineeWritePlatformService; + private final CommandSourceRepository commandSourceRepository; + private final FromJsonHelper fromApiJsonHelper; + + @Transactional + @Override + public CommandProcessingResult submitEditForApproval(final Long currentAccountId, final JsonCommand command) { + + final AppUser maker = this.context.authenticatedUser(); + final CurrentAccount account = this.currentAccountRepository.findById(currentAccountId) + .orElseThrow(() -> new CurrentAccountNotFoundException(currentAccountId)); + + if (this.pendingEditRepository.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING) + .isPresent()) { + throw new CurrentAccountEditPendingApprovalException(currentAccountId); + } + + final JsonObject root = command.parsedJson().getAsJsonObject(); + final CurrentAccountFacilityConfig currentConfig = account.getFacilityConfig(); + + final CurrentAccountPendingEdit pendingEdit = CurrentAccountPendingEdit.snapshotOf(account, maker); + + // ── Digital & Account Features ── + final Set newlyEnabledFacilityTypes = new HashSet<>(); + if (root.has("accountFacilityConfig") && root.get("accountFacilityConfig").isJsonObject()) { + final JsonObject fc = root.getAsJsonObject("accountFacilityConfig"); + pendingEdit.setFacilityConfigSubmitted(true); + populateFacilityField(fc, "atmFacility", currentConfig != null && currentConfig.isAtmFacility(), pendingEdit::setAtmFacility, + newlyEnabledFacilityTypes); + populateFacilityField(fc, "atmWithdrawalFacility", currentConfig != null && currentConfig.isAtmWithdrawalFacility(), + pendingEdit::setAtmWithdrawalFacility, newlyEnabledFacilityTypes); + populateFacilityField(fc, "upiFacility", currentConfig != null && currentConfig.isUpiFacility(), pendingEdit::setUpiFacility, + newlyEnabledFacilityTypes); + populateFacilityField(fc, "posTransactionsFacility", currentConfig != null && currentConfig.isPosTransactionsFacility(), + pendingEdit::setPosTransactionsFacility, newlyEnabledFacilityTypes); + populateFacilityField(fc, "chequeBook", currentConfig != null && currentConfig.isChequeBook(), pendingEdit::setChequeBook, + newlyEnabledFacilityTypes); + populateFacilityField(fc, "impsFacility", currentConfig != null && currentConfig.isImpsFacility(), + pendingEdit::setImpsFacility, newlyEnabledFacilityTypes); + populateFacilityField(fc, "smsAlerts", currentConfig != null && currentConfig.isSmsAlerts(), pendingEdit::setSmsAlerts, + newlyEnabledFacilityTypes); + populateFacilityField(fc, "bbpsFacility", currentConfig != null && currentConfig.isBbpsFacility(), + pendingEdit::setBbpsFacility, newlyEnabledFacilityTypes); + populateFacilityField(fc, "insuranceCover", currentConfig != null && currentConfig.isInsuranceCover(), + pendingEdit::setInsuranceCover, newlyEnabledFacilityTypes); + populateFacilityField(fc, "bepgFacility", currentConfig != null && currentConfig.isBepgFacility(), + pendingEdit::setBepgFacility, newlyEnabledFacilityTypes); + populateFacilityField(fc, "neftFacility", currentConfig != null && currentConfig.isNeftFacility(), + pendingEdit::setNeftFacility, newlyEnabledFacilityTypes); + populateFacilityField(fc, "rtgsFacility", currentConfig != null && currentConfig.isRtgsFacility(), + pendingEdit::setRtgsFacility, newlyEnabledFacilityTypes); + populateFacilityField(fc, "interBankTotalCapFacility", currentConfig != null && currentConfig.isInterBankTotalCapFacility(), + pendingEdit::setInterBankTotalCapFacility, newlyEnabledFacilityTypes); + populateFacilityField(fc, "withdrawalFacility", currentConfig != null && currentConfig.isWithdrawalFacility(), + pendingEdit::setWithdrawalFacility, newlyEnabledFacilityTypes); + populateFacilityField(fc, "depositFacilityKyc", currentConfig != null && currentConfig.isDepositFacilityKyc(), + pendingEdit::setDepositFacilityKyc, newlyEnabledFacilityTypes); + populateFacilityField(fc, "virtualDebitCard", currentConfig != null && currentConfig.isVirtualDebitCard(), + pendingEdit::setVirtualDebitCard, newlyEnabledFacilityTypes); + populateFacilityField(fc, "internetBanking", currentConfig != null && currentConfig.isInternetBanking(), + pendingEdit::setInternetBanking, newlyEnabledFacilityTypes); + populateFacilityField(fc, "mobileBanking", currentConfig != null && currentConfig.isMobileBanking(), + pendingEdit::setMobileBanking, newlyEnabledFacilityTypes); + } + + // ── Nominee (single add / update / delete per request) ── + if (root.has("nominee") && root.get("nominee").isJsonObject()) { + final JsonObject nom = root.getAsJsonObject("nominee"); + pendingEdit.setNomineeSubmitted(true); + pendingEdit.setNomineeOperation(nom.has("operation") ? nom.get("operation").getAsString() : null); + pendingEdit.setNomineeId(nom.has("nomineeId") && !nom.get("nomineeId").isJsonNull() ? nom.get("nomineeId").getAsLong() : null); + pendingEdit.setNomineeClientId( + nom.has("nomineeClientId") && !nom.get("nomineeClientId").isJsonNull() ? nom.get("nomineeClientId").getAsLong() + : null); + pendingEdit.setRelationshipCvId( + nom.has("relationshipId") && !nom.get("relationshipId").isJsonNull() ? nom.get("relationshipId").getAsLong() : null); + pendingEdit.setPercentageShare( + nom.has("percentageShare") && !nom.get("percentageShare").isJsonNull() ? nom.get("percentageShare").getAsBigDecimal() + : null); + pendingEdit.setNomineeIsActive( + (nom.has("isActive") && !nom.get("isActive").isJsonNull()) ? nom.get("isActive").getAsBoolean() : null); + pendingEdit.setNomineeIsMinor( + (nom.has("nomineeIsMinor") && !nom.get("nomineeIsMinor").isJsonNull()) ? nom.get("nomineeIsMinor").getAsBoolean() + : null); + pendingEdit.setGuardianClientId( + nom.has("guardianClientId") && !nom.get("guardianClientId").isJsonNull() ? nom.get("guardianClientId").getAsLong() + : null); + pendingEdit.setGuardianRelationshipCvId( + nom.has("guardianRelationshipId") && !nom.get("guardianRelationshipId").isJsonNull() + ? nom.get("guardianRelationshipId").getAsLong() + : null); + pendingEdit.setEffectiveDate( + nom.has("effectiveDate") && !nom.get("effectiveDate").isJsonNull() ? LocalDate.parse(nom.get("effectiveDate").getAsString()) + : null); + pendingEdit.setNomineeRemarks(nom.has("remarks") && !nom.get("remarks").isJsonNull() ? nom.get("remarks").getAsString() : null); + } + + // ── Hold the charge amount for newly-enabled facilities before anything is persisted ── + final BigDecimal holdNeeded = totalChargeAmountFor(newlyEnabledFacilityTypes); + if (holdNeeded.compareTo(BigDecimal.ZERO) > 0) { + final BigDecimal currentBalance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() + : BigDecimal.ZERO; + final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO; + final BigDecimal availableBalance = currentBalance.subtract(onHold); + if (availableBalance.compareTo(holdNeeded) < 0) { + throw new CurrentAccountEditInsufficientBalanceException(currentAccountId, availableBalance, holdNeeded); + } + + final JsonObject holdJson = new JsonObject(); + holdJson.addProperty("locale", "en"); + holdJson.addProperty("dateFormat", "yyyy-MM-dd"); + holdJson.addProperty("transactionDate", DateUtils.getBusinessLocalDate().toString()); + holdJson.addProperty("transactionAmount", holdNeeded); + holdJson.addProperty("reasonForBlock", "Pending approval of Nominee/Digital-Feature edit"); + final JsonCommand holdCommand = JsonCommand.fromJsonElement(currentAccountId, holdJson, this.fromApiJsonHelper); + + final CommandProcessingResult holdResult = this.currentAccountTransactionWritePlatformService.holdAmount(currentAccountId, + holdCommand); + pendingEdit.setHoldTransactionId(holdResult.getResourceId()); + pendingEdit.setHoldAmount(holdNeeded); + } + + final CommandSource auditEntry = CommandSource.builder() // + .actionName("SUBMITEDIT") // + .entityName("CURRENTACCOUNT") // + .resourceGetUrl("/currentaccounts/" + currentAccountId) // + .resourceId(currentAccountId) // + .commandAsJson(command.json()) // + .maker(maker) // + .madeOnDate(DateUtils.getAuditOffsetDateTime()) // + .status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) // + .idempotencyKey(UUID.randomUUID().toString()) // + .sanitized(false) // + .build(); + this.commandSourceRepository.saveAndFlush(auditEntry); + pendingEdit.setCommandSourceId(auditEntry.getId()); + + this.pendingEditRepository.saveAndFlush(pendingEdit); + + return new CommandProcessingResultBuilder().withEntityId(currentAccountId).build(); + } + + @Transactional + @Override + public CommandProcessingResult approveEdit(final Long currentAccountId) { + + final AppUser checker = this.context.authenticatedUser(); + final CurrentAccountPendingEdit pendingEdit = this.pendingEditRepository + .findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING) + .orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId)); + + if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId); + } + + final CurrentAccount account = this.currentAccountRepository.findById(currentAccountId) + .orElseThrow(() -> new CurrentAccountNotFoundException(currentAccountId)); + final CurrentAccountFacilityConfig currentConfig = account.getFacilityConfig(); + final Set newlyEnabledFacilityTypes = pendingEdit.isFacilityConfigSubmitted() + ? computeNewlyEnabled(pendingEdit, currentConfig) + : Set.of(); + + // 1) Release the hold placed at submission time. + if (pendingEdit.getHoldTransactionId() != null) { + this.currentAccountTransactionWritePlatformService.releaseAmount(currentAccountId, pendingEdit.getHoldTransactionId()); + } + + // 2) Post the real charge(s) for newly-enabled facilities, today. + if (!newlyEnabledFacilityTypes.isEmpty()) { + this.currentAccountWritePlatformService.applyAndCollectFacilityCharges(currentAccountId, newlyEnabledFacilityTypes); + } + + // 3) Apply the facility-config toggles to the real row. + if (pendingEdit.isFacilityConfigSubmitted()) { + CurrentAccountFacilityConfig config = account.getFacilityConfig(); + if (config == null) { + config = CurrentAccountFacilityConfig.createFor(account); + account.setFacilityConfig(config); + } + config.updateFrom(buildFacilityConfigJson(pendingEdit)); + this.facilityConfigRepository.saveAndFlush(config); + } + + // 4) Apply the nominee change to the real table. + if (pendingEdit.isNomineeSubmitted()) { + applyNomineeChange(account, pendingEdit); + } + + this.currentAccountRepository.saveAndFlush(account); + + pendingEdit.markApproved(checker); + this.pendingEditRepository.saveAndFlush(pendingEdit); + + updateAuditEntry(pendingEdit, checker, false); + + return new CommandProcessingResultBuilder().withEntityId(currentAccountId).build(); + } + + @Transactional + @Override + public CommandProcessingResult rejectEdit(final Long currentAccountId, final String rejectReason) { + + final AppUser checker = this.context.authenticatedUser(); + final CurrentAccountPendingEdit pendingEdit = this.pendingEditRepository + .findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING) + .orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId)); + + if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId); + } + + if (pendingEdit.getHoldTransactionId() != null) { + this.currentAccountTransactionWritePlatformService.releaseAmount(currentAccountId, pendingEdit.getHoldTransactionId()); + } + + pendingEdit.markRejected(checker, rejectReason); + this.pendingEditRepository.saveAndFlush(pendingEdit); + + updateAuditEntry(pendingEdit, checker, true); + + return new CommandProcessingResultBuilder().withEntityId(currentAccountId).build(); + } + + @Override + public CurrentAccountPendingEditData retrievePendingEdit(final Long currentAccountId) { + return this.pendingEditRepository.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING) + .map(this::toData).orElse(null); + } + + @Override + public java.util.Collection retrieveAllPendingEdits() { + return this.pendingEditRepository.findByStatus(CurrentAccountPendingEdit.STATUS_PENDING).stream().map(this::toData).toList(); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + /** + * Sum of the mapped charge amounts for the given facility types - used to size the hold placed at + * edit-submission time, before any charge is actually applied. + */ + private BigDecimal totalChargeAmountFor(final Set facilityTypes) { + BigDecimal total = BigDecimal.ZERO; + for (final String facilityType : facilityTypes) { + final var mapping = this.facilityChargeMappingRepository.findByFacilityType(facilityType); + if (mapping.isPresent() && mapping.get().getCharge() != null) { + total = total.add(mapping.get().getCharge().getAmount()); + } + } + return total; + } + + private void applyNomineeChange(final CurrentAccount account, final CurrentAccountPendingEdit pendingEdit) { + final Long clientId = account.getClient() != null ? account.getClient().getId() : null; + final String operation = pendingEdit.getNomineeOperation(); + + if (CurrentAccountPendingEdit.NOMINEE_OP_DELETE.equals(operation)) { + this.clientNomineeWritePlatformService.deleteNominee(clientId, pendingEdit.getNomineeId()); + return; + } + + final JsonObject nomineeJson = new JsonObject(); + nomineeJson.addProperty("locale", "en"); + nomineeJson.addProperty("dateFormat", "yyyy-MM-dd"); + nomineeJson.addProperty("accountNo", account.getAccountNo()); + nomineeJson.addProperty("accountType", "CB"); + if (pendingEdit.getNomineeClientId() != null) { + nomineeJson.addProperty("nomineeClientId", pendingEdit.getNomineeClientId()); + } + if (pendingEdit.getRelationshipCvId() != null) { + nomineeJson.addProperty("relationshipId", pendingEdit.getRelationshipCvId()); + } + if (pendingEdit.getPercentageShare() != null) { + nomineeJson.addProperty("percentageShare", pendingEdit.getPercentageShare()); + } + if (pendingEdit.getNomineeIsActive() != null) { + nomineeJson.addProperty("isActive", pendingEdit.getNomineeIsActive()); + } + if (pendingEdit.getNomineeIsMinor() != null) { + nomineeJson.addProperty("nomineeIsMinor", pendingEdit.getNomineeIsMinor()); + } + if (pendingEdit.getGuardianClientId() != null) { + nomineeJson.addProperty("guardianClientId", pendingEdit.getGuardianClientId()); + } + if (pendingEdit.getGuardianRelationshipCvId() != null) { + nomineeJson.addProperty("guardianRelationshipId", pendingEdit.getGuardianRelationshipCvId()); + } + if (pendingEdit.getEffectiveDate() != null) { + nomineeJson.addProperty("effectiveDate", pendingEdit.getEffectiveDate().toString()); + } + if (pendingEdit.getNomineeRemarks() != null) { + nomineeJson.addProperty("remarks", pendingEdit.getNomineeRemarks()); + } + final JsonCommand nomineeCommand = JsonCommand.fromJsonElement(clientId, nomineeJson, this.fromApiJsonHelper); + + if (CurrentAccountPendingEdit.NOMINEE_OP_ADD.equals(operation)) { + this.clientNomineeWritePlatformService.addNominee(clientId, nomineeCommand); + } else if (CurrentAccountPendingEdit.NOMINEE_OP_UPDATE.equals(operation)) { + this.clientNomineeWritePlatformService.updateNominee(clientId, pendingEdit.getNomineeId(), nomineeCommand); + } + } + + private JsonObject buildFacilityConfigJson(final CurrentAccountPendingEdit p) { + final JsonObject fc = new JsonObject(); + putIfPresent(fc, "atmFacility", p.getAtmFacility()); + putIfPresent(fc, "atmWithdrawalFacility", p.getAtmWithdrawalFacility()); + putIfPresent(fc, "upiFacility", p.getUpiFacility()); + putIfPresent(fc, "posTransactionsFacility", p.getPosTransactionsFacility()); + putIfPresent(fc, "chequeBook", p.getChequeBook()); + putIfPresent(fc, "impsFacility", p.getImpsFacility()); + putIfPresent(fc, "smsAlerts", p.getSmsAlerts()); + putIfPresent(fc, "bbpsFacility", p.getBbpsFacility()); + putIfPresent(fc, "insuranceCover", p.getInsuranceCover()); + putIfPresent(fc, "bepgFacility", p.getBepgFacility()); + putIfPresent(fc, "neftFacility", p.getNeftFacility()); + putIfPresent(fc, "rtgsFacility", p.getRtgsFacility()); + putIfPresent(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility()); + putIfPresent(fc, "withdrawalFacility", p.getWithdrawalFacility()); + putIfPresent(fc, "depositFacilityKyc", p.getDepositFacilityKyc()); + putIfPresent(fc, "virtualDebitCard", p.getVirtualDebitCard()); + putIfPresent(fc, "internetBanking", p.getInternetBanking()); + putIfPresent(fc, "mobileBanking", p.getMobileBanking()); + return fc; + } + + private void putIfPresent(final JsonObject json, final String field, final Boolean value) { + if (value != null) { + json.addProperty(field, value); + } + } + + private Set computeNewlyEnabled(final CurrentAccountPendingEdit p, final CurrentAccountFacilityConfig current) { + final Set result = new HashSet<>(); + addIfNewlyEnabled(result, "atmFacility", p.getAtmFacility(), current != null && current.isAtmFacility()); + addIfNewlyEnabled(result, "atmWithdrawalFacility", p.getAtmWithdrawalFacility(), current != null && current.isAtmWithdrawalFacility()); + addIfNewlyEnabled(result, "upiFacility", p.getUpiFacility(), current != null && current.isUpiFacility()); + addIfNewlyEnabled(result, "posTransactionsFacility", p.getPosTransactionsFacility(), + current != null && current.isPosTransactionsFacility()); + addIfNewlyEnabled(result, "chequeBook", p.getChequeBook(), current != null && current.isChequeBook()); + addIfNewlyEnabled(result, "impsFacility", p.getImpsFacility(), current != null && current.isImpsFacility()); + addIfNewlyEnabled(result, "smsAlerts", p.getSmsAlerts(), current != null && current.isSmsAlerts()); + addIfNewlyEnabled(result, "bbpsFacility", p.getBbpsFacility(), current != null && current.isBbpsFacility()); + addIfNewlyEnabled(result, "insuranceCover", p.getInsuranceCover(), current != null && current.isInsuranceCover()); + addIfNewlyEnabled(result, "bepgFacility", p.getBepgFacility(), current != null && current.isBepgFacility()); + addIfNewlyEnabled(result, "neftFacility", p.getNeftFacility(), current != null && current.isNeftFacility()); + addIfNewlyEnabled(result, "rtgsFacility", p.getRtgsFacility(), current != null && current.isRtgsFacility()); + addIfNewlyEnabled(result, "interBankTotalCapFacility", p.getInterBankTotalCapFacility(), + current != null && current.isInterBankTotalCapFacility()); + addIfNewlyEnabled(result, "withdrawalFacility", p.getWithdrawalFacility(), current != null && current.isWithdrawalFacility()); + addIfNewlyEnabled(result, "depositFacilityKyc", p.getDepositFacilityKyc(), current != null && current.isDepositFacilityKyc()); + addIfNewlyEnabled(result, "virtualDebitCard", p.getVirtualDebitCard(), current != null && current.isVirtualDebitCard()); + addIfNewlyEnabled(result, "internetBanking", p.getInternetBanking(), current != null && current.isInternetBanking()); + addIfNewlyEnabled(result, "mobileBanking", p.getMobileBanking(), current != null && current.isMobileBanking()); + return result; + } + + private void addIfNewlyEnabled(final Set result, final String field, final Boolean proposed, final boolean current) { + if (Boolean.TRUE.equals(proposed) && !current) { + result.add(FACILITY_TYPES.get(field)); + } + } + + /** Reads a single submitted field out of the incoming JSON, tracks whether it enables a previously-off facility. */ + private void populateFacilityField(final JsonObject fc, final String field, final boolean currentValue, + final java.util.function.Consumer setter, final Set newlyEnabledCollector) { + if (!fc.has(field) || fc.get(field).isJsonNull()) { + return; + } + final boolean proposed = fc.get(field).getAsBoolean(); + setter.accept(proposed); + if (proposed && !currentValue) { + newlyEnabledCollector.add(FACILITY_TYPES.get(field)); + } + } + + private void updateAuditEntry(final CurrentAccountPendingEdit pendingEdit, final AppUser checker, final boolean rejected) { + Optional existing = Optional.empty(); + if (pendingEdit.getCommandSourceId() != null) { + existing = this.commandSourceRepository.findById(pendingEdit.getCommandSourceId()); + } + final CommandSource auditEntry = existing.orElseGet(() -> CommandSource.builder() // + .actionName(rejected ? "REJECTEDIT" : "APPROVEEDIT") // + .entityName("CURRENTACCOUNT") // + .resourceGetUrl("/currentaccounts/" + pendingEdit.getCurrentAccount().getId()) // + .resourceId(pendingEdit.getCurrentAccount().getId()) // + .maker(pendingEdit.getMaker()) // + .madeOnDate(DateUtils.getAuditOffsetDateTime()) // + .status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) // + .idempotencyKey(UUID.randomUUID().toString()) // + .sanitized(false) // + .build()); + if (rejected) { + auditEntry.markAsRejected(checker); + } else { + auditEntry.markAsChecked(checker); + } + this.commandSourceRepository.saveAndFlush(auditEntry); + } + + private CurrentAccountPendingEditData toData(final CurrentAccountPendingEdit p) { + final CurrentAccountPendingEditData data = new CurrentAccountPendingEditData(); + data.setId(p.getId()); + data.setCurrentAccountId(p.getCurrentAccount().getId()); + data.setAccountNo(p.getCurrentAccount().getAccountNo()); + data.setMakerId(p.getMaker().getId()); + data.setMakerUsername(p.getMaker().getUsername()); + if (p.getChecker() != null) { + data.setCheckerId(p.getChecker().getId()); + data.setCheckerUsername(p.getChecker().getUsername()); + } + data.setCreatedOn(p.getCreatedOn()); + data.setCheckedOn(p.getCheckedOn()); + data.setStatus(p.getStatus()); + data.setRejectReason(p.getRejectReason()); + data.setHoldAmount(p.getHoldAmount()); + data.setFacilityConfigSubmitted(p.isFacilityConfigSubmitted()); + data.setNomineeSubmitted(p.isNomineeSubmitted()); + + if (p.isFacilityConfigSubmitted()) { + final Map fc = new LinkedHashMap<>(); + putIfNotNull(fc, "atmFacility", p.getAtmFacility()); + putIfNotNull(fc, "atmWithdrawalFacility", p.getAtmWithdrawalFacility()); + putIfNotNull(fc, "upiFacility", p.getUpiFacility()); + putIfNotNull(fc, "posTransactionsFacility", p.getPosTransactionsFacility()); + putIfNotNull(fc, "chequeBook", p.getChequeBook()); + putIfNotNull(fc, "impsFacility", p.getImpsFacility()); + putIfNotNull(fc, "smsAlerts", p.getSmsAlerts()); + putIfNotNull(fc, "bbpsFacility", p.getBbpsFacility()); + putIfNotNull(fc, "insuranceCover", p.getInsuranceCover()); + putIfNotNull(fc, "bepgFacility", p.getBepgFacility()); + putIfNotNull(fc, "neftFacility", p.getNeftFacility()); + putIfNotNull(fc, "rtgsFacility", p.getRtgsFacility()); + putIfNotNull(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility()); + putIfNotNull(fc, "withdrawalFacility", p.getWithdrawalFacility()); + putIfNotNull(fc, "depositFacilityKyc", p.getDepositFacilityKyc()); + putIfNotNull(fc, "virtualDebitCard", p.getVirtualDebitCard()); + putIfNotNull(fc, "internetBanking", p.getInternetBanking()); + putIfNotNull(fc, "mobileBanking", p.getMobileBanking()); + data.setProposedFacilityConfig(fc); + } + + if (p.isNomineeSubmitted()) { + final Map nom = new LinkedHashMap<>(); + putIfNotNull(nom, "operation", p.getNomineeOperation()); + putIfNotNull(nom, "nomineeId", p.getNomineeId()); + putIfNotNull(nom, "nomineeClientId", p.getNomineeClientId()); + putIfNotNull(nom, "relationshipId", p.getRelationshipCvId()); + putIfNotNull(nom, "percentageShare", p.getPercentageShare()); + putIfNotNull(nom, "isActive", p.getNomineeIsActive()); + putIfNotNull(nom, "nomineeIsMinor", p.getNomineeIsMinor()); + putIfNotNull(nom, "guardianClientId", p.getGuardianClientId()); + putIfNotNull(nom, "guardianRelationshipId", p.getGuardianRelationshipCvId()); + putIfNotNull(nom, "effectiveDate", p.getEffectiveDate()); + putIfNotNull(nom, "remarks", p.getNomineeRemarks()); + data.setProposedNominee(nom); + } + + return data; + } + + private void putIfNotNull(final Map map, final String key, final Object value) { + if (value != null) { + map.put(key, value); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java index de44fac30..bf6d014e8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java @@ -70,9 +70,37 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead response.put("partners", retrievePartners(accountId)); response.put("charges", retrieveCharges(accountId)); response.put("transactions", retrieveTransactions(accountId)); + response.put("accountFacilityConfig", retrieveFacilityConfig(accountId)); return response; } + private Map retrieveFacilityConfig(Long accountId) { + final String sql = "SELECT * FROM m_current_account_facility_config WHERE current_account_id = ?"; + List> rows = jdbcTemplate.query(sql, (rs, rowNum) -> { + final Map cfg = new LinkedHashMap<>(); + cfg.put("atmFacility", rs.getBoolean("atm_facility")); + cfg.put("atmWithdrawalFacility", rs.getBoolean("atm_withdrawal_facility")); + cfg.put("upiFacility", rs.getBoolean("upi_facility")); + cfg.put("posTransactionsFacility", rs.getBoolean("pos_transactions_facility")); + cfg.put("chequeBook", rs.getBoolean("cheque_book")); + cfg.put("impsFacility", rs.getBoolean("imps_facility")); + cfg.put("smsAlerts", rs.getBoolean("sms_alerts")); + cfg.put("bbpsFacility", rs.getBoolean("bbps_facility")); + cfg.put("insuranceCover", rs.getBoolean("insurance_cover")); + cfg.put("bepgFacility", rs.getBoolean("bepg_facility")); + cfg.put("neftFacility", rs.getBoolean("neft_facility")); + cfg.put("rtgsFacility", rs.getBoolean("rtgs_facility")); + cfg.put("interBankTotalCapFacility", rs.getBoolean("inter_bank_total_cap_facility")); + cfg.put("withdrawalFacility", rs.getBoolean("withdrawal_facility")); + cfg.put("depositFacilityKyc", rs.getBoolean("deposit_facility_kyc")); + cfg.put("virtualDebitCard", rs.getBoolean("virtual_debit_card")); + cfg.put("internetBanking", rs.getBoolean("internet_banking")); + cfg.put("mobileBanking", rs.getBoolean("mobile_banking")); + return cfg; + }, accountId); + return rows.isEmpty() ? null : rows.get(0); + } + private List> retrieveJointHolders(Long accountId) { final String sql = "SELECT jh.id, jh.client_id, jh.cif_number, jh.display_name, " @@ -607,8 +635,18 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead private Map buildSummary(Map row) { Map s = new LinkedHashMap<>(); - s.put("accountBalance", row.get("account_balance_derived")); - s.put("availableBalance", row.get("account_balance_derived")); + BigDecimal balance = toBigDecimal(row.get("account_balance_derived")); + if (balance == null) { + balance = BigDecimal.ZERO; + } + BigDecimal onHold = toBigDecimal(row.get("on_hold_funds_derived")); + if (onHold == null) { + onHold = BigDecimal.ZERO; + } + BigDecimal available = balance.subtract(onHold); + + s.put("accountBalance", balance); + s.put("availableBalance", available); s.put("totalDeposits", row.get("total_deposits_derived")); s.put("totalWithdrawals", row.get("total_withdrawals_derived")); s.put("totalInterestEarned", row.get("total_interest_earned_derived")); @@ -619,7 +657,7 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead s.put("totalWithdrawalFees", null); s.put("totalAnnualFees", null); s.put("lastInterestCalculationDate", row.get("last_interest_calculation_date")); - s.put("onHoldFunds", row.get("on_hold_funds_derived")); + s.put("onHoldFunds", onHold); return s; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountTransactionWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountTransactionWritePlatformServiceImpl.java index 8b5539d5e..ef3d5c60b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountTransactionWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountTransactionWritePlatformServiceImpl.java @@ -46,6 +46,7 @@ import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductReposit import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Slf4j @Service @@ -72,7 +73,7 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren final Office office = account.getOffice(); LocalDate txnDate = command.localDateValueOfParameterNamed("transactionDate"); - if (txnDate == null) txnDate = LocalDate.now(); + if (txnDate == null) txnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); final BigDecimal amount = command.bigDecimalValueOfParameterNamed("transactionAmount"); validatePositiveAmount(amount, accountId, "deposit"); @@ -118,7 +119,7 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren final Office office = account.getOffice(); LocalDate txnDate = command.localDateValueOfParameterNamed("transactionDate"); - if (txnDate == null) txnDate = LocalDate.now(); + if (txnDate == null) txnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); final BigDecimal amount = command.bigDecimalValueOfParameterNamed("transactionAmount"); validatePositiveAmount(amount, accountId, "withdrawal"); @@ -186,7 +187,7 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren final Office office = account.getOffice(); LocalDate txnDate = command.localDateValueOfParameterNamed("transactionDate"); - if (txnDate == null) txnDate = LocalDate.now(); + if (txnDate == null) txnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); final BigDecimal amount = command.bigDecimalValueOfParameterNamed("transactionAmount"); validatePositiveAmount(amount, accountId, "hold amount"); @@ -250,7 +251,7 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren // Create release transaction final CurrentAccountTransaction releaseTxn = CurrentAccountTransaction.releaseAmount( - account, office, holdAmount, LocalDate.now(), transactionId); + account, office, holdAmount, LocalDate.now(DateUtils.getDateTimeZoneOfTenant()), transactionId); releaseTxn.setRunningBalanceDerived(currentBalance); currentAccountTransactionRepository.saveAndFlush(releaseTxn); @@ -328,7 +329,7 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren // Create a reversal entry final CurrentAccountTransaction reversalTxn = CurrentAccountTransaction.reversal( - account, account.getOffice(), original.getAmount(), LocalDate.now(), transactionId); + account, account.getOffice(), original.getAmount(), LocalDate.now(DateUtils.getDateTimeZoneOfTenant()), transactionId); reversalTxn.setRunningBalanceDerived(safeBalance(account.getAccountBalanceDerived())); currentAccountTransactionRepository.saveAndFlush(reversalTxn); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java index 873bf3962..e4ec06386 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java @@ -21,7 +21,6 @@ package org.apache.fineract.portfolio.savings.service; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; -import com.google.gson.JsonParser; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import lombok.RequiredArgsConstructor; @@ -30,6 +29,7 @@ import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.organisation.office.domain.Office; import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper; @@ -105,7 +105,7 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri final String locale = getString(json, "locale"); LocalDate submittedOnDate = parseDate(getString(json, "submittedOnDate"), dateFormat, locale); - if (submittedOnDate == null) submittedOnDate = LocalDate.now(); + if (submittedOnDate == null) submittedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); // accountType (Fineract enum: 1=individual, 2=group) — try int, else default 1 Integer accountTypeEnum = 1; @@ -121,11 +121,11 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri } } - boolean msme = json.has("msme") && !json.get("msme").isJsonNull() + boolean msme = (json.has("msme") && !json.get("msme").isJsonNull()) ? json.get("msme").getAsBoolean() : product.isMsme(); - boolean autoDebitFacility = json.has("autoDebitFacility") && !json.get("autoDebitFacility").isJsonNull() + boolean autoDebitFacility = (json.has("autoDebitFacility") && !json.get("autoDebitFacility").isJsonNull()) ? json.get("autoDebitFacility").getAsBoolean() : product.isAutoDebitFacility(); - boolean balanceSwapOption = json.has("balanceSwapOption") && !json.get("balanceSwapOption").isJsonNull() + boolean balanceSwapOption = (json.has("balanceSwapOption") && !json.get("balanceSwapOption").isJsonNull()) ? json.get("balanceSwapOption").getAsBoolean() : product.isBalanceSwapOption(); final AppUser user = context.authenticatedUser(); @@ -143,7 +143,7 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri product.getAvgBalanceCalcPeriod()); account.setSubmittedOnByUser(user); - boolean shareLinkingEnabled = json.has("shareLinkingEnabled") && !json.get("shareLinkingEnabled").isJsonNull() + boolean shareLinkingEnabled = (json.has("shareLinkingEnabled") && !json.get("shareLinkingEnabled").isJsonNull()) ? json.get("shareLinkingEnabled").getAsBoolean() : product.isShareLinkingEnabled(); account.setShareLinkingEnabled(shareLinkingEnabled); @@ -268,7 +268,7 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri final CurrentAccount account = findAccount(accountId); final JsonObject json = command.parsedJson().getAsJsonObject(); LocalDate approvedOnDate = parseDate(getString(json, "approvedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); - if (approvedOnDate == null) approvedOnDate = LocalDate.now(); + if (approvedOnDate == null) approvedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); account.approve(approvedOnDate, user); currentAccountRepository.saveAndFlush(account); return new CommandProcessingResultBuilder().withEntityId(accountId).build(); @@ -293,11 +293,13 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri final CurrentAccount account = findAccount(accountId); final JsonObject json = command.parsedJson().getAsJsonObject(); LocalDate activatedOnDate = parseDate(getString(json, "activatedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); - if (activatedOnDate == null) activatedOnDate = LocalDate.now(); + if (activatedOnDate == null) activatedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); // Validate minimum opening balance before activation java.math.BigDecimal openingBalanceValue = java.math.BigDecimal.ZERO; if (json.has("openingBalance") && !json.get("openingBalance").isJsonNull()) { - try { openingBalanceValue = json.get("openingBalance").getAsBigDecimal(); } catch (Exception ignored) {} + try { openingBalanceValue = json.get("openingBalance").getAsBigDecimal(); } catch (Exception ignored) { + // intentionally ignored + } } final java.math.BigDecimal minRequired = account.getMinRequiredOpeningBalance(); if (minRequired != null && minRequired.compareTo(java.math.BigDecimal.ZERO) > 0 @@ -335,25 +337,11 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri } // Auto-deduct activation charges (chargeTimeEnum=3) from opening balance - for (final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge charge : account.getCharges()) { - if (charge.getChargeTimeEnum() != null && charge.getChargeTimeEnum() == 3 - && charge.getAmount() != null - && charge.getAmount().compareTo(java.math.BigDecimal.ZERO) > 0 - && charge.getCharge() != null) { - charge.setAmountPaidDerived(charge.getAmount()); - charge.setAmountOutstandingDerived(java.math.BigDecimal.ZERO); - charge.setPaid(true); - currentBalance = currentBalance.subtract(charge.getAmount()); - final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction chargeTxn = - org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction - .chargeDeduction(account, account.getOffice(), charge.getAmount(), activatedOnDate); - chargeTxn.setRunningBalanceDerived(currentBalance); - currentAccountTransactionRepository.saveAndFlush(chargeTxn); - chargeEntries.add(new ChargeGlEntry(chargeTxn, charge)); - log.info("Auto-deducted activation charge '{}' {} from account id={}, running balance={}", - charge.getCharge().getName(), charge.getAmount(), accountId, currentBalance); - } - } + final java.util.List activationCharges = + account.getCharges().stream() + .filter(c -> c.getChargeTimeEnum() != null && c.getChargeTimeEnum() == 3 && !c.isPaid()) + .toList(); + currentBalance = collectChargesAndPost(account, activationCharges, currentBalance, activatedOnDate, chargeEntries); account.setAccountBalanceDerived(currentBalance); currentAccountRepository.saveAndFlush(account); @@ -371,7 +359,7 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri final CurrentAccount account = findAccount(accountId); final JsonObject json = command.parsedJson().getAsJsonObject(); LocalDate rejectedOnDate = parseDate(getString(json, "rejectedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); - if (rejectedOnDate == null) rejectedOnDate = LocalDate.now(); + if (rejectedOnDate == null) rejectedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); account.reject(rejectedOnDate, user); currentAccountRepository.saveAndFlush(account); return new CommandProcessingResultBuilder().withEntityId(accountId).build(); @@ -384,7 +372,7 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri final CurrentAccount account = findAccount(accountId); final JsonObject json = command.parsedJson().getAsJsonObject(); LocalDate withdrawnOnDate = parseDate(getString(json, "withdrawnOnDate"), getString(json, "dateFormat"), getString(json, "locale")); - if (withdrawnOnDate == null) withdrawnOnDate = LocalDate.now(); + if (withdrawnOnDate == null) withdrawnOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); account.withdraw(withdrawnOnDate, user); currentAccountRepository.saveAndFlush(account); return new CommandProcessingResultBuilder().withEntityId(accountId).build(); @@ -397,7 +385,7 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri final CurrentAccount account = findAccount(accountId); final JsonObject json = command.parsedJson().getAsJsonObject(); LocalDate closedOnDate = parseDate(getString(json, "closedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); - if (closedOnDate == null) closedOnDate = LocalDate.now(); + if (closedOnDate == null) closedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); account.close(closedOnDate, user); currentAccountRepository.saveAndFlush(account); return new CommandProcessingResultBuilder().withEntityId(accountId).build(); @@ -462,13 +450,17 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri try { account.setEstablishmentDate(LocalDate.parse(json.get("establishmentDate").getAsString(), DateTimeFormatter.ofPattern(dateFormat))); - } catch (Exception ignored) {} + } catch (Exception ignored) { + // intentionally ignored + } } if (json.has("startOperationDate") && !json.get("startOperationDate").isJsonNull()) { try { account.setStartOperationDate(LocalDate.parse(json.get("startOperationDate").getAsString(), DateTimeFormatter.ofPattern(dateFormat))); - } catch (Exception ignored) {} + } catch (Exception ignored) { + // intentionally ignored + } } // facility config @@ -579,7 +571,9 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri if (jo.has("signingAuthority") && !jo.get("signingAuthority").isJsonNull()) holder.setSigningAuthority(jo.get("signingAuthority").getAsString()); if (jo.has("role") && !jo.get("role").isJsonNull()) holder.setRole(jo.get("role").getAsString()); if (jo.has("sharePercentage") && !jo.get("sharePercentage").isJsonNull()) { - try { holder.setSharePercentage(jo.get("sharePercentage").getAsBigDecimal()); } catch (Exception ignored) {} + try { holder.setSharePercentage(jo.get("sharePercentage").getAsBigDecimal()); } catch (Exception ignored) { + // intentionally ignored + } } if (jo.has("isPrimary") && !jo.get("isPrimary").isJsonNull()) holder.setPrimary(jo.get("isPrimary").getAsBoolean()); if (jo.has("isMandatory") && !jo.get("isMandatory").isJsonNull()) holder.setMandatory(jo.get("isMandatory").getAsBoolean()); @@ -597,7 +591,9 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri if (jo.has("clientCif") && !jo.get("clientCif").isJsonNull()) partner.setClientCif(jo.get("clientCif").getAsString()); if (jo.has("clientName") && !jo.get("clientName").isJsonNull()) partner.setClientName(jo.get("clientName").getAsString()); if (jo.has("sharePercentage") && !jo.get("sharePercentage").isJsonNull()) { - try { partner.setSharePercentage(jo.get("sharePercentage").getAsBigDecimal()); } catch (Exception ignored) {} + try { partner.setSharePercentage(jo.get("sharePercentage").getAsBigDecimal()); } catch (Exception ignored) { + // intentionally ignored + } } if (jo.has("isMandatory") && !jo.get("isMandatory").isJsonNull()) partner.setMandatory(jo.get("isMandatory").getAsBoolean()); account.getPartners().add(partner); @@ -718,6 +714,78 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn, org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge charge) {} + /** + * Marks each of the given (unpaid) charges as paid, deducts its amount from {@code runningBalance}, creates a + * {@code CurrentAccountTransaction.chargeDeduction(...)} row for it, and records a {@link ChargeGlEntry} for GL + * posting. Shared by {@link #activate(Long, JsonCommand)} (activation-time charges) and + * {@link #applyAndCollectFacilityCharges(Long, java.util.Set)} (edit-approval-time charges) so the + * charge-collection logic isn't duplicated. Returns the resulting balance. + */ + private java.math.BigDecimal collectChargesAndPost(final CurrentAccount account, + final java.util.List charges, + java.math.BigDecimal runningBalance, final LocalDate transactionDate, + final java.util.List chargeEntriesOut) { + for (final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge charge : charges) { + if (charge.getAmount() == null || charge.getAmount().compareTo(java.math.BigDecimal.ZERO) <= 0 || charge.getCharge() == null) { + continue; + } + charge.setAmountPaidDerived(charge.getAmount()); + charge.setAmountOutstandingDerived(java.math.BigDecimal.ZERO); + charge.setPaid(true); + runningBalance = runningBalance.subtract(charge.getAmount()); + final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction chargeTxn = + org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction + .chargeDeduction(account, account.getOffice(), charge.getAmount(), transactionDate); + chargeTxn.setRunningBalanceDerived(runningBalance); + currentAccountTransactionRepository.saveAndFlush(chargeTxn); + chargeEntriesOut.add(new ChargeGlEntry(chargeTxn, charge)); + log.info("[FacilityCharge] Collected charge '{}' {} from account id={}, running balance={}", + charge.getCharge().getName(), charge.getAmount(), account.getId(), runningBalance); + } + return runningBalance; + } + + @Override + @Transactional + public void applyAndCollectFacilityCharges(final Long currentAccountId, final java.util.Set newlyEnabledFacilityTypes) { + if (newlyEnabledFacilityTypes == null || newlyEnabledFacilityTypes.isEmpty()) { + return; + } + final CurrentAccount account = findAccount(currentAccountId); + final java.util.List newCharges = new java.util.ArrayList<>(); + + for (final String facilityType : newlyEnabledFacilityTypes) { + final var mappingOpt = facilityChargeMappingRepository.findByFacilityType(facilityType); + if (mappingOpt.isEmpty()) { + log.warn("[FacilityCharge] {} newly-enabled via edit-approval but NO mapping found in m_facility_charge_mapping", + facilityType); + continue; + } + final org.apache.fineract.portfolio.charge.domain.Charge charge = mappingOpt.get().getCharge(); + if (charge == null) { + continue; + } + final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge newCharge = + org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge.createFromFacility(account, charge); + account.getCharges().add(newCharge); + newCharges.add(newCharge); + } + + if (newCharges.isEmpty()) { + return; + } + + java.math.BigDecimal currentBalance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() + : java.math.BigDecimal.ZERO; + final java.util.List chargeEntries = new java.util.ArrayList<>(); + currentBalance = collectChargesAndPost(account, newCharges, currentBalance, DateUtils.getBusinessLocalDate(), chargeEntries); + + account.setAccountBalanceDerived(currentBalance); + currentAccountRepository.saveAndFlush(account); + + postGlEntries(account, java.util.List.of(), chargeEntries); + } + // entity type for savings/current accounts in acc_gl_journal_entry private static final int ENTITY_TYPE_SAVINGS = 2; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java index bae584d90..9f8b28e2f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java @@ -92,10 +92,12 @@ import org.apache.fineract.portfolio.savings.data.SavingsAccountApplicationTimel import org.apache.fineract.portfolio.savings.data.SavingsAccountChargeData; import org.apache.fineract.portfolio.savings.data.SavingsAccountData; import org.apache.fineract.portfolio.savings.data.SavingsAccountStatusEnumData; +import org.apache.fineract.portfolio.savings.data.SavingsAccountSubStatusEnumData; import org.apache.fineract.portfolio.savings.data.SavingsAccountSummaryData; import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData; import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData; import org.apache.fineract.portfolio.savings.domain.SavingsAccountStatusType; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountSubStatusEnum; import org.apache.fineract.portfolio.savings.exception.DepositAccountNotFoundException; import org.apache.fineract.portfolio.tax.data.TaxGroupData; import org.apache.fineract.useradministration.domain.AppUser; @@ -847,6 +849,13 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead selectFieldsSqlBuilder.append("sa.senior_citizen_interest_rate as seniorCitizenInterestRate, "); selectFieldsSqlBuilder.append("sa.min_balance_for_interest_calculation as minBalanceForInterestCalculation, "); selectFieldsSqlBuilder.append("sa.withhold_tax as withHoldTax,"); + selectFieldsSqlBuilder.append("sa.sub_status_enum as subStatusEnum, "); + selectFieldsSqlBuilder.append("sa.reason_for_block as reasonForBlock, "); + selectFieldsSqlBuilder.append("(select COALESCE(max(sat.transaction_date),sa.activatedon_date) "); + selectFieldsSqlBuilder.append("from m_savings_account_transaction as sat "); + selectFieldsSqlBuilder.append("where sat.is_reversed = false and sat.is_reversal = false "); + selectFieldsSqlBuilder.append("and sat.transaction_type_enum in (1,2) "); + selectFieldsSqlBuilder.append("and sat.savings_account_id = sa.id) as lastActiveTransactionDate, "); selectFieldsSqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName "); this.selectFieldsSql = selectFieldsSqlBuilder.toString(); @@ -1129,12 +1138,48 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead preClosurePenalInterestOnType, minDepositTerm, maxDepositTerm, minDepositTermType, maxDepositTermType, inMultiplesOfDepositTerm, inMultiplesOfDepositTermType, depositAmount, maturityAmount, maturityDate, depositPeriod, depositPeriodFrequencyType, onAccountClosureType, transferInterestToSavings, transferToSavingsId); + + // Live per-account dormancy state: subStatus + the actual countdown, mirroring + // SavingsAccountReadPlatformServiceImpl.SavingAccountMapper. The isDormancyTrackingActive/ + // daysToInactive/daysToDormancy/daysToEscheat columns read below are the product's + // configured thresholds; they're used here only as inputs to compute the live countdown. + final Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum"); + final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum); + final String reasonForBlock = rs.getString("reasonForBlock"); + final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate"); + final boolean isDormancyTrackingActive = rs.getBoolean("isDormancyTrackingActive"); + final Integer numDaysToInactive = JdbcSupport.getInteger(rs, "daysToInactive"); + final Integer numDaysToDormancy = JdbcSupport.getInteger(rs, "daysToDormancy"); + final Integer numDaysToEscheat = JdbcSupport.getInteger(rs, "daysToEscheat"); + Integer computedDaysToInactive = null; + Integer computedDaysToDormancy = null; + Integer computedDaysToEscheat = null; + final Integer accountStatusEnum = JdbcSupport.getInteger(rs, "statusEnum"); + if (isDormancyTrackingActive && lastActiveTransactionDate != null && accountStatusEnum != null + && accountStatusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) { + final LocalDate currentDate = DateUtils.getBusinessLocalDate(); + if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) { + computedDaysToEscheat = DateUtils.getExactDifferenceInDays(currentDate, + lastActiveTransactionDate.plusDays(numDaysToEscheat)); + } + if (subStatusEnum < SavingsAccountSubStatusEnum.DORMANT.getValue()) { + computedDaysToDormancy = DateUtils.getExactDifferenceInDays(currentDate, + lastActiveTransactionDate.plusDays(numDaysToDormancy)); + } + if (subStatusEnum < SavingsAccountSubStatusEnum.INACTIVE.getValue()) { + computedDaysToInactive = DateUtils.getExactDifferenceInDays(currentDate, + lastActiveTransactionDate.plusDays(numDaysToInactive)); + } + } + result.setCbsProductFields(rs.getString("accountType"), rs.getString("customerSegment"), rs.getString("fdType"), rs.getString("productCode"), JdbcSupport.getLocalDate(rs, "effectiveDate"), JdbcSupport.getLocalDate(rs, "effectiveTo"), JdbcSupport.getLocalDate(rs, "endDate"), rs.getBoolean("introducerRequired"), rs.getBoolean("shareLinkingEnabled"), - rs.getBoolean("nominationFacility"), rs.getBoolean("dormancyAlert"), rs.getBoolean("isDormancyTrackingActive"), - JdbcSupport.getLong(rs, "daysToInactive"), JdbcSupport.getLong(rs, "daysToDormancy"), - JdbcSupport.getLong(rs, "daysToEscheat"), JdbcSupport.getInteger(rs, "interestCalculateSelector"), + rs.getBoolean("nominationFacility"), rs.getBoolean("dormancyAlert"), isDormancyTrackingActive, + computedDaysToInactive == null ? null : computedDaysToInactive.longValue(), + computedDaysToDormancy == null ? null : computedDaysToDormancy.longValue(), + computedDaysToEscheat == null ? null : computedDaysToEscheat.longValue(), + JdbcSupport.getInteger(rs, "interestCalculateSelector"), rs.getString("interestCalculationTypeName"), rs.getString("averagePeriod"), rs.getBoolean("autoPost"), JdbcSupport.getInteger(rs, "frequencyFixDay"), JdbcSupport.getLocalDate(rs, "lastInterestDate"), rs.getBoolean("enableLockinPeriod"), rs.getString("balanceFrequency"), @@ -1144,6 +1189,7 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead rs.getBoolean("creditMaturityProceeds"), rs.getBoolean("penaltyGracePeriodMissedInstallments"), rs.getBoolean("panelRequestApplicable"), rs.getBoolean("noPenaltyNomineeHeirClosure"), rs.getBoolean("deceasedSettlement"), rs.getBoolean("allowDebitTxnAfterDueDate")); + result.setDormancyStatus(subStatus, reasonForBlock, lastActiveTransactionDate); result.setCbsAccountFields(rs.getString("accountStatus"), rs.getString("modeOfOperation"), JdbcSupport.getLong(rs, "introducerClientId"), rs.getString("introducerName"), rs.getString("introducerCifNumber"), rs.getString("specialCategory"), rs.getString("weakerSection"), @@ -1293,12 +1339,45 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead onAccountClosureType, expectedFirstDepositOnDate, totalOverdueAmount, noOfOverdueInstallments, isMandatoryDeposit, allowWithdrawal, adjustAdvanceTowardsFuturePayments, isCalendarInherited); + // Live per-account dormancy state: subStatus + the actual countdown, mirroring + // SavingsAccountReadPlatformServiceImpl.SavingAccountMapper. The isDormancyTrackingActive/ + // daysToInactive/daysToDormancy/daysToEscheat columns read below are the product's + // configured thresholds; they're used here only as inputs to compute the live countdown. + final Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum"); + final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum); + final String reasonForBlock = rs.getString("reasonForBlock"); + final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate"); + final boolean isDormancyTrackingActive = rs.getBoolean("isDormancyTrackingActive"); + final Integer numDaysToInactive = JdbcSupport.getInteger(rs, "daysToInactive"); + final Integer numDaysToDormancy = JdbcSupport.getInteger(rs, "daysToDormancy"); + final Integer numDaysToEscheat = JdbcSupport.getInteger(rs, "daysToEscheat"); + Integer computedDaysToInactive = null; + Integer computedDaysToDormancy = null; + Integer computedDaysToEscheat = null; + final Integer accountStatusEnum = JdbcSupport.getInteger(rs, "statusEnum"); + if (isDormancyTrackingActive && lastActiveTransactionDate != null && accountStatusEnum != null + && accountStatusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) { + final LocalDate currentDate = DateUtils.getBusinessLocalDate(); + if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) { + computedDaysToEscheat = DateUtils.getExactDifferenceInDays(currentDate, + lastActiveTransactionDate.plusDays(numDaysToEscheat)); + } + if (subStatusEnum < SavingsAccountSubStatusEnum.DORMANT.getValue()) { + computedDaysToDormancy = DateUtils.getExactDifferenceInDays(currentDate, + lastActiveTransactionDate.plusDays(numDaysToDormancy)); + } + if (subStatusEnum < SavingsAccountSubStatusEnum.INACTIVE.getValue()) { + computedDaysToInactive = DateUtils.getExactDifferenceInDays(currentDate, + lastActiveTransactionDate.plusDays(numDaysToInactive)); + } + } + result.setCbsProductFields(rs.getString("accountType"), rs.getString("customerSegment"), rs.getString("productCode"), JdbcSupport.getLocalDate(rs, "effectiveDate"), JdbcSupport.getLocalDate(rs, "effectiveTo"), JdbcSupport.getLocalDate(rs, "endDate"), rs.getBoolean("introducerRequired"), rs.getBoolean("shareLinkingEnabled"), - rs.getBoolean("nominationFacility"), rs.getBoolean("dormancyAlert"), rs.getBoolean("isDormancyTrackingActive"), - JdbcSupport.getInteger(rs, "daysToInactive"), JdbcSupport.getInteger(rs, "daysToDormancy"), - JdbcSupport.getInteger(rs, "daysToEscheat"), JdbcSupport.getInteger(rs, "interestCalculateSelector"), + rs.getBoolean("nominationFacility"), rs.getBoolean("dormancyAlert"), isDormancyTrackingActive, + computedDaysToInactive, computedDaysToDormancy, + computedDaysToEscheat, JdbcSupport.getInteger(rs, "interestCalculateSelector"), rs.getString("interestCalculationTypeName"), rs.getString("averagePeriod"), rs.getBoolean("autoPost"), JdbcSupport.getInteger(rs, "frequencyFixDay"), JdbcSupport.getLocalDate(rs, "lastInterestDate"), rs.getBoolean("enableLockinPeriod"), rs.getString("balanceFrequency"), @@ -1308,6 +1387,7 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead rs.getBoolean("creditMaturityProceeds"), rs.getBoolean("penaltyGracePeriodMissedInstallments"), rs.getBoolean("panelRequestApplicable"), rs.getBoolean("noPenaltyNomineeHeirClosure"), rs.getBoolean("deceasedSettlement"), rs.getBoolean("allowDebitTxnAfterDueDate")); + result.setDormancyStatus(subStatus, reasonForBlock, lastActiveTransactionDate); result.setCbsAccountFields(rs.getString("accountStatus"), rs.getString("modeOfOperation"), JdbcSupport.getLong(rs, "introducerClientId"), rs.getString("introducerName"), rs.getString("introducerCifNumber"), rs.getString("specialCategory"), rs.getString("weakerSection"), diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java index 2be58645f..d431acb5b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java @@ -19,7 +19,6 @@ package org.apache.fineract.portfolio.savings.service; import com.google.gson.Gson; -import com.google.gson.reflect.TypeToken; import java.math.BigDecimal; import java.sql.ResultSet; import java.sql.SQLException; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateReadPlatformServiceImpl.java index baf53d972..516eb97c8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateReadPlatformServiceImpl.java @@ -23,6 +23,7 @@ import org.apache.fineract.portfolio.savings.domain.FdBaseInterestRateEffectiveR import org.apache.fineract.portfolio.savings.domain.FdBaseInterestRateSlabHistory; import org.apache.fineract.portfolio.savings.domain.FdBaseInterestRateSlabHistoryRepository; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; @RequiredArgsConstructor public class FdBaseInterestRateReadPlatformServiceImpl implements FdBaseInterestRateReadPlatformService { @@ -51,7 +52,7 @@ public class FdBaseInterestRateReadPlatformServiceImpl implements FdBaseInterest @Transactional public FdInterestRateEffectiveData saveInterestRates(FdInterestRateSaveRequest request, Long createdById) { final String fdType = request.getFdType(); - final LocalDate today = LocalDate.now(); + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); final List activeVersions = effectiveRepository .findByFdTypeAndIsActiveTrueOrderByEffectiveFromDateDesc(fdType); @@ -64,7 +65,7 @@ public class FdBaseInterestRateReadPlatformServiceImpl implements FdBaseInterest final String label = request.getVersionLabel() != null && !request.getVersionLabel().isBlank() ? request.getVersionLabel() - : "FD " + fdType + " Rate Version " + LocalDate.now(); + : "FD " + fdType + " Rate Version " + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); FdBaseInterestRateEffective newVersion = FdBaseInterestRateEffective.create(fdType, label, today, request.getRemarks(), createdById); newVersion = effectiveRepository.saveAndFlush(newVersion); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositAccountInterestCalculationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositAccountInterestCalculationServiceImpl.java index 54355aba7..061e1ce94 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositAccountInterestCalculationServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositAccountInterestCalculationServiceImpl.java @@ -62,7 +62,9 @@ public class FixedDepositAccountInterestCalculationServiceImpl implements FixedD if (dateStr != null && dateFormat != null) { submittedOnDate = LocalDate.parse(dateStr, java.time.format.DateTimeFormatter.ofPattern(dateFormat)); } - } catch (Exception ignored) {} + } catch (Exception ignored) { + // intentionally ignored + } Integer daysInYear = null; final Long daysInYearLong = this.fromApiJsonHelper.extractLongNamed("daysInYear", element); if (daysInYearLong != null) daysInYear = daysInYearLong.intValue(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RdBaseInterestRateReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RdBaseInterestRateReadPlatformServiceImpl.java index ea622e2d3..c2c25dcc6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RdBaseInterestRateReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RdBaseInterestRateReadPlatformServiceImpl.java @@ -29,6 +29,7 @@ import org.apache.fineract.portfolio.savings.domain.RdBaseInterestRateEffectiveR import org.apache.fineract.portfolio.savings.domain.RdBaseInterestRateSlabHistory; import org.apache.fineract.portfolio.savings.domain.RdBaseInterestRateSlabHistoryRepository; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; @RequiredArgsConstructor public class RdBaseInterestRateReadPlatformServiceImpl implements RdBaseInterestRateReadPlatformService { @@ -54,7 +55,7 @@ public class RdBaseInterestRateReadPlatformServiceImpl implements RdBaseInterest validateRequest(request); final List normalizedSlabs = normalizeSlabs(request.getSlabs()); - final LocalDate today = LocalDate.now(); + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); final List activeVersions = effectiveRepository.findByIsActiveTrueOrderByEffectiveFromDateDesc(); for (RdBaseInterestRateEffective version : activeVersions) { version.deactivate(today); @@ -65,9 +66,9 @@ public class RdBaseInterestRateReadPlatformServiceImpl implements RdBaseInterest final String label = request.getVersionLabel() != null && !request.getVersionLabel().isBlank() ? request.getVersionLabel() - : "CBS Base Rate Version " + LocalDate.now(); + : "CBS Base Rate Version " + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); - RdBaseInterestRateEffective newVersion = RdBaseInterestRateEffective.create(label, LocalDate.now(), request.getRemarks(), createdById); + RdBaseInterestRateEffective newVersion = RdBaseInterestRateEffective.create(label, LocalDate.now(DateUtils.getDateTimeZoneOfTenant()), request.getRemarks(), createdById); newVersion = effectiveRepository.saveAndFlush(newVersion); final Long versionId = newVersion.getId(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RdInterestRateReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RdInterestRateReadPlatformServiceImpl.java index 830ddb9f3..d6a19aad4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RdInterestRateReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RdInterestRateReadPlatformServiceImpl.java @@ -29,6 +29,7 @@ import org.apache.fineract.portfolio.savings.domain.RdInterestRateEffectiveRepos import org.apache.fineract.portfolio.savings.domain.RdInterestRateSlabHistory; import org.apache.fineract.portfolio.savings.domain.RdInterestRateSlabHistoryRepository; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; @RequiredArgsConstructor public class RdInterestRateReadPlatformServiceImpl implements RdInterestRateReadPlatformService { @@ -59,7 +60,7 @@ public class RdInterestRateReadPlatformServiceImpl implements RdInterestRateRead final List normalizedSlabs = normalizeSlabs(request.getSlabs()); // 1. Deactivate all current active versions for this product, preserving end dates for audit/history. - final LocalDate today = LocalDate.now(); + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); final List activeVersions = effectiveRepository .findByProductIdAndIsActiveTrueOrderByEffectiveFromDateDesc(productId); for (RdInterestRateEffective version : activeVersions) { @@ -72,10 +73,10 @@ public class RdInterestRateReadPlatformServiceImpl implements RdInterestRateRead // 2. Create the new version header String label = request.getVersionLabel() != null && !request.getVersionLabel().isBlank() ? request.getVersionLabel() - : "Rate Version " + LocalDate.now(); + : "Rate Version " + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); RdInterestRateEffective newVersion = RdInterestRateEffective.create( - productId, label, LocalDate.now(), request.getRemarks(), createdById); + productId, label, LocalDate.now(DateUtils.getDateTimeZoneOfTenant()), request.getRemarks(), createdById); newVersion = effectiveRepository.saveAndFlush(newVersion); // 3. Insert slabs for the new version diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositAccountInterestCalculationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositAccountInterestCalculationServiceImpl.java index f9379f53a..4a7709a25 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositAccountInterestCalculationServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositAccountInterestCalculationServiceImpl.java @@ -35,6 +35,7 @@ import org.apache.fineract.infrastructure.core.api.JsonQuery; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.organisation.monetary.domain.MoneyHelper; import org.springframework.stereotype.Service; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Service @RequiredArgsConstructor @@ -52,7 +53,7 @@ public class RecurringDepositAccountInterestCalculationServiceImpl implements Re element); LocalDate submittedOnDate = this.fromApiJsonHelper.extractLocalDateNamed("submittedOnDate", element); if (submittedOnDate == null) { - submittedOnDate = LocalDate.now(); + submittedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); } BigDecimal maturityAmount = this.calculateInterestInternal(depositAmount, annualInterestRate, tenureInMonths, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java new file mode 100644 index 000000000..241198c7c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java @@ -0,0 +1,510 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +import com.google.gson.JsonObject; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandProcessingResultType; +import org.apache.fineract.commands.domain.CommandSource; +import org.apache.fineract.commands.domain.CommandSourceRepository; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.client.service.ClientNomineeWritePlatformService; +import org.apache.fineract.portfolio.savings.data.SavingsAccountPendingEditData; +import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountFacilityConfig; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountFacilityConfigRepository; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountPendingEdit; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountPendingEditRepository; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper; +import org.apache.fineract.portfolio.savings.exception.SavingsAccountEditInsufficientBalanceException; +import org.apache.fineract.portfolio.savings.exception.SavingsAccountEditPendingApprovalException; +import org.apache.fineract.portfolio.savings.exception.SavingsAccountEditSelfApprovalNotAllowedException; +import org.apache.fineract.portfolio.savings.exception.SavingsAccountNoPendingEditException; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.transaction.annotation.Transactional; + +/** + * Maker-checker edit flow for an ACTIVE savings account's Nominee + Digital & Account Features. Mirrors + * {@code SavingsProductWritePlatformServiceJpaRepositoryImpl}'s submitEditForApproval/approveEdit/rejectEdit + * pattern: a row in {@code m_savings_account_pending_edit} is itself the lock, and nothing is applied to the real + * {@code m_savings_account_facility_config} / {@code m_client_nominee} tables until a checker approves. + * + *

+ * Charge handling for a newly-enabled facility (one with a mapped charge in m_facility_charge_mapping): at + * submission a HOLD is placed for the total charge amount (blocking that much of the available balance without + * touching it); on approval the hold is released and the charge is posted for real; on rejection the hold is simply + * released. + */ +@RequiredArgsConstructor +public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements SavingsAccountEditWritePlatformService { + + /** field name (as sent in accountFacilityConfig JSON / stored on the pending edit) -> facility_type code. */ + private static final Map FACILITY_TYPES = new LinkedHashMap<>(); + static { + FACILITY_TYPES.put("atmFacility", "ATM_FACILITY"); + FACILITY_TYPES.put("atmWithdrawalFacility", "ATM_WITHDRAWAL"); + FACILITY_TYPES.put("upiFacility", "UPI_FACILITY"); + FACILITY_TYPES.put("posTransactionsFacility", "POS_TRANSACTIONS"); + FACILITY_TYPES.put("chequeBook", "CHEQUE_BOOK"); + FACILITY_TYPES.put("impsFacility", "IMPS_FACILITY"); + FACILITY_TYPES.put("smsAlerts", "SMS_ALERTS"); + FACILITY_TYPES.put("bbpsFacility", "BBPS_FACILITY"); + FACILITY_TYPES.put("insuranceCover", "INSURANCE_COVER"); + FACILITY_TYPES.put("bepgFacility", "BEPG_FACILITY"); + FACILITY_TYPES.put("neftFacility", "NEFT_FACILITY"); + FACILITY_TYPES.put("rtgsFacility", "RTGS_FACILITY"); + FACILITY_TYPES.put("interBankTotalCapFacility", "INTER_BANK_TOTAL_CAP"); + FACILITY_TYPES.put("withdrawalFacility", "WITHDRAWAL_FACILITY"); + FACILITY_TYPES.put("depositFacilityKyc", "DEPOSIT_FACILITY_KYC"); + FACILITY_TYPES.put("virtualDebitCard", "VIRTUAL_DEBIT_CARD"); + } + + private final PlatformSecurityContext context; + private final SavingsAccountRepositoryWrapper savingAccountRepositoryWrapper; + private final SavingsAccountPendingEditRepository pendingEditRepository; + private final SavingsAccountFacilityConfigRepository facilityConfigRepository; + private final FacilityChargeAutoApplyService facilityChargeAutoApplyService; + private final SavingsAccountWritePlatformService savingsAccountWritePlatformService; + private final ClientNomineeWritePlatformService clientNomineeWritePlatformService; + private final CommandSourceRepository commandSourceRepository; + private final FromJsonHelper fromApiJsonHelper; + + @Transactional + @Override + public CommandProcessingResult submitEditForApproval(final Long savingsId, final JsonCommand command) { + + final AppUser maker = this.context.authenticatedUser(); + final SavingsAccount account = this.savingAccountRepositoryWrapper.findOneWithNotFoundDetection(savingsId); + + if (this.pendingEditRepository.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING).isPresent()) { + throw new SavingsAccountEditPendingApprovalException(savingsId); + } + + final JsonObject root = command.parsedJson().getAsJsonObject(); + final SavingsAccountFacilityConfig currentConfig = account.getFacilityConfig(); + + final SavingsAccountPendingEdit pendingEdit = new SavingsAccountPendingEdit(account, maker); + + // ── Digital & Account Features ── + final Set newlyEnabledFacilityTypes = new HashSet<>(); + if (root.has("accountFacilityConfig") && root.get("accountFacilityConfig").isJsonObject()) { + final JsonObject fc = root.getAsJsonObject("accountFacilityConfig"); + pendingEdit.setFacilityConfigSubmitted(true); + populateFacilityField(fc, "atmFacility", currentConfig == null ? null : currentConfig.getAtmFacility(), pendingEdit::setAtmFacility, newlyEnabledFacilityTypes); + populateFacilityField(fc, "atmWithdrawalFacility", currentConfig == null ? null : currentConfig.getAtmWithdrawalFacility(), pendingEdit::setAtmWithdrawalFacility, newlyEnabledFacilityTypes); + populateFacilityField(fc, "upiFacility", currentConfig == null ? null : currentConfig.getUpiFacility(), pendingEdit::setUpiFacility, newlyEnabledFacilityTypes); + populateFacilityField(fc, "posTransactionsFacility", currentConfig == null ? null : currentConfig.getPosTransactionsFacility(), pendingEdit::setPosTransactionsFacility, newlyEnabledFacilityTypes); + populateFacilityField(fc, "chequeBook", currentConfig == null ? null : currentConfig.getChequeBook(), pendingEdit::setChequeBook, newlyEnabledFacilityTypes); + populateFacilityField(fc, "impsFacility", currentConfig == null ? null : currentConfig.getImpsFacility(), pendingEdit::setImpsFacility, newlyEnabledFacilityTypes); + populateFacilityField(fc, "smsAlerts", currentConfig == null ? null : currentConfig.getSmsAlerts(), pendingEdit::setSmsAlerts, newlyEnabledFacilityTypes); + populateFacilityField(fc, "bbpsFacility", currentConfig == null ? null : currentConfig.getBbpsFacility(), pendingEdit::setBbpsFacility, newlyEnabledFacilityTypes); + populateFacilityField(fc, "insuranceCover", currentConfig == null ? null : currentConfig.getInsuranceCover(), pendingEdit::setInsuranceCover, newlyEnabledFacilityTypes); + populateFacilityField(fc, "bepgFacility", currentConfig == null ? null : currentConfig.getBepgFacility(), pendingEdit::setBepgFacility, newlyEnabledFacilityTypes); + populateFacilityField(fc, "neftFacility", currentConfig == null ? null : currentConfig.getNeftFacility(), pendingEdit::setNeftFacility, newlyEnabledFacilityTypes); + populateFacilityField(fc, "rtgsFacility", currentConfig == null ? null : currentConfig.getRtgsFacility(), pendingEdit::setRtgsFacility, newlyEnabledFacilityTypes); + populateFacilityField(fc, "interBankTotalCapFacility", currentConfig == null ? null : currentConfig.getInterBankTotalCapFacility(), pendingEdit::setInterBankTotalCapFacility, newlyEnabledFacilityTypes); + populateFacilityField(fc, "withdrawalFacility", currentConfig == null ? null : currentConfig.getWithdrawalFacility(), pendingEdit::setWithdrawalFacility, newlyEnabledFacilityTypes); + populateFacilityField(fc, "depositFacilityKyc", currentConfig == null ? null : currentConfig.getDepositFacilityKyc(), pendingEdit::setDepositFacilityKyc, newlyEnabledFacilityTypes); + populateFacilityField(fc, "virtualDebitCard", currentConfig == null ? null : currentConfig.getVirtualDebitCard(), pendingEdit::setVirtualDebitCard, newlyEnabledFacilityTypes); + } + + // ── Nominee (single add / update / delete per request) ── + if (root.has("nominee") && root.get("nominee").isJsonObject()) { + final JsonObject nom = root.getAsJsonObject("nominee"); + pendingEdit.setNomineeSubmitted(true); + pendingEdit.setNomineeOperation(nom.has("operation") ? nom.get("operation").getAsString() : null); + pendingEdit.setNomineeId(nom.has("nomineeId") && !nom.get("nomineeId").isJsonNull() ? nom.get("nomineeId").getAsLong() : null); + pendingEdit.setNomineeClientId(nom.has("nomineeClientId") && !nom.get("nomineeClientId").isJsonNull() ? nom.get("nomineeClientId").getAsLong() : null); + pendingEdit.setRelationshipCvId(nom.has("relationshipId") && !nom.get("relationshipId").isJsonNull() ? nom.get("relationshipId").getAsLong() : null); + pendingEdit.setPercentageShare(nom.has("percentageShare") && !nom.get("percentageShare").isJsonNull() ? nom.get("percentageShare").getAsBigDecimal() : null); + pendingEdit.setNomineeIsActive((nom.has("isActive") && !nom.get("isActive").isJsonNull()) ? nom.get("isActive").getAsBoolean() : null); + pendingEdit.setNomineeIsMinor((nom.has("nomineeIsMinor") && !nom.get("nomineeIsMinor").isJsonNull()) ? nom.get("nomineeIsMinor").getAsBoolean() : null); + pendingEdit.setGuardianClientId(nom.has("guardianClientId") && !nom.get("guardianClientId").isJsonNull() ? nom.get("guardianClientId").getAsLong() : null); + pendingEdit.setGuardianRelationshipCvId(nom.has("guardianRelationshipId") && !nom.get("guardianRelationshipId").isJsonNull() ? nom.get("guardianRelationshipId").getAsLong() : null); + pendingEdit.setEffectiveDate(nom.has("effectiveDate") && !nom.get("effectiveDate").isJsonNull() ? LocalDate.parse(nom.get("effectiveDate").getAsString()) : null); + pendingEdit.setNomineeRemarks(nom.has("remarks") && !nom.get("remarks").isJsonNull() ? nom.get("remarks").getAsString() : null); + } + + // ── Hold the charge amount for newly-enabled facilities before anything is persisted ── + final BigDecimal holdNeeded = this.facilityChargeAutoApplyService.totalChargeAmountFor(newlyEnabledFacilityTypes); + if (holdNeeded.compareTo(BigDecimal.ZERO) > 0) { + final BigDecimal availableBalance = account.getWithdrawableBalance(); + if (availableBalance.compareTo(holdNeeded) < 0) { + throw new SavingsAccountEditInsufficientBalanceException(savingsId, availableBalance, holdNeeded); + } + + final JsonObject holdJson = new JsonObject(); + holdJson.addProperty("locale", "en"); + holdJson.addProperty("dateFormat", "yyyy-MM-dd"); + holdJson.addProperty("transactionDate", DateUtils.getBusinessLocalDate().toString()); + holdJson.addProperty("transactionAmount", holdNeeded); + holdJson.addProperty("lienAllowed", false); + holdJson.addProperty("reasonForBlock", "Pending approval of Nominee/Digital-Feature edit"); + final JsonCommand holdCommand = JsonCommand.fromJsonElement(savingsId, holdJson, this.fromApiJsonHelper); + + final CommandProcessingResult holdResult = this.savingsAccountWritePlatformService.holdAmount(savingsId, holdCommand); + pendingEdit.setHoldTransactionId(holdResult.getResourceId()); + pendingEdit.setHoldAmount(holdNeeded); + } + + final CommandSource auditEntry = CommandSource.builder() // + .actionName("SUBMITEDIT") // + .entityName("SAVINGSACCOUNT") // + .resourceGetUrl("/savingsaccounts/" + savingsId) // + .resourceId(savingsId) // + .commandAsJson(command.json()) // + .maker(maker) // + .madeOnDate(DateUtils.getAuditOffsetDateTime()) // + .status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) // + .idempotencyKey(UUID.randomUUID().toString()) // + .sanitized(false) // + .build(); + this.commandSourceRepository.saveAndFlush(auditEntry); + pendingEdit.setCommandSourceId(auditEntry.getId()); + + this.pendingEditRepository.saveAndFlush(pendingEdit); + + return new CommandProcessingResultBuilder().withEntityId(savingsId).build(); + } + + @Transactional + @Override + public CommandProcessingResult approveEdit(final Long savingsId) { + + final AppUser checker = this.context.authenticatedUser(); + final SavingsAccountPendingEdit pendingEdit = this.pendingEditRepository + .findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING) + .orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId)); + + if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId); + } + + final SavingsAccount account = this.savingAccountRepositoryWrapper.findOneWithNotFoundDetection(savingsId); + final SavingsAccountFacilityConfig currentConfig = account.getFacilityConfig(); + final Set newlyEnabledFacilityTypes = pendingEdit.isFacilityConfigSubmitted() + ? computeNewlyEnabled(pendingEdit, currentConfig) + : Set.of(); + + // 1) Release the hold placed at submission time. + if (pendingEdit.getHoldTransactionId() != null) { + this.savingsAccountWritePlatformService.releaseAmount(savingsId, pendingEdit.getHoldTransactionId()); + } + + // 2) Post the real charge(s) for newly-enabled facilities, today. + if (!newlyEnabledFacilityTypes.isEmpty()) { + final var chargesAdded = this.facilityChargeAutoApplyService.applyChargesForFacilities(account, newlyEnabledFacilityTypes); + this.facilityChargeAutoApplyService.collectCharges(account, chargesAdded, DateUtils.getBusinessLocalDate()); + } + + // 3) Apply the facility-config toggles to the real row. + if (pendingEdit.isFacilityConfigSubmitted()) { + SavingsAccountFacilityConfig config = account.getFacilityConfig(); + if (config == null) { + config = new SavingsAccountFacilityConfig(account); + account.setFacilityConfig(config); + } + config.updateFrom(buildFacilityConfigJson(pendingEdit)); + this.facilityConfigRepository.saveAndFlush(config); + } + + // 4) Apply the nominee change to the real table. + if (pendingEdit.isNomineeSubmitted()) { + applyNomineeChange(account, pendingEdit); + } + + this.savingAccountRepositoryWrapper.saveAndFlush(account); + + pendingEdit.markApproved(checker); + this.pendingEditRepository.saveAndFlush(pendingEdit); + + updateAuditEntry(pendingEdit, checker, false); + + return new CommandProcessingResultBuilder().withEntityId(savingsId).build(); + } + + @Transactional + @Override + public CommandProcessingResult rejectEdit(final Long savingsId, final String rejectReason) { + + final AppUser checker = this.context.authenticatedUser(); + final SavingsAccountPendingEdit pendingEdit = this.pendingEditRepository + .findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING) + .orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId)); + + if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId); + } + + if (pendingEdit.getHoldTransactionId() != null) { + this.savingsAccountWritePlatformService.releaseAmount(savingsId, pendingEdit.getHoldTransactionId()); + } + + pendingEdit.markRejected(checker, rejectReason); + this.pendingEditRepository.saveAndFlush(pendingEdit); + + updateAuditEntry(pendingEdit, checker, true); + + return new CommandProcessingResultBuilder().withEntityId(savingsId).build(); + } + + @Override + public SavingsAccountPendingEditData retrievePendingEdit(final Long savingsId) { + return this.pendingEditRepository.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING) + .map(this::toData).orElse(null); + } + + @Override + public java.util.Collection retrieveAllPendingEdits() { + return this.pendingEditRepository.findByStatus(SavingsAccountPendingEdit.STATUS_PENDING).stream().map(this::toData).toList(); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + private void applyNomineeChange(final SavingsAccount account, final SavingsAccountPendingEdit pendingEdit) { + final Long clientId = account.clientId(); + final String operation = pendingEdit.getNomineeOperation(); + + if (SavingsAccountPendingEdit.NOMINEE_OP_DELETE.equals(operation)) { + this.clientNomineeWritePlatformService.deleteNominee(clientId, pendingEdit.getNomineeId()); + return; + } + + final String accountType; + if (account.depositAccountType().isFixedDeposit()) { + accountType = "FD"; + } else if (account.depositAccountType().isRecurringDeposit()) { + accountType = "RD"; + } else { + accountType = "SB"; + } + + final JsonObject nomineeJson = new JsonObject(); + nomineeJson.addProperty("locale", "en"); + nomineeJson.addProperty("dateFormat", "yyyy-MM-dd"); + nomineeJson.addProperty("accountNo", account.getAccountNumber()); + nomineeJson.addProperty("accountType", accountType); + if (pendingEdit.getNomineeClientId() != null) { + nomineeJson.addProperty("nomineeClientId", pendingEdit.getNomineeClientId()); + } + if (pendingEdit.getRelationshipCvId() != null) { + nomineeJson.addProperty("relationshipId", pendingEdit.getRelationshipCvId()); + } + if (pendingEdit.getPercentageShare() != null) { + nomineeJson.addProperty("percentageShare", pendingEdit.getPercentageShare()); + } + if (pendingEdit.getNomineeIsActive() != null) { + nomineeJson.addProperty("isActive", pendingEdit.getNomineeIsActive()); + } + if (pendingEdit.getNomineeIsMinor() != null) { + nomineeJson.addProperty("nomineeIsMinor", pendingEdit.getNomineeIsMinor()); + } + if (pendingEdit.getGuardianClientId() != null) { + nomineeJson.addProperty("guardianClientId", pendingEdit.getGuardianClientId()); + } + if (pendingEdit.getGuardianRelationshipCvId() != null) { + nomineeJson.addProperty("guardianRelationshipId", pendingEdit.getGuardianRelationshipCvId()); + } + if (pendingEdit.getEffectiveDate() != null) { + nomineeJson.addProperty("effectiveDate", pendingEdit.getEffectiveDate().toString()); + } + if (pendingEdit.getNomineeRemarks() != null) { + nomineeJson.addProperty("remarks", pendingEdit.getNomineeRemarks()); + } + final JsonCommand nomineeCommand = JsonCommand.fromJsonElement(clientId, nomineeJson, this.fromApiJsonHelper); + + if (SavingsAccountPendingEdit.NOMINEE_OP_ADD.equals(operation)) { + this.clientNomineeWritePlatformService.addNominee(clientId, nomineeCommand); + } else if (SavingsAccountPendingEdit.NOMINEE_OP_UPDATE.equals(operation)) { + this.clientNomineeWritePlatformService.updateNominee(clientId, pendingEdit.getNomineeId(), nomineeCommand); + } + } + + private JsonObject buildFacilityConfigJson(final SavingsAccountPendingEdit p) { + final JsonObject fc = new JsonObject(); + putIfPresent(fc, "atmFacility", p.getAtmFacility()); + putIfPresent(fc, "atmWithdrawalFacility", p.getAtmWithdrawalFacility()); + putIfPresent(fc, "upiFacility", p.getUpiFacility()); + putIfPresent(fc, "posTransactionsFacility", p.getPosTransactionsFacility()); + putIfPresent(fc, "chequeBook", p.getChequeBook()); + putIfPresent(fc, "impsFacility", p.getImpsFacility()); + putIfPresent(fc, "smsAlerts", p.getSmsAlerts()); + putIfPresent(fc, "bbpsFacility", p.getBbpsFacility()); + putIfPresent(fc, "insuranceCover", p.getInsuranceCover()); + putIfPresent(fc, "bepgFacility", p.getBepgFacility()); + putIfPresent(fc, "neftFacility", p.getNeftFacility()); + putIfPresent(fc, "rtgsFacility", p.getRtgsFacility()); + putIfPresent(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility()); + putIfPresent(fc, "withdrawalFacility", p.getWithdrawalFacility()); + putIfPresent(fc, "depositFacilityKyc", p.getDepositFacilityKyc()); + putIfPresent(fc, "virtualDebitCard", p.getVirtualDebitCard()); + return fc; + } + + private void putIfPresent(final JsonObject json, final String field, final Boolean value) { + if (value != null) { + json.addProperty(field, value); + } + } + + private Set computeNewlyEnabled(final SavingsAccountPendingEdit p, final SavingsAccountFacilityConfig current) { + final Set result = new HashSet<>(); + addIfNewlyEnabled(result, "atmFacility", p.getAtmFacility(), current == null ? null : current.getAtmFacility()); + addIfNewlyEnabled(result, "atmWithdrawalFacility", p.getAtmWithdrawalFacility(), current == null ? null : current.getAtmWithdrawalFacility()); + addIfNewlyEnabled(result, "upiFacility", p.getUpiFacility(), current == null ? null : current.getUpiFacility()); + addIfNewlyEnabled(result, "posTransactionsFacility", p.getPosTransactionsFacility(), current == null ? null : current.getPosTransactionsFacility()); + addIfNewlyEnabled(result, "chequeBook", p.getChequeBook(), current == null ? null : current.getChequeBook()); + addIfNewlyEnabled(result, "impsFacility", p.getImpsFacility(), current == null ? null : current.getImpsFacility()); + addIfNewlyEnabled(result, "smsAlerts", p.getSmsAlerts(), current == null ? null : current.getSmsAlerts()); + addIfNewlyEnabled(result, "bbpsFacility", p.getBbpsFacility(), current == null ? null : current.getBbpsFacility()); + addIfNewlyEnabled(result, "insuranceCover", p.getInsuranceCover(), current == null ? null : current.getInsuranceCover()); + addIfNewlyEnabled(result, "bepgFacility", p.getBepgFacility(), current == null ? null : current.getBepgFacility()); + addIfNewlyEnabled(result, "neftFacility", p.getNeftFacility(), current == null ? null : current.getNeftFacility()); + addIfNewlyEnabled(result, "rtgsFacility", p.getRtgsFacility(), current == null ? null : current.getRtgsFacility()); + addIfNewlyEnabled(result, "interBankTotalCapFacility", p.getInterBankTotalCapFacility(), current == null ? null : current.getInterBankTotalCapFacility()); + addIfNewlyEnabled(result, "withdrawalFacility", p.getWithdrawalFacility(), current == null ? null : current.getWithdrawalFacility()); + addIfNewlyEnabled(result, "depositFacilityKyc", p.getDepositFacilityKyc(), current == null ? null : current.getDepositFacilityKyc()); + addIfNewlyEnabled(result, "virtualDebitCard", p.getVirtualDebitCard(), current == null ? null : current.getVirtualDebitCard()); + return result; + } + + private void addIfNewlyEnabled(final Set result, final String field, final Boolean proposed, final Boolean current) { + if (Boolean.TRUE.equals(proposed) && !Boolean.TRUE.equals(current)) { + result.add(FACILITY_TYPES.get(field)); + } + } + + /** Reads a single submitted field out of the incoming JSON, tracks whether it enables a previously-off facility. */ + private void populateFacilityField(final JsonObject fc, final String field, final Boolean currentValue, + final java.util.function.Consumer setter, final Set newlyEnabledCollector) { + if (!fc.has(field) || fc.get(field).isJsonNull()) { + return; + } + final boolean proposed = fc.get(field).getAsBoolean(); + setter.accept(proposed); + if (proposed && !Boolean.TRUE.equals(currentValue)) { + newlyEnabledCollector.add(FACILITY_TYPES.get(field)); + } + } + + private void updateAuditEntry(final SavingsAccountPendingEdit pendingEdit, final AppUser checker, final boolean rejected) { + Optional existing = Optional.empty(); + if (pendingEdit.getCommandSourceId() != null) { + existing = this.commandSourceRepository.findById(pendingEdit.getCommandSourceId()); + } + final CommandSource auditEntry = existing.orElseGet(() -> CommandSource.builder() // + .actionName(rejected ? "REJECTEDIT" : "APPROVEEDIT") // + .entityName("SAVINGSACCOUNT") // + .resourceGetUrl("/savingsaccounts/" + pendingEdit.getSavingsAccount().getId()) // + .resourceId(pendingEdit.getSavingsAccount().getId()) // + .maker(pendingEdit.getMaker()) // + .madeOnDate(DateUtils.getAuditOffsetDateTime()) // + .status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) // + .idempotencyKey(UUID.randomUUID().toString()) // + .sanitized(false) // + .build()); + if (rejected) { + auditEntry.markAsRejected(checker); + } else { + auditEntry.markAsChecked(checker); + } + this.commandSourceRepository.saveAndFlush(auditEntry); + } + + private SavingsAccountPendingEditData toData(final SavingsAccountPendingEdit p) { + final SavingsAccountPendingEditData data = new SavingsAccountPendingEditData(); + data.setId(p.getId()); + data.setSavingsAccountId(p.getSavingsAccount().getId()); + data.setAccountNo(p.getSavingsAccount().getAccountNumber()); + data.setMakerId(p.getMaker().getId()); + data.setMakerUsername(p.getMaker().getUsername()); + if (p.getChecker() != null) { + data.setCheckerId(p.getChecker().getId()); + data.setCheckerUsername(p.getChecker().getUsername()); + } + data.setCreatedOn(p.getCreatedOn()); + data.setCheckedOn(p.getCheckedOn()); + data.setStatus(p.getStatus()); + data.setRejectReason(p.getRejectReason()); + data.setHoldAmount(p.getHoldAmount()); + data.setFacilityConfigSubmitted(p.isFacilityConfigSubmitted()); + data.setNomineeSubmitted(p.isNomineeSubmitted()); + if (p.getSavingsAccount() != null) { + data.setDepositAccountType(p.getSavingsAccount().depositAccountType().name()); + } + + if (p.isFacilityConfigSubmitted()) { + final Map fc = new LinkedHashMap<>(); + putIfNotNull(fc, "atmFacility", p.getAtmFacility()); + putIfNotNull(fc, "atmWithdrawalFacility", p.getAtmWithdrawalFacility()); + putIfNotNull(fc, "upiFacility", p.getUpiFacility()); + putIfNotNull(fc, "posTransactionsFacility", p.getPosTransactionsFacility()); + putIfNotNull(fc, "chequeBook", p.getChequeBook()); + putIfNotNull(fc, "impsFacility", p.getImpsFacility()); + putIfNotNull(fc, "smsAlerts", p.getSmsAlerts()); + putIfNotNull(fc, "bbpsFacility", p.getBbpsFacility()); + putIfNotNull(fc, "insuranceCover", p.getInsuranceCover()); + putIfNotNull(fc, "bepgFacility", p.getBepgFacility()); + putIfNotNull(fc, "neftFacility", p.getNeftFacility()); + putIfNotNull(fc, "rtgsFacility", p.getRtgsFacility()); + putIfNotNull(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility()); + putIfNotNull(fc, "withdrawalFacility", p.getWithdrawalFacility()); + putIfNotNull(fc, "depositFacilityKyc", p.getDepositFacilityKyc()); + putIfNotNull(fc, "virtualDebitCard", p.getVirtualDebitCard()); + data.setProposedFacilityConfig(fc); + } + + if (p.isNomineeSubmitted()) { + final Map nom = new LinkedHashMap<>(); + putIfNotNull(nom, "operation", p.getNomineeOperation()); + putIfNotNull(nom, "nomineeId", p.getNomineeId()); + putIfNotNull(nom, "nomineeClientId", p.getNomineeClientId()); + putIfNotNull(nom, "relationshipId", p.getRelationshipCvId()); + putIfNotNull(nom, "percentageShare", p.getPercentageShare()); + putIfNotNull(nom, "isActive", p.getNomineeIsActive()); + putIfNotNull(nom, "nomineeIsMinor", p.getNomineeIsMinor()); + putIfNotNull(nom, "guardianClientId", p.getGuardianClientId()); + putIfNotNull(nom, "guardianRelationshipId", p.getGuardianRelationshipCvId()); + putIfNotNull(nom, "effectiveDate", p.getEffectiveDate()); + putIfNotNull(nom, "remarks", p.getNomineeRemarks()); + data.setProposedNominee(nom); + } + + return data; + } + + private void putIfNotNull(final Map map, final String key, final Object value) { + if (value != null) { + map.put(key, value); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java index 471e82962..ce7ce86fc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java @@ -1142,7 +1142,9 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead java.util.Map nomineeMap = gson.fromJson(nomineeDataStr, new com.google.gson.reflect.TypeToken>() {}.getType()); accountData.setNomineeData(nomineeMap); - } catch (Exception ignored) {} + } catch (Exception ignored) { + // intentionally ignored + } } // Facility config diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java index 6ec884712..33a14e8c0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java @@ -35,7 +35,6 @@ import io.github.resilience4j.retry.annotation.Retry; import java.math.BigDecimal; import java.math.MathContext; import java.time.LocalDate; -import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collection; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformServiceJpaRepositoryImpl.java index 5a2ee8187..e45876e82 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformServiceJpaRepositoryImpl.java @@ -79,7 +79,6 @@ import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundExc import org.apache.fineract.portfolio.savings.exception.SavingsProductSelfApprovalNotAllowedException; import org.apache.fineract.portfolio.tax.domain.TaxGroup; import org.apache.fineract.useradministration.domain.AppUser; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataAccessException; import org.springframework.transaction.annotation.Transactional; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java index b5fa180bc..45d888cdf 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java @@ -94,17 +94,14 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRep import org.apache.fineract.portfolio.savings.domain.SavingsHelper; import org.apache.fineract.portfolio.savings.domain.SavingsProductAssembler; import org.apache.fineract.portfolio.savings.domain.SavingsProductDepositLimitKycRepository; -import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityConfig; import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityConfigRepository; import org.apache.fineract.portfolio.savings.domain.ProductPendingEditRepository; import org.apache.fineract.portfolio.savings.domain.ProductPendingEditSubmittedFieldRepository; import org.apache.fineract.portfolio.savings.domain.ProductPendingEditChargeRepository; import org.apache.fineract.portfolio.savings.domain.ProductPendingEditGlMappingRepository; import org.apache.fineract.commands.domain.CommandSourceRepository; -import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityLimit; import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityLimitRepository; import org.apache.fineract.portfolio.savings.domain.SavingsProductRepository; -import org.apache.fineract.portfolio.denominationmaster.domain.SavingsTxnDenominationRepository; import org.apache.fineract.portfolio.savings.service.FacilityChargeAutoApplyService; import org.apache.fineract.portfolio.savings.service.DepositAccountInterestRateChartReadPlatformService; import org.apache.fineract.portfolio.savings.service.DepositAccountInterestRateChartReadPlatformServiceImpl; @@ -152,6 +149,16 @@ import org.apache.fineract.portfolio.savings.service.SavingsDropdownReadPlatform import org.apache.fineract.portfolio.savings.service.SavingsDropdownReadPlatformServiceImpl; import org.apache.fineract.portfolio.savings.service.SavingsProductReadPlatformService; import org.apache.fineract.portfolio.savings.service.SavingsProductReadPlatformServiceImpl; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountFacilityConfigRepository; +import org.apache.fineract.portfolio.savings.service.SavingsAccountEditWritePlatformService; +import org.apache.fineract.portfolio.savings.service.SavingsAccountEditWritePlatformServiceJpaRepositoryImpl; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfigRepository; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountPendingEditRepository; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; +import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountEditWritePlatformService; +import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountTransactionWritePlatformService; +import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService; +import org.apache.fineract.portfolio.savings.service.CurrentAccountEditWritePlatformServiceJpaRepositoryImpl; import org.apache.fineract.portfolio.savings.service.SavingsProductWritePlatformService; import org.apache.fineract.portfolio.savings.service.SavingsProductWritePlatformServiceJpaRepositoryImpl; import org.apache.fineract.portfolio.savings.domain.RdBaseInterestRateEffectiveRepository; @@ -468,6 +475,35 @@ public class SavingsConfiguration { errorHandler, facilityChargeAutoApplyService, denominationBreakdownWriter); } + @Bean + @ConditionalOnMissingBean(SavingsAccountEditWritePlatformService.class) + public SavingsAccountEditWritePlatformService savingsAccountEditWritePlatformService(PlatformSecurityContext context, + SavingsAccountRepositoryWrapper savingAccountRepositoryWrapper, + org.apache.fineract.portfolio.savings.domain.SavingsAccountPendingEditRepository pendingEditRepository, + SavingsAccountFacilityConfigRepository facilityConfigRepository, FacilityChargeAutoApplyService facilityChargeAutoApplyService, + SavingsAccountWritePlatformService savingsAccountWritePlatformService, + org.apache.fineract.portfolio.client.service.ClientNomineeWritePlatformService clientNomineeWritePlatformService, + org.apache.fineract.commands.domain.CommandSourceRepository commandSourceRepository, FromJsonHelper fromApiJsonHelper) { + return new SavingsAccountEditWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepositoryWrapper, + pendingEditRepository, facilityConfigRepository, facilityChargeAutoApplyService, savingsAccountWritePlatformService, + clientNomineeWritePlatformService, commandSourceRepository, fromApiJsonHelper); + } + + @Bean + @ConditionalOnMissingBean(CurrentAccountEditWritePlatformService.class) + public CurrentAccountEditWritePlatformService currentAccountEditWritePlatformService(PlatformSecurityContext context, + CurrentAccountRepository currentAccountRepository, CurrentAccountPendingEditRepository pendingEditRepository, + CurrentAccountFacilityConfigRepository facilityConfigRepository, + org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository facilityChargeMappingRepository, + CurrentAccountTransactionWritePlatformService currentAccountTransactionWritePlatformService, + CurrentAccountWritePlatformService currentAccountWritePlatformService, + ClientNomineeWritePlatformService clientNomineeWritePlatformService, CommandSourceRepository commandSourceRepository, + FromJsonHelper fromApiJsonHelper) { + return new CurrentAccountEditWritePlatformServiceJpaRepositoryImpl(context, currentAccountRepository, pendingEditRepository, + facilityConfigRepository, facilityChargeMappingRepository, currentAccountTransactionWritePlatformService, + currentAccountWritePlatformService, clientNomineeWritePlatformService, commandSourceRepository, fromApiJsonHelper); + } + @Bean @ConditionalOnMissingBean(SavingsApplicationProcessWritePlatformService.class) public SavingsApplicationProcessWritePlatformService savingsApplicationProcessWritePlatformService(PlatformSecurityContext context, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/self/client/api/SelfClientsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/self/client/api/SelfClientsApiResource.java index 9037547bb..00f111896 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/self/client/api/SelfClientsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/self/client/api/SelfClientsApiResource.java @@ -135,7 +135,7 @@ public class SelfClientsApiResource { final String hierarchy = null; final Boolean orphansOnly = null; return this.clientApiResource.retrieveAll(uriInfo, officeId, externalId, displayName, firstname, lastname, status, legalForm, - hierarchy, offset, limit, orderBy, sortOrder, orphansOnly, true); + hierarchy, offset, limit, orderBy, sortOrder, orphansOnly, true, null, null); } @GET diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareAccountEditApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareAccountEditApiResource.java new file mode 100644 index 000000000..144643bae --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareAccountEditApiResource.java @@ -0,0 +1,154 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.shareaccounts.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.UriInfo; +import java.util.Collection; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountPendingEditData; +import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountEditWritePlatformService; +import org.springframework.stereotype.Component; + +@Path("/v1/shareaccounts") +@Component +@Tag(name = "Share Account Edit Approval", description = "Maker-checker workflow for editing a Share Account's details (linked savings account, dividend calculation for inactive clients, lockin period, minimum active period). Edits are held in m_share_account_pending_edit (a pending row there is itself the lock) and only applied to the real account once a checker approves.") +@RequiredArgsConstructor +public class ShareAccountEditApiResource { + + private final PlatformSecurityContext context; + private final ShareAccountEditWritePlatformService shareAccountEditWritePlatformService; + private final FromJsonHelper fromApiJsonHelper; + private final ApiRequestParameterHelper apiRequestParameterHelper; + private final DefaultToApiJsonSerializer toApiJsonSerializer; + private final DefaultToApiJsonSerializer pendingEditApiJsonSerializer; + + @POST + @Path("{accountId}/edit") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Submit a details edit for a Share Account", + description = "Edits are held in m_share_account_pending_edit (a pending row there is itself the lock) and only " + + "applied to the real account once a checker approves - see /shareaccounts/{accountId}/edit/approve and /reject below.") + public String submitEdit(@PathParam("accountId") @Parameter(description = "accountId") final Long accountId, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + + this.context.authenticatedUser().validateHasPermissionTo("SUBMITEDIT_SHAREACCOUNT"); + + final JsonCommand command = JsonCommand.fromJsonElement(accountId, this.fromApiJsonHelper.parse(apiRequestBodyAsJson), + this.fromApiJsonHelper); + + final CommandProcessingResult result = this.shareAccountEditWritePlatformService.submitEditForApproval(accountId, command); + + return this.toApiJsonSerializer.serialize(result); + } + + @GET + @Path("edit/pending") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "List Share Account details edits pending approval") + public String retrieveAllPendingEdits(@Context final UriInfo uriInfo) { + + this.context.authenticatedUser().validateHasReadPermission("SHAREACCOUNT"); + + final Collection pendingEdits = this.shareAccountEditWritePlatformService + .retrieveAllPendingEdits(); + + final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters()); + return this.pendingEditApiJsonSerializer.serialize(settings, pendingEdits); + } + + @GET + @Path("{accountId}/edit/pending") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve the pending details edit for a Share Account, if any") + public String retrievePendingEdit(@PathParam("accountId") @Parameter(description = "accountId") final Long accountId, + @Context final UriInfo uriInfo) { + + this.context.authenticatedUser().validateHasReadPermission("SHAREACCOUNT"); + + final ShareAccountPendingEditData pendingEdit = this.shareAccountEditWritePlatformService.retrievePendingEdit(accountId); + + final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters()); + return this.pendingEditApiJsonSerializer.serialize(settings, pendingEdit); + } + + @POST + @Path("{accountId}/edit/approve") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Approve a pending Share Account details edit", + description = "Applies the proposed details changes to the account. The maker of the edit cannot also approve it.") + public String approvePendingEdit(@PathParam("accountId") @Parameter(description = "accountId") final Long accountId) { + + if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVEEDIT_SHAREACCOUNT")) { + throw new NoAuthorizationException("User has no authority to approve share account edits"); + } + + final CommandProcessingResult result = this.shareAccountEditWritePlatformService.approveEdit(accountId); + + return this.toApiJsonSerializer.serialize(result); + } + + @POST + @Path("{accountId}/edit/reject") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Reject a pending Share Account details edit", + description = "Discards the proposed changes without applying them. The maker of the edit cannot also reject it.") + public String rejectPendingEdit(@PathParam("accountId") @Parameter(description = "accountId") final Long accountId, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + + if (this.context.authenticatedUser().hasNotPermissionForAnyOf("REJECTEDIT_SHAREACCOUNT")) { + throw new NoAuthorizationException("User has no authority to reject share account edits"); + } + + String rejectReason = null; + if (apiRequestBodyAsJson != null && !apiRequestBodyAsJson.isBlank()) { + final var parsed = this.fromApiJsonHelper.parse(apiRequestBodyAsJson); + if (parsed != null && parsed.isJsonObject() && parsed.getAsJsonObject().has("rejectReason")) { + rejectReason = parsed.getAsJsonObject().get("rejectReason").getAsString(); + } + } + + final CommandProcessingResult result = this.shareAccountEditWritePlatformService.rejectEdit(accountId, rejectReason); + + return this.toApiJsonSerializer.serialize(result); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountData.java index 0430001bb..4114f219b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountData.java @@ -41,6 +41,8 @@ public class ShareAccountData implements Serializable, AccountData { private String externalId; + private String resolutionNumber; + private String savingsAccountNumber; private Long clientId; @@ -154,9 +156,10 @@ public class ShareAccountData implements Serializable, AccountData { private Collection minimumActivePeriodFrequencyTypeOptions; private Collection clientSavingsAccounts; - public ShareAccountData(final Long id, final String accountNo, final String externalId, final Long savingsAccountId, - final String savingsAccountNumber, final Long clientId, final String clientName, final Long productId, final String productName, - final ShareAccountStatusEnumData status, final ShareAccountApplicationTimelineData timeline, final CurrencyData currency, + public ShareAccountData(final Long id, final String accountNo, final String externalId, final String resolutionNumber, + final Long savingsAccountId, final String savingsAccountNumber, final Long clientId, final String clientName, + final Long productId, final String productName, final ShareAccountStatusEnumData status, + final ShareAccountApplicationTimelineData timeline, final CurrencyData currency, final ShareAccountSummaryData summaryData, final Collection charges, final Collection purchasedSharesData, final Integer lockinPeriod, final EnumOptionData lockPeriodTypeEnum, final Integer minimumActivePeriod, final EnumOptionData minimumActivePeriodTypeEnum, @@ -164,6 +167,7 @@ public class ShareAccountData implements Serializable, AccountData { this.id = id; this.accountNo = accountNo; this.externalId = externalId; + this.resolutionNumber = resolutionNumber; this.savingsAccountId = savingsAccountId; this.savingsAccountNumber = savingsAccountNumber; this.clientId = clientId; @@ -208,9 +212,10 @@ public class ShareAccountData implements Serializable, AccountData { this.defaultShares = defaultShares; } - private ShareAccountData(final Long id, final String accountNo, final String externalId, final Long savingsAccountId, - final String savingsAccountNumber, final Long clientId, final String clientName, final Long productId, final String productName, - final ShareAccountStatusEnumData status, final ShareAccountApplicationTimelineData timeline, final CurrencyData currency, + private ShareAccountData(final Long id, final String accountNo, final String externalId, final String resolutionNumber, + final Long savingsAccountId, final String savingsAccountNumber, final Long clientId, final String clientName, + final Long productId, final String productName, final ShareAccountStatusEnumData status, + final ShareAccountApplicationTimelineData timeline, final CurrencyData currency, final ShareAccountSummaryData summaryData, final Collection charges, final Collection purchasedSharesData, final Integer lockinPeriod, final EnumOptionData lockPeriodTypeEnum, final Integer minimumActivePeriod, final EnumOptionData minimumActivePeriodTypeEnum, @@ -221,6 +226,7 @@ public class ShareAccountData implements Serializable, AccountData { this.id = id; this.accountNo = accountNo; this.externalId = externalId; + this.resolutionNumber = resolutionNumber; this.savingsAccountId = savingsAccountId; this.savingsAccountNumber = savingsAccountNumber; this.clientId = clientId; @@ -250,15 +256,17 @@ public class ShareAccountData implements Serializable, AccountData { Collection chargeOptions, Collection clientSavingsAccounts, Collection lockinPeriodFrequencyTypeOptions, Collection minimumActivePeriodFrequencyTypeOptions) { - return new ShareAccountData(data.id, data.accountNo, data.externalId, data.savingsAccountId, data.savingsAccountNumber, - data.clientId, data.clientName, data.productId, data.productName, data.status, data.timeline, data.currency, data.summary, - data.charges, data.purchasedShares, data.lockinPeriod, data.lockPeriodTypeEnum, data.minimumActivePeriod, - data.minimumActivePeriodTypeEnum, data.allowDividendCalculationForInactiveClients, productOptions, chargeOptions, - clientSavingsAccounts, lockinPeriodFrequencyTypeOptions, minimumActivePeriodFrequencyTypeOptions, data.currentMarketPrice); + return new ShareAccountData(data.id, data.accountNo, data.externalId, data.resolutionNumber, data.savingsAccountId, + data.savingsAccountNumber, data.clientId, data.clientName, data.productId, data.productName, data.status, data.timeline, + data.currency, data.summary, data.charges, data.purchasedShares, data.lockinPeriod, data.lockPeriodTypeEnum, + data.minimumActivePeriod, data.minimumActivePeriodTypeEnum, data.allowDividendCalculationForInactiveClients, + productOptions, chargeOptions, clientSavingsAccounts, lockinPeriodFrequencyTypeOptions, + minimumActivePeriodFrequencyTypeOptions, data.currentMarketPrice); } public static ShareAccountData lookup(final Long id, final String accountNo, final Long clientId, final String clientName) { String externalId = null; + String resolutionNumber = null; ShareAccountSummaryData summaryData = null; Collection purchasedSharesData = null; GLAccountData suspenseAccount = null; @@ -282,10 +290,11 @@ public class ShareAccountData implements Serializable, AccountData { final Long productId = null; final Collection productOptions = null; - return new ShareAccountData(id, accountNo, externalId, savingsAccountId, savingsAccountNumber, clientId, clientName, productId, - productName, status, timeline, currency, summaryData, charges, purchasedSharesData, lockinPeriod, lockPeriodTypeEnum, - minimumActivePeriod, minimumActivePeriodTypeEnum, allowDividendCalculationForInactiveClients, productOptions, chargeOptions, - clientSavingsAccounts, lockinPeriodFrequencyTypeOptions, minimumActivePeriodFrequencyTypeOption, currenMarketPrice); + return new ShareAccountData(id, accountNo, externalId, resolutionNumber, savingsAccountId, savingsAccountNumber, clientId, + clientName, productId, productName, status, timeline, currency, summaryData, charges, purchasedSharesData, lockinPeriod, + lockPeriodTypeEnum, minimumActivePeriod, minimumActivePeriodTypeEnum, allowDividendCalculationForInactiveClients, + productOptions, chargeOptions, clientSavingsAccounts, lockinPeriodFrequencyTypeOptions, + minimumActivePeriodFrequencyTypeOption, currenMarketPrice); } public void setCurrentMarketPrice(final BigDecimal currentMarketPrice) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountPendingEditData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountPendingEditData.java new file mode 100644 index 000000000..19356392c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountPendingEditData.java @@ -0,0 +1,57 @@ +/** + * 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.data; + +import java.io.Serializable; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.Setter; + +/** + * Read-side view of a single pending Share Account "details" edit (m_share_account_pending_edit), for the Approvals + * -> Pending Share Account Edits screen. + */ +@Getter +@Setter +public class ShareAccountPendingEditData implements Serializable { + + private Long id; + private Long shareAccountId; + private String accountNo; + private Long clientId; + private String clientName; + private Long makerId; + private String makerUsername; + private Long checkerId; + private String checkerUsername; + private LocalDateTime createdOn; + private LocalDateTime checkedOn; + private String status; + private String rejectReason; + + private Long savingsAccountId; + private String savingsAccountNo; + private Boolean allowDividendCalculationForInactiveClients; + private Integer lockinPeriodFrequency; + private Integer lockinPeriodFrequencyType; + private Integer minimumActivePeriodFrequency; + private Integer minimumActivePeriodFrequencyType; + + public ShareAccountPendingEditData() {} +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java index ee231b701..cfeab7ae1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java @@ -105,6 +105,9 @@ public class ShareAccount extends AbstractPersistableCustom { @Column(name = "external_id") private String externalId; + @Column(name = "resolution_number", nullable = false) + private String resolutionNumber; + @Column(name = "account_no", length = 20, unique = true, nullable = false) private String accountNumber; @@ -151,10 +154,11 @@ public class ShareAccount extends AbstractPersistableCustom { } - public ShareAccount(final Client client, final ShareProduct shareProduct, final String externalId, final MonetaryCurrency currency, - final SavingsAccount savingsAccount, final String accountNo, final Long totalSharesApproved, final Long totalSharesPending, - final Set purchasedShares, final Boolean allowDividendCalculationForInactiveClients, - final Integer lockinPeriodFrequency, final PeriodFrequencyType lockPeriodType, final Integer minimumActivePeriodFrequency, + public ShareAccount(final Client client, final ShareProduct shareProduct, final String externalId, final String resolutionNumber, + final MonetaryCurrency currency, final SavingsAccount savingsAccount, final String accountNo, + final Long totalSharesApproved, final Long totalSharesPending, final Set purchasedShares, + final Boolean allowDividendCalculationForInactiveClients, final Integer lockinPeriodFrequency, + final PeriodFrequencyType lockPeriodType, final Integer minimumActivePeriodFrequency, final PeriodFrequencyType minimumActivePeriodType, Set charges, AppUser submittedBy, final LocalDate submittedDate, AppUser approvedBy, LocalDate approvedDate, AppUser rejectedBy, LocalDate rejectedDate, AppUser activatedBy, LocalDate activatedDate, AppUser closedBy, LocalDate closedDate, AppUser modifiedBy, @@ -163,6 +167,7 @@ public class ShareAccount extends AbstractPersistableCustom { this.client = client; this.shareProduct = shareProduct; this.externalId = externalId; + this.resolutionNumber = resolutionNumber; this.currency = currency; this.savingsAccount = savingsAccount; if (StringUtils.isBlank(accountNo)) { @@ -235,6 +240,19 @@ public class ShareAccount extends AbstractPersistableCustom { return toReturn; } + public String getResolutionNumber() { + return this.resolutionNumber; + } + + public boolean setResolutionNumber(final String resolutionNumber) { + boolean toReturn = false; + if (this.resolutionNumber == null || !this.resolutionNumber.equals(resolutionNumber)) { + this.resolutionNumber = resolutionNumber; + toReturn = true; + } + return toReturn; + } + public boolean setSavingsAccount(final SavingsAccount savingsAccount) { boolean returnValue = false; if (!this.savingsAccount.getId().equals(savingsAccount.getId())) { @@ -561,6 +579,22 @@ public class ShareAccount extends AbstractPersistableCustom { return this.lockinPeriodFrequencyType; } + public Integer getMinimumActivePeriodFrequency() { + return this.minimumActivePeriodFrequency; + } + + public PeriodFrequencyType getMinimumActivePeriodFrequencyType() { + return this.minimumActivePeriodFrequencyType; + } + + public Boolean getAllowDividendCalculationForInactiveClients() { + return this.allowDividendCalculationForInactiveClients; + } + + public SavingsAccount getSavingsAccount() { + return this.savingsAccount; + } + public LocalDate getActivatedDate() { return this.activatedDate; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountPendingEdit.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountPendingEdit.java new file mode 100644 index 000000000..a2d033bd1 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountPendingEdit.java @@ -0,0 +1,139 @@ +/** + * 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.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType; +import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.useradministration.domain.AppUser; +import org.apache.fineract.infrastructure.core.service.DateUtils; + +/** + * Holds a pending edit request for a Share Account's "details" fields (linked savings account, + * dividend-calculation-for-inactive-clients, lockin period, minimum active period). Presence of a row here with + * {@code status = PENDING} (enforced via the unique {@link #pendingLockKey}) is itself the lock - nothing is applied + * to the real {@code m_share_account} row until a checker approves (see + * ShareAccountEditWritePlatformServiceJpaRepositoryImpl). + */ +@Entity +@Table(name = "m_share_account_pending_edit") +@Getter +@Setter +public class ShareAccountPendingEdit extends AbstractPersistableCustom { + + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_APPROVED = "APPROVED"; + public static final String STATUS_REJECTED = "REJECTED"; + + @ManyToOne + @JoinColumn(name = "share_account_id", nullable = false) + private ShareAccount shareAccount; + + @ManyToOne + @JoinColumn(name = "createdby_id", nullable = false) + private AppUser maker; + + @ManyToOne + @JoinColumn(name = "lastmodifiedby_id") + private AppUser checker; + + @Column(name = "created_date", nullable = false) + private LocalDateTime createdOn; + + @Column(name = "lastmodified_date") + private LocalDateTime checkedOn; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @Column(name = "reject_reason", length = 500) + private String rejectReason; + + /** "SHARE_ACCOUNT_EDIT:" while PENDING, NULL otherwise - the real DB-level lock. */ + @Column(name = "pending_lock_key", length = 60) + private String pendingLockKey; + + /** Links to the audit-only m_portfolio_command_source row created for this edit's submit/approve/reject. */ + @Column(name = "command_source_id") + private Long commandSourceId; + + @ManyToOne + @JoinColumn(name = "savings_account_id") + private SavingsAccount savingsAccount; + + @Column(name = "allow_dividends_inactive_clients") + private Boolean allowDividendCalculationForInactiveClients; + + @Column(name = "lockin_period_frequency") + private Integer lockinPeriodFrequency; + + @Enumerated(EnumType.ORDINAL) + @Column(name = "lockin_period_frequency_enum") + private PeriodFrequencyType lockinPeriodFrequencyType; + + @Column(name = "minimum_active_period_frequency") + private Integer minimumActivePeriodFrequency; + + @Enumerated(EnumType.ORDINAL) + @Column(name = "minimum_active_period_frequency_enum") + private PeriodFrequencyType minimumActivePeriodFrequencyType; + + protected ShareAccountPendingEdit() {} + + public ShareAccountPendingEdit(final ShareAccount shareAccount, final AppUser maker) { + this.shareAccount = shareAccount; + this.maker = maker; + this.createdOn = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + this.status = STATUS_PENDING; + this.pendingLockKey = lockKeyFor(shareAccount.getId()); + } + + public static String lockKeyFor(final Long shareAccountId) { + return "SHARE_ACCOUNT_EDIT:" + shareAccountId; + } + + public boolean isPending() { + return STATUS_PENDING.equals(this.status); + } + + public void markApproved(final AppUser checker) { + this.status = STATUS_APPROVED; + this.checker = checker; + this.checkedOn = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + this.pendingLockKey = null; + } + + public void markRejected(final AppUser checker, final String reason) { + this.status = STATUS_REJECTED; + this.checker = checker; + this.checkedOn = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + this.rejectReason = reason; + this.pendingLockKey = null; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountPendingEditRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountPendingEditRepository.java new file mode 100644 index 000000000..2c4ae98ed --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountPendingEditRepository.java @@ -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.shareaccounts.domain; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ShareAccountPendingEditRepository extends JpaRepository { + + Optional findByShareAccountIdAndStatus(Long shareAccountId, String status); + + List findByStatus(String status); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/exception/ShareAccountEditPendingApprovalException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/exception/ShareAccountEditPendingApprovalException.java new file mode 100644 index 000000000..cec2ac50d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/exception/ShareAccountEditPendingApprovalException.java @@ -0,0 +1,29 @@ +/** + * 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.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class ShareAccountEditPendingApprovalException extends AbstractPlatformDomainRuleException { + + public ShareAccountEditPendingApprovalException(final Long shareAccountId) { + super("error.msg.shareaccount.edit.pending.approval", + "Share account with identifier " + shareAccountId + " already has a details edit pending approval", shareAccountId); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/exception/ShareAccountEditSelfApprovalNotAllowedException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/exception/ShareAccountEditSelfApprovalNotAllowedException.java new file mode 100644 index 000000000..24d0feba3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/exception/ShareAccountEditSelfApprovalNotAllowedException.java @@ -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.shareaccounts.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class ShareAccountEditSelfApprovalNotAllowedException extends AbstractPlatformDomainRuleException { + + public ShareAccountEditSelfApprovalNotAllowedException(final Long shareAccountId) { + super("error.msg.shareaccount.edit.self.approval.not.allowed", + "The maker of the pending details edit for share account " + shareAccountId + " cannot also approve or reject it", + shareAccountId); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/exception/ShareAccountNoPendingEditException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/exception/ShareAccountNoPendingEditException.java new file mode 100644 index 000000000..d13d0e27f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/exception/ShareAccountNoPendingEditException.java @@ -0,0 +1,29 @@ +/** + * 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.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class ShareAccountNoPendingEditException extends AbstractPlatformDomainRuleException { + + public ShareAccountNoPendingEditException(final Long shareAccountId) { + super("error.msg.shareaccount.no.pending.edit", + "Share account with identifier " + shareAccountId + " has no details edit awaiting approval", shareAccountId); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java index bb9477fd0..ba9fc54ce 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java @@ -145,6 +145,10 @@ public class ShareAccountDataSerializer { final String externalId = this.fromApiJsonHelper.extractStringNamed(ShareAccountApiConstants.externalid_paramname, element); // baseDataValidator.reset().parameter(ShareAccountApiConstants.externalid_paramname).value(externalId).notNull(); + final String resolutionNumber = this.fromApiJsonHelper.extractStringNamed(ShareAccountApiConstants.resolutionnumber_paramname, + element); + baseDataValidator.reset().parameter(ShareAccountApiConstants.resolutionnumber_paramname).value(resolutionNumber).notBlank(); + Long savingsAccountId = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.savingsaccountid_paramname, element); baseDataValidator.reset().parameter(ShareAccountApiConstants.savingsaccountid_paramname).value(savingsAccountId).notNull() .longGreaterThanZero(); @@ -216,10 +220,10 @@ public class ShareAccountDataSerializer { Set sharesPurchased = new HashSet<>(); sharesPurchased.add(transaction); - ShareAccount account = new ShareAccount(client, shareProduct, externalId, currency, savingsAccount, accountNo, approvedShares, - pendingShares, sharesPurchased, allowdividendsForInactiveClients, lockinPeriod, lockPeriodEnum, minimumActivePeriod, - minimumActivePeriodEnum, charges, submittedBy, submittedDate, approvedBy, approvedDate, rejectedBy, rejectedDate, - activatedBy, activatedDate, closedBy, closedDate, modifiedBy, modifiedDate); + ShareAccount account = new ShareAccount(client, shareProduct, externalId, resolutionNumber, currency, savingsAccount, accountNo, + approvedShares, pendingShares, sharesPurchased, allowdividendsForInactiveClients, lockinPeriod, lockPeriodEnum, + minimumActivePeriod, minimumActivePeriodEnum, charges, submittedBy, submittedDate, approvedBy, approvedDate, rejectedBy, + rejectedDate, activatedBy, activatedDate, closedBy, closedDate, modifiedBy, modifiedDate); for (ShareAccountTransaction pur : sharesPurchased) { pur.setShareAccount(account); @@ -300,6 +304,15 @@ public class ShareAccountDataSerializer { } } + if (this.fromApiJsonHelper.parameterExists(ShareAccountApiConstants.resolutionnumber_paramname, element)) { + final String resolutionNumber = this.fromApiJsonHelper + .extractStringNamed(ShareAccountApiConstants.resolutionnumber_paramname, element); + baseDataValidator.reset().parameter(ShareAccountApiConstants.resolutionnumber_paramname).value(resolutionNumber).notBlank(); + if (account.setResolutionNumber(resolutionNumber)) { + actualChanges.put(ShareAccountApiConstants.resolutionnumber_paramname, resolutionNumber); + } + } + 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() diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountEditWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountEditWritePlatformService.java new file mode 100644 index 000000000..d963bbe61 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountEditWritePlatformService.java @@ -0,0 +1,37 @@ +/** + * 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.service; + +import java.util.Collection; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountPendingEditData; + +public interface ShareAccountEditWritePlatformService { + + CommandProcessingResult submitEditForApproval(Long shareAccountId, JsonCommand command); + + CommandProcessingResult approveEdit(Long shareAccountId); + + CommandProcessingResult rejectEdit(Long shareAccountId, String rejectReason); + + ShareAccountPendingEditData retrievePendingEdit(Long shareAccountId); + + Collection retrieveAllPendingEdits(); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountEditWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountEditWritePlatformServiceJpaRepositoryImpl.java new file mode 100644 index 000000000..77d7290c9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountEditWritePlatformServiceJpaRepositoryImpl.java @@ -0,0 +1,301 @@ +/** + * 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.service; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandProcessingResultType; +import org.apache.fineract.commands.domain.CommandSource; +import org.apache.fineract.commands.domain.CommandSourceRepository; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.accounts.constants.ShareAccountApiConstants; +import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType; +import org.apache.fineract.portfolio.savings.DepositAccountType; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper; +import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException; +import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService; +import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountPendingEditData; +import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccount; +import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountPendingEdit; +import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountPendingEditRepository; +import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountRepositoryWrapper; +import org.apache.fineract.portfolio.shareaccounts.exception.ShareAccountEditPendingApprovalException; +import org.apache.fineract.portfolio.shareaccounts.exception.ShareAccountEditSelfApprovalNotAllowedException; +import org.apache.fineract.portfolio.shareaccounts.exception.ShareAccountNoPendingEditException; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.transaction.annotation.Transactional; + +/** + * Maker-checker edit flow for a Share Account's "details" fields (linked savings account, + * allow-dividend-calculation-for-inactive-clients, lockin period, minimum active period). Mirrors + * {@code SavingsAccountEditWritePlatformServiceJpaRepositoryImpl}'s submitEditForApproval/approveEdit/rejectEdit + * pattern: a row in {@code m_share_account_pending_edit} is itself the lock, and nothing is applied to the real + * {@code m_share_account} row until a checker approves. Unlike the Savings Account version, this edit carries no + * balance/hold implications, so the maker's full intended new state of the 6 fields is captured (falling back to the + * account's current value for any field the maker didn't touch) rather than a partial toggle diff. + */ +@RequiredArgsConstructor +public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements ShareAccountEditWritePlatformService { + + private final PlatformSecurityContext context; + private final ShareAccountRepositoryWrapper shareAccountRepositoryWrapper; + private final ShareAccountPendingEditRepository pendingEditRepository; + private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper; + private final SavingsAccountReadPlatformService savingsAccountReadPlatformService; + private final CommandSourceRepository commandSourceRepository; + + @Transactional + @Override + public CommandProcessingResult submitEditForApproval(final Long shareAccountId, final JsonCommand command) { + + final AppUser maker = this.context.authenticatedUser(); + final ShareAccount account = this.shareAccountRepositoryWrapper.findOneWithNotFoundDetection(shareAccountId); + + if (this.pendingEditRepository.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING) + .isPresent()) { + throw new ShareAccountEditPendingApprovalException(shareAccountId); + } + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("sharesaccount"); + + final ShareAccountPendingEdit pendingEdit = new ShareAccountPendingEdit(account, maker); + + if (command.hasParameter(ShareAccountApiConstants.savingsaccountid_paramname)) { + final Long savingsAccountId = command.longValueOfParameterNamed(ShareAccountApiConstants.savingsaccountid_paramname); + baseDataValidator.reset().parameter(ShareAccountApiConstants.savingsaccountid_paramname).value(savingsAccountId).notNull() + .longGreaterThanZero(); + if (!this.savingsAccountReadPlatformService.isAccountBelongsToClient(account.getClientId(), savingsAccountId, + DepositAccountType.SAVINGS_DEPOSIT, account.getShareProduct().getCurrency().getCode())) { + throw new SavingsAccountNotFoundException(savingsAccountId); + } + pendingEdit.setSavingsAccount(this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(savingsAccountId)); + } else { + pendingEdit.setSavingsAccount(account.getSavingsAccount()); + } + + if (command.hasParameter(ShareAccountApiConstants.allowdividendcalculationforinactiveclients_paramname)) { + pendingEdit.setAllowDividendCalculationForInactiveClients( + command.booleanObjectValueOfParameterNamed(ShareAccountApiConstants.allowdividendcalculationforinactiveclients_paramname)); + } else { + pendingEdit.setAllowDividendCalculationForInactiveClients(account.getAllowDividendCalculationForInactiveClients()); + } + + if (command.hasParameter(ShareAccountApiConstants.lockinperiod_paramname)) { + final Integer lockinPeriod = command.integerValueOfParameterNamed(ShareAccountApiConstants.lockinperiod_paramname); + baseDataValidator.reset().parameter(ShareAccountApiConstants.lockinperiod_paramname).value(lockinPeriod).notNull(); + pendingEdit.setLockinPeriodFrequency(lockinPeriod); + } else { + pendingEdit.setLockinPeriodFrequency(account.getLockinPeriodFrequency()); + } + + if (command.hasParameter(ShareAccountApiConstants.lockperiodfrequencytype_paramname)) { + pendingEdit.setLockinPeriodFrequencyType( + extractPeriodType(command.integerValueOfParameterNamed(ShareAccountApiConstants.lockperiodfrequencytype_paramname))); + } else { + pendingEdit.setLockinPeriodFrequencyType(account.getLockinPeriodFrequencyType()); + } + + if (command.hasParameter(ShareAccountApiConstants.minimumactiveperiod_paramname)) { + final Integer minimumActivePeriod = command.integerValueOfParameterNamed(ShareAccountApiConstants.minimumactiveperiod_paramname); + baseDataValidator.reset().parameter(ShareAccountApiConstants.minimumactiveperiod_paramname).value(minimumActivePeriod) + .notNull(); + pendingEdit.setMinimumActivePeriodFrequency(minimumActivePeriod); + } else { + pendingEdit.setMinimumActivePeriodFrequency(account.getMinimumActivePeriodFrequency()); + } + + if (command.hasParameter(ShareAccountApiConstants.minimumactiveperiodfrequencytype_paramname)) { + pendingEdit.setMinimumActivePeriodFrequencyType(extractPeriodType( + command.integerValueOfParameterNamed(ShareAccountApiConstants.minimumactiveperiodfrequencytype_paramname))); + } else { + pendingEdit.setMinimumActivePeriodFrequencyType(account.getMinimumActivePeriodFrequencyType()); + } + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + final CommandSource auditEntry = CommandSource.builder() // + .actionName("SUBMITEDIT") // + .entityName("SHAREACCOUNT") // + .resourceGetUrl("/shareaccounts/" + shareAccountId) // + .resourceId(shareAccountId) // + .commandAsJson(command.json()) // + .maker(maker) // + .madeOnDate(DateUtils.getAuditOffsetDateTime()) // + .status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) // + .idempotencyKey(UUID.randomUUID().toString()) // + .sanitized(false) // + .build(); + this.commandSourceRepository.saveAndFlush(auditEntry); + pendingEdit.setCommandSourceId(auditEntry.getId()); + + this.pendingEditRepository.saveAndFlush(pendingEdit); + + return new CommandProcessingResultBuilder().withEntityId(shareAccountId).build(); + } + + @Transactional + @Override + public CommandProcessingResult approveEdit(final Long shareAccountId) { + + final AppUser checker = this.context.authenticatedUser(); + final ShareAccountPendingEdit pendingEdit = this.pendingEditRepository + .findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING) + .orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId)); + + if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId); + } + + final ShareAccount account = this.shareAccountRepositoryWrapper.findOneWithNotFoundDetection(shareAccountId); + + if (pendingEdit.getSavingsAccount() != null) { + account.setSavingsAccount(pendingEdit.getSavingsAccount()); + } + account.setAllowDividendCalculationForInactiveClients(pendingEdit.getAllowDividendCalculationForInactiveClients()); + if (pendingEdit.getLockinPeriodFrequency() != null) { + account.setLockPeriod(pendingEdit.getLockinPeriodFrequency()); + } + if (pendingEdit.getLockinPeriodFrequencyType() != null) { + account.setLockPeriodFrequencyEnum(pendingEdit.getLockinPeriodFrequencyType()); + } + if (pendingEdit.getMinimumActivePeriodFrequency() != null) { + account.setminimumActivePeriod(pendingEdit.getMinimumActivePeriodFrequency()); + } + if (pendingEdit.getMinimumActivePeriodFrequencyType() != null) { + account.setminimumActivePeriodTypeEnum(pendingEdit.getMinimumActivePeriodFrequencyType()); + } + + this.shareAccountRepositoryWrapper.saveAndFlush(account); + + pendingEdit.markApproved(checker); + this.pendingEditRepository.saveAndFlush(pendingEdit); + + updateAuditEntry(pendingEdit, checker, false); + + return new CommandProcessingResultBuilder().withEntityId(shareAccountId).build(); + } + + @Transactional + @Override + public CommandProcessingResult rejectEdit(final Long shareAccountId, final String rejectReason) { + + final AppUser checker = this.context.authenticatedUser(); + final ShareAccountPendingEdit pendingEdit = this.pendingEditRepository + .findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING) + .orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId)); + + if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId); + } + + pendingEdit.markRejected(checker, rejectReason); + this.pendingEditRepository.saveAndFlush(pendingEdit); + + updateAuditEntry(pendingEdit, checker, true); + + return new CommandProcessingResultBuilder().withEntityId(shareAccountId).build(); + } + + @Override + public ShareAccountPendingEditData retrievePendingEdit(final Long shareAccountId) { + return this.pendingEditRepository.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING) + .map(this::toData).orElse(null); + } + + @Override + public Collection retrieveAllPendingEdits() { + return this.pendingEditRepository.findByStatus(ShareAccountPendingEdit.STATUS_PENDING).stream().map(this::toData).toList(); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + private PeriodFrequencyType extractPeriodType(final Integer value) { + return value == null ? null : PeriodFrequencyType.fromInt(value); + } + + private void updateAuditEntry(final ShareAccountPendingEdit pendingEdit, final AppUser checker, final boolean rejected) { + Optional existing = Optional.empty(); + if (pendingEdit.getCommandSourceId() != null) { + existing = this.commandSourceRepository.findById(pendingEdit.getCommandSourceId()); + } + final CommandSource auditEntry = existing.orElseGet(() -> CommandSource.builder() // + .actionName(rejected ? "REJECTEDIT" : "APPROVEEDIT") // + .entityName("SHAREACCOUNT") // + .resourceGetUrl("/shareaccounts/" + pendingEdit.getShareAccount().getId()) // + .resourceId(pendingEdit.getShareAccount().getId()) // + .maker(pendingEdit.getMaker()) // + .madeOnDate(DateUtils.getAuditOffsetDateTime()) // + .status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) // + .idempotencyKey(UUID.randomUUID().toString()) // + .sanitized(false) // + .build()); + if (rejected) { + auditEntry.markAsRejected(checker); + } else { + auditEntry.markAsChecked(checker); + } + this.commandSourceRepository.saveAndFlush(auditEntry); + } + + private ShareAccountPendingEditData toData(final ShareAccountPendingEdit p) { + final ShareAccountPendingEditData data = new ShareAccountPendingEditData(); + data.setId(p.getId()); + data.setShareAccountId(p.getShareAccount().getId()); + data.setAccountNo(p.getShareAccount().getAccountNumber()); + data.setClientId(p.getShareAccount().getClientId()); + data.setMakerId(p.getMaker().getId()); + data.setMakerUsername(p.getMaker().getUsername()); + if (p.getChecker() != null) { + data.setCheckerId(p.getChecker().getId()); + data.setCheckerUsername(p.getChecker().getUsername()); + } + data.setCreatedOn(p.getCreatedOn()); + data.setCheckedOn(p.getCheckedOn()); + data.setStatus(p.getStatus()); + data.setRejectReason(p.getRejectReason()); + if (p.getSavingsAccount() != null) { + data.setSavingsAccountId(p.getSavingsAccount().getId()); + data.setSavingsAccountNo(p.getSavingsAccount().getAccountNumber()); + } + data.setAllowDividendCalculationForInactiveClients(p.getAllowDividendCalculationForInactiveClients()); + data.setLockinPeriodFrequency(p.getLockinPeriodFrequency()); + if (p.getLockinPeriodFrequencyType() != null) { + data.setLockinPeriodFrequencyType(p.getLockinPeriodFrequencyType().getValue()); + } + data.setMinimumActivePeriodFrequency(p.getMinimumActivePeriodFrequency()); + if (p.getMinimumActivePeriodFrequencyType() != null) { + data.setMinimumActivePeriodFrequencyType(p.getMinimumActivePeriodFrequencyType().getValue()); + } + return data; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java index a7126f6c9..f50ead8fb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java @@ -265,7 +265,8 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat final Collection purchasedShares) { this.charges = charges; this.purchasedShares = purchasedShares; - StringBuilder buff = new StringBuilder().append("sa.id as id, sa.external_id as externalId, sa.status_enum as statusEnum, ") + 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.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, ") @@ -302,6 +303,7 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat final Long id = rs.getLong("id"); final String accountNo = rs.getString("accountNo"); final String externalId = rs.getString("externalId"); + final String resolutionNumber = rs.getString("resolutionNumber"); final Long savingsAccountId = JdbcSupport.getLong(rs, "savings_account_id"); final String savingsAccountNumber = rs.getString("savingsAccNo"); final Long clientId = JdbcSupport.getLong(rs, "clientId"); @@ -373,9 +375,10 @@ 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, savingsAccountId, savingsAccountNumber, clientId, clientName, productId, - productName, status, timeline, currency, summary, charges, purchasedShares, lockinPeriodFrequency, - lockinPeriodFrequencyType, minimumActivePeriod, minimumActivePeriodType, allowdividendsforinactiveclients); + return new ShareAccountData(id, accountNo, externalId, resolutionNumber, savingsAccountId, savingsAccountNumber, clientId, + clientName, productId, productName, status, timeline, currency, summary, charges, purchasedShares, + lockinPeriodFrequency, lockinPeriodFrequencyType, minimumActivePeriod, minimumActivePeriodType, + allowdividendsforinactiveclients); } @@ -416,6 +419,7 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat final Long totalApprovedShares = null; final Long totalPendingShares = null; final String externalId = null; + final String resolutionNumber = null; final Long productId = null; final String productName = null; final String shortProductName = null; @@ -442,9 +446,9 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat final ShareAccountSummaryData summary = new ShareAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency, totalApprovedShares, totalPendingShares, timeline); - return new ShareAccountData(id, accountNo, externalId, clientId, clientName, productId, shortProductName, productId, - shortProductName, status, timeline, currency, summary, charges, purchasedSharesData, lockinPeriod, lockPeriodTypeEnum, - minimumActivePeriod, minimumActivePeriodTypeEnum, allowdividendsforinactiveclients); + return new ShareAccountData(id, accountNo, externalId, resolutionNumber, clientId, clientName, productId, shortProductName, + productId, shortProductName, status, timeline, currency, summary, charges, purchasedSharesData, lockinPeriod, + lockPeriodTypeEnum, minimumActivePeriod, minimumActivePeriodTypeEnum, allowdividendsforinactiveclients); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java index 13698df7a..c92890bda 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java @@ -34,7 +34,12 @@ import org.apache.fineract.portfolio.note.domain.NoteRepository; import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; 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.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper; +import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService; import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendRepository; +import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountPendingEditRepository; import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountRepositoryWrapper; import org.apache.fineract.portfolio.shareaccounts.serialization.ShareAccountDataSerializer; import org.apache.fineract.portfolio.shareaccounts.service.PurchasedSharesReadPlatformService; @@ -44,6 +49,8 @@ import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountChargeRea import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountCommandsServiceImpl; import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountDividendReadPlatformService; import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountDividendReadPlatformServiceImpl; +import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountEditWritePlatformService; +import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountEditWritePlatformServiceJpaRepositoryImpl; import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountReadPlatformService; import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountReadPlatformServiceImpl; import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountSchedularService; @@ -121,4 +128,14 @@ public class ShareAccountsConfiguration { accountNumberGenerator, accountNumberFormatRepository, journalEntryWritePlatformService, noteRepository, jdbcTemplate, businessEventNotifierService); } + + @Bean + @ConditionalOnMissingBean(ShareAccountEditWritePlatformService.class) + public ShareAccountEditWritePlatformService shareAccountEditWritePlatformService(PlatformSecurityContext context, + ShareAccountRepositoryWrapper shareAccountRepositoryWrapper, ShareAccountPendingEditRepository pendingEditRepository, + SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper, + SavingsAccountReadPlatformService savingsAccountReadPlatformService, CommandSourceRepository commandSourceRepository) { + return new ShareAccountEditWritePlatformServiceJpaRepositoryImpl(context, shareAccountRepositoryWrapper, pendingEditRepository, + savingsAccountRepositoryWrapper, savingsAccountReadPlatformService, commandSourceRepository); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductReadPlatformServiceImpl.java index a9525484e..0651f7de2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductReadPlatformServiceImpl.java @@ -19,7 +19,6 @@ package org.apache.fineract.portfolio.shareproducts.service; import com.google.gson.Gson; -import com.google.gson.reflect.TypeToken; import java.math.BigDecimal; import java.sql.Date; import java.sql.ResultSet; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorBankApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorBankApiResource.java new file mode 100644 index 000000000..c006f010a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorBankApiResource.java @@ -0,0 +1,139 @@ +/** + * 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.sponsordd.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.function.Supplier; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorBank; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorBankRepository; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.stereotype.Component; +import org.apache.fineract.infrastructure.core.service.DateUtils; + +@Path("/v1/sponsorbanks") +@Component +@Tag(name = "Sponsor Bank", description = "Endpoints for managing sponsor banks.") +@RequiredArgsConstructor +public class SponsorBankApiResource { + + private final SponsorBankRepository sponsorBankRepository; + private final PlatformSecurityContext context; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + /** Mirrors SponsorDDApiResource.safely() - see that class for the full rationale. */ + private T safely(final Supplier action) { + try { + return action.get(); + } catch (final IllegalArgumentException | IllegalStateException e) { + final JsonObject body = new JsonObject(); + body.addProperty("message", e.getMessage()); + body.addProperty("defaultUserMessage", e.getMessage()); + body.addProperty("httpStatus", 400); + final JsonArray errors = new JsonArray(); + final JsonObject err = new JsonObject(); + err.addProperty("defaultUserMessage", e.getMessage()); + errors.add(err); + body.add("errors", errors); + throw new WebApplicationException( + Response.status(400).entity(this.gson.toJson(body)).type(MediaType.APPLICATION_JSON).build()); + } + } + + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve all Sponsor Banks", description = "Returns a list of all registered Sponsor Banks.") + public Collection retrieveAllSponsorBanks() { + return this.sponsorBankRepository.findAll().stream() + .map(sb -> new SponsorBankData(sb.getId(), sb.getName(), sb.getDescription(), sb.getIfscCode(), sb.getBankCode(), + sb.getSwiftCode())) + .toList(); + } + + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Create a Sponsor Bank", description = "Creates a new Sponsor Bank.") + public CommandProcessingResult createSponsorBank(final SponsorBankRequest request) { + return safely(() -> { + if (request.getIfscCode() != null && !request.getIfscCode().isBlank() + && !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) { + throw new IllegalArgumentException( + "IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234)."); + } + final AppUser currentUser = this.context.authenticatedUser(); + final SponsorBank sponsorBank = new SponsorBank( + request.getName(), + request.getDescription(), + request.getIfscCode() != null ? request.getIfscCode().trim().toUpperCase() : null, + request.getBankCode(), + request.getSwiftCode() != null ? request.getSwiftCode().trim().toUpperCase() : null, + currentUser, + LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()) + ); + this.sponsorBankRepository.saveAndFlush(sponsorBank); + return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build(); + }); + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class SponsorBankData { + private Long id; + private String name; + private String description; + private String ifscCode; + private String bankCode; + private String swiftCode; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class SponsorBankRequest { + private String name; + private String description; + private String ifscCode; + private String bankCode; + private String swiftCode; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java new file mode 100644 index 000000000..a2d1dcd0c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java @@ -0,0 +1,633 @@ +/** + * 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.sponsordd.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Collection; +import java.util.List; +import java.util.function.Supplier; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDCashierDepositOptionData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDGlMappingData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDInventoryData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDRecoBatch; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData; +import org.apache.fineract.portfolio.sponsordd.service.SponsorDDIssueRequestWritePlatformService; +import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReadPlatformService; +import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReconciliationService; +import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReprintRequestWritePlatformService; +import org.apache.fineract.portfolio.sponsordd.service.SponsorDDWritePlatformService; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.stereotype.Component; + +@Path("/v1/sponsordd") +@Component +@Tag(name = "Sponsor Demand Draft", description = "Endpoints for managing sponsor bank demand draft inventory, leaflet lifecycles, and clearing reconciliation.") +@RequiredArgsConstructor +public class SponsorDDApiResource { + + private final SponsorDDReadPlatformService readPlatformService; + private final SponsorDDWritePlatformService writePlatformService; + private final SponsorDDReconciliationService reconciliationService; + private final SponsorDDIssueRequestWritePlatformService issueRequestWritePlatformService; + private final SponsorDDReprintRequestWritePlatformService reprintRequestWritePlatformService; + private final PlatformSecurityContext context; + private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + /** + * Business-rule validation in this module is expressed with plain IllegalArgumentException / + * IllegalStateException (see SponsorDDWritePlatformServiceImpl / SponsorDDIssueRequestWritePlatformServiceImpl). + * Those aren't mapped by Fineract's global exception handler, so left uncaught they surface to the + * frontend as an opaque 500. This wraps every write endpoint so validation failures come back as a + * proper 400 with the actual message. + */ + private T safely(final Supplier action) { + try { + return action.get(); + } catch (final IllegalArgumentException | IllegalStateException e) { + throw apiError(400, e.getMessage()); + } + } + + private WebApplicationException apiError(final int httpStatus, final String message) { + final JsonObject body = new JsonObject(); + body.addProperty("message", message); + body.addProperty("defaultUserMessage", message); + body.addProperty("httpStatus", httpStatus); + final JsonArray errors = new JsonArray(); + final JsonObject err = new JsonObject(); + err.addProperty("defaultUserMessage", message); + errors.add(err); + body.add("errors", errors); + return new WebApplicationException( + Response.status(httpStatus).entity(this.gson.toJson(body)).type(MediaType.APPLICATION_JSON).build()); + } + + @GET + @Path("inventory") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve all DD Inventories", description = "Returns a list of all registered Sponsor DD Inventory blocks.") + public Collection retrieveAllInventories() { + return this.readPlatformService.retrieveAllInventories(); + } + + @GET + @Path("inventory/{inventoryId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve DD Inventory block details", description = "Returns detail metadata for a specific DD inventory block.") + public SponsorDDInventoryData retrieveInventory(@PathParam("inventoryId") final Long inventoryId) { + return this.readPlatformService.retrieveInventory(inventoryId); + } + + @POST + @Path("inventory") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Register new DD Inventory block", description = "Creates a new DD inventory series and auto-generates associated available leaflets.") + public CommandProcessingResult registerInventory(final RegisterInventoryRequest request) { + final LocalDate receivedDate = LocalDate.parse(request.getReceivedDate(), this.dateFormatter); + return safely(() -> this.writePlatformService.registerInventory( + request.getSponsorBankId(), + request.getBranchId(), + request.getSeriesStart(), + request.getSeriesEnd(), + receivedDate + )); + } + + @GET + @Path("inventory/{inventoryId}/leaflets") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve all leaflets in an inventory", description = "Returns status registry list of all leaflets under a specific inventory batch.") + public Collection retrieveAllLeafletsByInventory(@PathParam("inventoryId") final Long inventoryId) { + return this.readPlatformService.retrieveAllLeafletsByInventory(inventoryId); + } + + @GET + @Path("leaflets/{leafletId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve specific leaflet details", description = "Returns detailed metadata and audit properties for a single leaflet.") + public SponsorDDLeafletData retrieveLeaflet(@PathParam("leafletId") final Long leafletId) { + return this.readPlatformService.retrieveLeaflet(leafletId); + } + + @GET + @Path("leaflets/by-number/{ddNumber}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Find leaflet by DD Number", description = "Finds and returns a leaflet's data structure searching by its pre-printed DD Number.") + public SponsorDDLeafletData retrieveLeafletByDdNumber(@PathParam("ddNumber") final String ddNumber) { + return this.readPlatformService.retrieveLeafletByDdNumber(ddNumber); + } + + @POST + @Path("leaflets/{leafletId}/issue") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Issue a demand draft", description = "Transitions an AVAILABLE leaflet to ISSUED and links it to customer debit transactions.") + public CommandProcessingResult issueDemandDraft(@PathParam("leafletId") final Long leafletId, final IssueDDRequest request) { + final LocalDate issuedDate = LocalDate.parse(request.getIssuedDate(), this.dateFormatter); + return safely(() -> this.writePlatformService.issueDemandDraft( + leafletId, + request.getClientId(), + null, + null, + null, + request.getBeneficiaryName(), + request.getFaceValue(), + request.getIssuingBranchId(), + issuedDate, + request.getSavingsTransactionId(), + request.getCurrentTransactionId(), + request.getJournalEntryId(), + null, + null + )); + } + + @POST + @Path("leaflets/{leafletId}/cancel") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Cancel a demand draft", description = "Transitions an ISSUED leaflet to CANCELLED and maps customer refund details.") + public CommandProcessingResult cancelDemandDraft(@PathParam("leafletId") final Long leafletId, final CancelDDRequest request) { + return safely(() -> this.writePlatformService.cancelDemandDraft( + leafletId, + request.getRemarks(), + request.getRefundSavingsTransactionId(), + request.getRefundCurrentTransactionId() + )); + } + + @POST + @Path("leaflets/{leafletId}/stop") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Process Stop Payment instruction", description = "Transitions an ISSUED leaflet to STOPPED.") + public CommandProcessingResult stopPaymentDemandDraft(@PathParam("leafletId") final Long leafletId, final StopDDRequest request) { + return safely(() -> this.writePlatformService.stopPaymentDemandDraft(leafletId, request.getRemarks())); + } + + @GET + @Path("leaflets") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve leaflets with filter", description = "Returns status registry list of leaflets filtered by sponsor bank, status, and advice status.") + public Collection retrieveLeaflets( + @QueryParam("sponsorBankId") final Long sponsorBankId, + @QueryParam("status") final String status, + @QueryParam("adviceStatus") final String adviceStatus) { + return this.readPlatformService.retrieveLeaflets(sponsorBankId, status, adviceStatus); + } + + @POST + @Path("leaflets/send-advice") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Send advice to sponsor bank", description = "Marks all issued, unsent leaflets for a specific sponsor bank as sent.") + public CommandProcessingResult sendAdvice(final SendAdviceRequest request) { + return safely(() -> this.writePlatformService.sendAdvice(request.getSponsorBankId())); + } + + // ── Maker-checker DD issuance (clerk submits, manager/head-cashier approves) ── + + @POST + @Path("leaflets/{leafletId}/issue-request") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Submit a DD issuance request", description = "Clerk (maker) step: records a pending DD issuance request funded either by an account debit or a cheque. No money moves until a manager approves it.") + public CommandProcessingResult submitIssueRequest(@PathParam("leafletId") final Long leafletId, final IssueRequestRequest request) { + final LocalDate issuedDate = LocalDate.parse(request.getIssuedDate(), this.dateFormatter); + return safely(() -> this.issueRequestWritePlatformService.submitIssueRequest( + leafletId, + request.getClientId(), + request.getWalkinPurchaserName(), + request.getWalkinPurchaserAddress(), + request.getWalkinPurchaserAadhaar(), + request.getBeneficiaryName(), + request.getPayableAtBranch(), + request.getPayableAtCity(), + request.getFaceValue(), + request.getChargesAmount(), + request.getIssuingBranchId(), + issuedDate, + request.getPaymentMethod(), + request.getSavingsAccountId(), + request.getCurrentAccountId(), + request.getChequeNumber(), + request.getChequeBankName(), + request.getCashierDepositRequestId() + )); + } + + @POST + @Path("inventory/{inventoryId}/issue-request-batch") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Submit a multi-DD slip", description = "Clerk (maker) step: one account debit or cheque funds several drafts (different beneficiaries/amounts, charges optional per line) submitted together. A physical leaflet is auto-allocated per line from this inventory - the response shows exactly which DD number and beneficiary/amount was assigned to each line. No money moves until a manager approves the whole slip.") + public SponsorDDBatchIssueResult submitIssueRequestBatch(@PathParam("inventoryId") final Long inventoryId, + final IssueRequestBatchRequest request) { + final LocalDate issuedDate = LocalDate.parse(request.getIssuedDate(), this.dateFormatter); + final List lines = request.getLines() == null ? List.of() + : request.getLines().stream() + .map(l -> new SponsorDDSlipLine(l.getBeneficiaryName(), l.getPayableAtBranch(), l.getPayableAtCity(), + l.getFaceValue(), l.getChargesAmount())) + .collect(java.util.stream.Collectors.toList()); + return safely(() -> this.issueRequestWritePlatformService.submitIssueRequestBatch( + inventoryId, + request.getClientId(), + request.getIssuingBranchId(), + issuedDate, + request.getPaymentMethod(), + request.getSavingsAccountId(), + request.getCurrentAccountId(), + request.getChequeNumber(), + request.getChequeBankName(), + lines + )); + } + + @POST + @Path("issue-requests/batch/{batchId}/approve") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Approve a multi-DD slip", description = "Manager/head-cashier (checker) step: one combined GL posting for the whole slip's total, then issues every draft in it. Approves every line together, not one at a time.") + public CommandProcessingResult approveIssueRequestBatch(@PathParam("batchId") final Long batchId) { + return safely(() -> this.issueRequestWritePlatformService.approveIssueRequestBatch(batchId)); + } + + @POST + @Path("issue-requests/batch/{batchId}/reject") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Reject a multi-DD slip", description = "Manager/head-cashier (checker) step: rejects every line of a pending slip together. All leaflets remain AVAILABLE.") + public CommandProcessingResult rejectIssueRequestBatch(@PathParam("batchId") final Long batchId, final RejectIssueRequestRequest request) { + return safely(() -> this.issueRequestWritePlatformService.rejectIssueRequestBatch(batchId, request.getRejectionReason())); + } + + @GET + @Path("cashier-deposit-requests/available") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve available cashier deposit requests for CASH DD issuance", description = "Returns approved cashier Transaction Entry (AC.TP=GL) deposit requests credited to the DD Cash Suspense GL account, not yet linked to any DD issuance request.") + public Collection retrieveAvailableCashierDepositRequests() { + return this.readPlatformService.retrieveAvailableCashierDepositRequests(); + } + + @GET + @Path("issue-requests") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve DD issuance requests", description = "Returns all DD issuance requests, optionally filtered by status (PENDING/APPROVED/REJECTED), for the manager approval queue.") + public Collection retrieveAllIssueRequests(@QueryParam("status") final String status) { + return this.readPlatformService.retrieveAllIssueRequests(status); + } + + @GET + @Path("issue-requests/mine") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve my DD issuance requests", description = "Returns the DD issuance requests submitted by the current clerk.") + public Collection retrieveMyIssueRequests() { + return this.readPlatformService.retrieveMyIssueRequests(this.context.authenticatedUser().getId()); + } + + @GET + @Path("issue-requests/{issueRequestId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve a DD issuance request", description = "Returns detail for a single DD issuance request.") + public SponsorDDIssueRequestData retrieveIssueRequest(@PathParam("issueRequestId") final Long issueRequestId) { + return this.readPlatformService.retrieveIssueRequest(issueRequestId); + } + + @POST + @Path("issue-requests/{issueRequestId}/approve") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Approve a DD issuance request", description = "Manager/head-cashier (checker) step: performs the real GL posting (client account debit or cheque-clearing debit, credited to the Sponsor DD Payable GL account) and issues the demand draft.") + public CommandProcessingResult approveIssueRequest(@PathParam("issueRequestId") final Long issueRequestId) { + return safely(() -> this.issueRequestWritePlatformService.approveIssueRequest(issueRequestId)); + } + + @POST + @Path("issue-requests/{issueRequestId}/reject") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Reject a DD issuance request", description = "Manager/head-cashier (checker) step: rejects a pending DD issuance request. The leaflet remains AVAILABLE.") + public CommandProcessingResult rejectIssueRequest(@PathParam("issueRequestId") final Long issueRequestId, final RejectIssueRequestRequest request) { + return safely(() -> this.issueRequestWritePlatformService.rejectIssueRequest(issueRequestId, request.getRejectionReason())); + } + + // ── DD reprint requests (maker-checker) ───────────────────────────────── + + @POST + @Path("leaflets/{leafletId}/print") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Record a print of an issued DD", description = "First print of a leaflet is always allowed. Any print after that consumes a previously-approved reprint request, or fails if none exists.") + public CommandProcessingResult recordPrint(@PathParam("leafletId") final Long leafletId) { + return safely(() -> this.reprintRequestWritePlatformService.recordPrint(leafletId)); + } + + @POST + @Path("leaflets/{leafletId}/reprint-request") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Request permission to reprint a DD", description = "Clerk (maker) step: requests manager approval to print an already-printed leaflet again.") + public CommandProcessingResult requestReprint(@PathParam("leafletId") final Long leafletId, final ReprintRequestRequest request) { + return safely(() -> this.reprintRequestWritePlatformService.requestReprint(leafletId, request.getReason())); + } + + @GET + @Path("reprint-requests") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve DD reprint requests", description = "Returns all DD reprint requests, optionally filtered by status (PENDING/APPROVED/REJECTED), for the manager approval queue.") + public Collection retrieveAllReprintRequests(@QueryParam("status") final String status) { + return this.readPlatformService.retrieveAllReprintRequests(status); + } + + @POST + @Path("reprint-requests/{reprintRequestId}/approve") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Approve a DD reprint request", description = "Manager/head-cashier (checker) step: allows one further print of the leaflet.") + public CommandProcessingResult approveReprintRequest(@PathParam("reprintRequestId") final Long reprintRequestId) { + return safely(() -> this.reprintRequestWritePlatformService.approveReprintRequest(reprintRequestId)); + } + + @POST + @Path("reprint-requests/{reprintRequestId}/reject") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Reject a DD reprint request", description = "Manager/head-cashier (checker) step: rejects a pending DD reprint request.") + public CommandProcessingResult rejectReprintRequest(@PathParam("reprintRequestId") final Long reprintRequestId, final RejectIssueRequestRequest request) { + return safely(() -> this.reprintRequestWritePlatformService.rejectReprintRequest(reprintRequestId, request.getRejectionReason())); + } + + // ── Sponsor DD GL account configuration ─────────────────────────────── + + @GET + @Path("gl-mapping") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve Sponsor DD GL mappings", description = "Returns the configured GL accounts (DD Payable liability + cheque-clearing suspense) for each sponsor bank / payment type.") + public Collection retrieveAllGlMappings() { + return this.readPlatformService.retrieveAllGlMappings(); + } + + @POST + @Path("gl-mapping") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Configure a Sponsor DD GL mapping", description = "Creates or updates the DD Payable / cheque-clearing GL accounts for a sponsor bank / payment type.") + public CommandProcessingResult upsertGlMapping(final GlMappingRequest request) { + return safely(() -> this.issueRequestWritePlatformService.upsertGlMapping( + request.getSponsorBankId(), + request.getDdPayableGlAccountId(), + request.getChequeClearingGlAccountId(), + request.getCashGlAccountId(), + request.getChargesIncomeGlAccountId(), + request.getCashSuspenseGlAccountId(), + request.getGstPercentage(), + request.getGstPayableGlAccountId(), + request.getDdLimitAmount())); + } + + @POST + @Path("reconcile/upload") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Upload and process clearing scroll", description = "Imports Sponsor Bank clearing scroll content, matching DD numbers and face values.") + public RecoBatchResponse uploadAndReconcile(final RecoUploadRequest request) { + return safely(() -> { + final SponsorDDRecoBatch batch = this.reconciliationService.uploadAndReconcile(request.getFileName(), request.getFileContent()); + return new RecoBatchResponse( + batch.getId(), + batch.getBatchReference(), + batch.getStatus(), + batch.getTotalRecords(), + batch.getSuccessCount(), + batch.getMismatchCount() + ); + }); + } + + @POST + @Path("reconcile/resolve/{recordId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Resolve matching exception", description = "Manually resolves a reconciliation mismatch by approving force-match matching.") + public CommandProcessingResult resolveMismatch(@PathParam("recordId") final Long recordId, final ResolveMismatchRequest request) { + return safely(() -> this.reconciliationService.resolveMismatch( + recordId, + request.getActionType(), + request.getResolvedSavingsTxnId(), + request.getResolvedCurrentTxnId() + )); + } + + @GET + @Path("reconcile/batches") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve all reconciliation batches", description = "Returns a list of all clearing scrolls processed.") + public Collection retrieveAllReconciliationBatches() { + return this.readPlatformService.retrieveAllReconciliationBatches(); + } + + @GET + @Path("reconcile/batches/{batchId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve reconciliation batch detail", description = "Returns metadata for a specific reconciliation upload batch.") + public org.apache.fineract.portfolio.sponsordd.data.SponsorDDRecoBatchData retrieveReconciliationBatch(@PathParam("batchId") final Long batchId) { + return this.readPlatformService.retrieveReconciliationBatch(batchId); + } + + @GET + @Path("reconcile/batches/{batchId}/records") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve records in a reconciliation batch", description = "Returns all matched and exception scroll records within a specific batch.") + public Collection retrieveReconciliationRecordsByBatch(@PathParam("batchId") final Long batchId) { + return this.readPlatformService.retrieveReconciliationRecordsByBatch(batchId); + } + + // Requests DTOs + @lombok.Data + public static class RegisterInventoryRequest { + private Long sponsorBankId; + private Long branchId; + private Long seriesStart; + private Long seriesEnd; + private String receivedDate; + } + + @lombok.Data + public static class IssueDDRequest { + private Long clientId; + private String beneficiaryName; + private BigDecimal faceValue; + private Long issuingBranchId; + private String issuedDate; + private Long savingsTransactionId; + private Long currentTransactionId; + private Long journalEntryId; + } + + @lombok.Data + public static class CancelDDRequest { + private String remarks; + private Long refundSavingsTransactionId; + private Long refundCurrentTransactionId; + } + + @lombok.Data + public static class StopDDRequest { + private String remarks; + } + + @lombok.Data + public static class RecoUploadRequest { + private String fileName; + private String fileContent; + } + + @lombok.Data + public static class ResolveMismatchRequest { + private String actionType; // FORCE_MATCH + private Long resolvedSavingsTxnId; + private Long resolvedCurrentTxnId; + } + + @lombok.Data + public static class IssueRequestRequest { + private Long clientId; + // Walk-in (non-client) purchaser details — required instead of clientId when paymentMethod is CASH. + private String walkinPurchaserName; + private String walkinPurchaserAddress; + private String walkinPurchaserAadhaar; // raw 12-digit Aadhaar — masked to last 4 digits before storage + private String beneficiaryName; + private String payableAtBranch; + private String payableAtCity; + private BigDecimal faceValue; + private BigDecimal chargesAmount; // DD issuance commission/charges, if any + private Long issuingBranchId; + private String issuedDate; + private String paymentMethod; // ACCOUNT_DEBIT, CHEQUE, or CASH + private Long savingsAccountId; + private Long currentAccountId; + private String chequeNumber; + private String chequeBankName; + // CASH only — the cashier's already-APPROVED Transaction Entry (AC.TP=GL) deposit request + // that received this cash, credited to the DD Cash Suspense GL account. + private Long cashierDepositRequestId; + } + + @lombok.Data + public static class IssueRequestBatchRequest { + private Long clientId; + private Long issuingBranchId; + private String issuedDate; + private String paymentMethod; // ACCOUNT_DEBIT or CHEQUE only — a slip does not support CASH + private Long savingsAccountId; + private Long currentAccountId; + private String chequeNumber; + private String chequeBankName; + private List lines; + } + + @lombok.Data + public static class SlipLineRequest { + private String beneficiaryName; + private String payableAtBranch; + private String payableAtCity; + private BigDecimal faceValue; + private BigDecimal chargesAmount; + } + + @lombok.Data + public static class RejectIssueRequestRequest { + private String rejectionReason; + } + + @lombok.Data + public static class ReprintRequestRequest { + private String reason; + } + + @lombok.Data + public static class GlMappingRequest { + private Long sponsorBankId; + private Long ddPayableGlAccountId; + private Long chequeClearingGlAccountId; + private Long cashGlAccountId; + private Long chargesIncomeGlAccountId; + private Long cashSuspenseGlAccountId; + private BigDecimal gstPercentage; + private Long gstPayableGlAccountId; + private BigDecimal ddLimitAmount; + } + + @lombok.Data + @lombok.AllArgsConstructor + public static class RecoBatchResponse { + private Long id; + private String batchReference; + private String status; + private Integer totalRecords; + private Integer successCount; + private Integer mismatchCount; + } + + @lombok.Data + public static class SendAdviceRequest { + private Long sponsorBankId; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDBatchIssueResult.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDBatchIssueResult.java new file mode 100644 index 000000000..0713ebca3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDBatchIssueResult.java @@ -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.sponsordd.data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * Response for submitIssueRequestBatch - tells the maker exactly which physical leaflet (DD number) + * was auto-allocated to each line and for what amount, since that allocation only happens inside the + * submit transaction (locked against concurrent slips) and can't be known beforehand. + */ +public record SponsorDDBatchIssueResult(Long batchId, List lines) { + + public record Line(Long issueRequestId, Long leafletId, String ddNumber, String beneficiaryName, BigDecimal faceValue) {} +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDCashierDepositOptionData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDCashierDepositOptionData.java new file mode 100644 index 000000000..e7146c701 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDCashierDepositOptionData.java @@ -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.sponsordd.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * A cashier's Transaction Entry (AC.TP=GL) deposit request, already approved, credited to the DD + * Cash Suspense GL account, and not yet linked to any Sponsor DD issue request - i.e. available to + * be selected as the cash-receipt evidence for a new CASH DD issuance request. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDCashierDepositOptionData implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private String voucherNo; + private BigDecimal amount; + private String description; + private String cashierUsername; + private LocalDateTime actionedAt; + // DD face value / charges / GST breakdown captured at Transaction Entry submit time - null for + // deposit requests that were not raised as a DD + Cash + GL row. + private BigDecimal ddFaceValue; + private BigDecimal ddCharges; + private BigDecimal ddGstPercentage; + private BigDecimal ddGstAmount; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDGlMappingData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDGlMappingData.java new file mode 100644 index 000000000..04a78d787 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDGlMappingData.java @@ -0,0 +1,50 @@ +/** + * 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.sponsordd.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDGlMappingData implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private Long sponsorBankId; + private String sponsorBankName; + private Long ddPayableGlAccountId; + private String ddPayableGlAccountName; + private Long chequeClearingGlAccountId; + private String chequeClearingGlAccountName; + private Long cashGlAccountId; + private String cashGlAccountName; + private Long chargesIncomeGlAccountId; + private String chargesIncomeGlAccountName; + private Long cashSuspenseGlAccountId; + private String cashSuspenseGlAccountName; + private BigDecimal gstPercentage; + private Long gstPayableGlAccountId; + private String gstPayableGlAccountName; + private BigDecimal ddLimitAmount; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDInventoryData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDInventoryData.java new file mode 100644 index 000000000..956b7a684 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDInventoryData.java @@ -0,0 +1,45 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sponsordd.data; + +import java.io.Serializable; +import java.time.LocalDate; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDInventoryData implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private Long sponsorBankId; + private String sponsorBankName; + private Long branchId; + private String branchName; + private Long seriesStart; + private Long seriesEnd; + private Integer totalLeaves; + private LocalDate receivedDate; + private String status; + /** Leaflets in this inventory still in AVAILABLE status (not yet issued/cancelled/stopped). */ + private Integer availableLeaves; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDIssueRequestData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDIssueRequestData.java new file mode 100644 index 000000000..7368ea700 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDIssueRequestData.java @@ -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.sponsordd.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDIssueRequestData implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private Long leafletId; + private String ddNumber; + private Long clientId; + private String clientName; + private String beneficiaryName; + private String payableAtBranch; + private String payableAtCity; + private BigDecimal faceValue; + private Long issuingBranchId; + private String issuingBranchName; + private LocalDate issuedDate; + private String paymentMethod; + private Long savingsAccountId; + private Long currentAccountId; + private String chequeNumber; + private String chequeBankName; + private String walkinPurchaserName; + private String walkinPurchaserAddress; + private String walkinPurchaserAadhaarMasked; + private BigDecimal chargesAmount; + private BigDecimal gstAmount; + private BigDecimal gstPercentageApplied; + private String status; + private Long makerId; + private String makerUsername; + private LocalDateTime createdDate; + private Long checkerId; + private String checkerUsername; + private LocalDateTime checkerDate; + private String rejectionReason; + private Long resultingSavingsTransactionId; + private Long resultingCurrentTransactionId; + private Long resultingJournalEntryId; + private Long cashReceiptJournalEntryId; + private Long cashRefundJournalEntryId; + /** Groups rows submitted together as one multi-DD slip (see submitIssueRequestBatch). Null for an ordinary single-DD request. */ + private Long batchId; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDLeafletData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDLeafletData.java new file mode 100644 index 000000000..8e1e75f71 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDLeafletData.java @@ -0,0 +1,60 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sponsordd.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDLeafletData implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private Long inventoryId; + private String ddNumber; + private String status; + private BigDecimal faceValue; + private Long issuedToClientId; + private String issuedToClientName; + private String walkinPurchaserName; + private String walkinPurchaserAddress; + private String walkinPurchaserAadhaarMasked; + private String beneficiaryName; + private String payableAtBranch; + private String payableAtCity; + private Long issuingBranchId; + private String issuingBranchName; + private LocalDate issuedDate; + private LocalDate clearedDate; + private String adviceStatus; + private LocalDateTime adviceSentDate; + private Long pendingIssueRequestId; + private Integer printCount; + private Long pendingReprintRequestId; + private Long approvedUnconsumedReprintRequestId; + private Long approverUserId; + private String approverUsername; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDRecoBatchData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDRecoBatchData.java new file mode 100644 index 000000000..b273e4849 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDRecoBatchData.java @@ -0,0 +1,45 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sponsordd.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDRecoBatchData implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private String batchReference; + private String fileName; + private String fileHash; + private String status; + private Integer totalRecords; + private BigDecimal totalAmount; + private Integer successCount; + private Integer mismatchCount; + private String createdByUsername; + private LocalDateTime createdDate; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDRecoRecordData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDRecoRecordData.java new file mode 100644 index 000000000..e25d74bdd --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDRecoRecordData.java @@ -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.sponsordd.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDRecoRecordData implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private Long batchId; + private String ddNumber; + private BigDecimal clearingAmount; + private LocalDate clearingDate; + private String clearingRefNumber; + private String matchStatus; + private Long matchedLeafletId; + private String remarks; + private String resolvedByUsername; + private LocalDateTime resolvedDate; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDReprintRequestData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDReprintRequestData.java new file mode 100644 index 000000000..ae84a698f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDReprintRequestData.java @@ -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.sponsordd.data; + +import java.io.Serializable; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDReprintRequestData implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private Long leafletId; + private String ddNumber; + private String reason; + private String status; + private Boolean consumed; + private Long makerId; + private String makerUsername; + private LocalDateTime createdDate; + private Long checkerId; + private String checkerUsername; + private LocalDateTime checkerDate; + private String rejectionReason; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDSlipLine.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDSlipLine.java new file mode 100644 index 000000000..ca452808e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDSlipLine.java @@ -0,0 +1,31 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sponsordd.data; + +import java.math.BigDecimal; + +/** + * One line of a multi-DD "slip" - a customer fills one slip listing several drafts (different + * beneficiaries/amounts, charges optional per line) funded from one account debit or one cheque; the + * combined total across every line is what actually gets deducted. Everything else (payment method, + * account, cheque details, issuing branch, issued date) is shared across the whole slip and carried + * separately, not per line. + */ +public record SponsorDDSlipLine(String beneficiaryName, String payableAtBranch, String payableAtCity, BigDecimal faceValue, + BigDecimal chargesAmount) {} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorBank.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorBank.java new file mode 100644 index 000000000..59db2f8d7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorBank.java @@ -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.sponsordd.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.useradministration.domain.AppUser; + +@Getter +@Setter +@Entity +@Table(name = "m_sponsor_bank") +@NoArgsConstructor +@AllArgsConstructor +public class SponsorBank extends AbstractPersistableCustom { + + @Column(name = "name", nullable = false, unique = true, length = 100) + private String name; + + @Column(name = "description", length = 255) + private String description; + + @Column(name = "ifsc_code", length = 11) + private String ifscCode; + + @Column(name = "bank_code", length = 20) + private String bankCode; + + @Column(name = "swift_code", length = 11) + private String swiftCode; + + @ManyToOne + @JoinColumn(name = "createdby_id", nullable = false) + private AppUser createdBy; + + @Column(name = "created_date", nullable = false) + private LocalDateTime createdDate; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorBankRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorBankRepository.java new file mode 100644 index 000000000..0fa2f7a12 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorBankRepository.java @@ -0,0 +1,27 @@ +/** + * 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.sponsordd.domain; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface SponsorBankRepository extends JpaRepository, JpaSpecificationExecutor { + Optional findByName(String name); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDAuditLog.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDAuditLog.java new file mode 100644 index 000000000..47cb68407 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDAuditLog.java @@ -0,0 +1,70 @@ +/** + * 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.sponsordd.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.useradministration.domain.AppUser; + +@Getter +@Setter +@Entity +@Table(name = "m_sponsor_dd_audit_log") +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDAuditLog extends AbstractPersistableCustom { + + @ManyToOne + @JoinColumn(name = "leaflet_id", nullable = false) + private SponsorDDLeaflet leaflet; + + @Column(name = "action_type", nullable = false, length = 30) + private String actionType; + + @Column(name = "pre_status", length = 20) + private String preStatus; + + @Column(name = "post_status", nullable = false, length = 20) + private String postStatus; + + @ManyToOne + @JoinColumn(name = "changed_by_id", nullable = false) + private AppUser changedBy; + + @Column(name = "client_ip", nullable = false, length = 45) + private String clientIp; + + @Column(name = "payload_snapshot") + private String payloadSnapshot; + + @Column(name = "remarks", length = 255) + private String remarks; + + @Column(name = "created_date", nullable = false) + private LocalDateTime createdDate; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDAuditLogRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDAuditLogRepository.java new file mode 100644 index 000000000..4131beb9d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDAuditLogRepository.java @@ -0,0 +1,25 @@ +/** + * 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.sponsordd.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface SponsorDDAuditLogRepository extends JpaRepository, JpaSpecificationExecutor { +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDInventory.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDInventory.java new file mode 100644 index 000000000..6aae865a1 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDInventory.java @@ -0,0 +1,80 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sponsordd.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.useradministration.domain.AppUser; + +@Getter +@Setter +@Entity +@Table(name = "m_sponsor_dd_inventory") +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDInventory extends AbstractPersistableCustom { + + @ManyToOne + @JoinColumn(name = "sponsor_bank_id", nullable = false) + private SponsorBank sponsorBank; + + @ManyToOne + @JoinColumn(name = "branch_id", nullable = false) + private Office branch; + + @Column(name = "series_start", nullable = false) + private Long seriesStart; + + @Column(name = "series_end", nullable = false) + private Long seriesEnd; + + @Column(name = "total_leaves", nullable = false) + private Integer totalLeaves; + + @Column(name = "received_date", nullable = false) + private LocalDate receivedDate; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @ManyToOne + @JoinColumn(name = "createdby_id", nullable = false) + private AppUser createdBy; + + @Column(name = "created_date", nullable = false) + private LocalDateTime createdDate; + + @ManyToOne + @JoinColumn(name = "lastmodifiedby_id") + private AppUser lastModifiedBy; + + @Column(name = "lastmodified_date") + private LocalDateTime lastModifiedDate; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDInventoryRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDInventoryRepository.java new file mode 100644 index 000000000..6a4b070c3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDInventoryRepository.java @@ -0,0 +1,25 @@ +/** + * 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.sponsordd.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface SponsorDDInventoryRepository extends JpaRepository, JpaSpecificationExecutor { +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDLeaflet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDLeaflet.java new file mode 100644 index 000000000..5aa64a285 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDLeaflet.java @@ -0,0 +1,117 @@ +/** + * 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.sponsordd.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.useradministration.domain.AppUser; + +@Getter +@Setter +@Entity +@Table(name = "m_sponsor_dd_leaflet") +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDLeaflet extends AbstractPersistableCustom { + + @ManyToOne + @JoinColumn(name = "inventory_id", nullable = false) + private SponsorDDInventory inventory; + + @Column(name = "dd_number", nullable = false, unique = true, length = 30) + private String ddNumber; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @Column(name = "face_value", nullable = false, precision = 19, scale = 6) + private BigDecimal faceValue; + + @ManyToOne + @JoinColumn(name = "issued_to_client_id") + private Client issuedToClient; + + @Column(name = "beneficiary_name", length = 150) + private String beneficiaryName; + + @Column(name = "payable_at_branch", length = 150) + private String payableAtBranch; + + @Column(name = "payable_at_city", length = 100) + private String payableAtCity; + + /** Set instead of issuedToClient when the purchaser is a walk-in, not a bank client (cash payment). */ + @Column(name = "walkin_purchaser_name", length = 150) + private String walkinPurchaserName; + + @Column(name = "walkin_purchaser_address", length = 255) + private String walkinPurchaserAddress; + + /** Stored masked (only the last 4 digits kept) — never the full Aadhaar number. */ + @Column(name = "walkin_purchaser_aadhaar_masked", length = 20) + private String walkinPurchaserAadhaarMasked; + + @ManyToOne + @JoinColumn(name = "issuing_branch_id") + private Office issuingBranch; + + @Column(name = "issued_date") + private LocalDate issuedDate; + + @Column(name = "cleared_date") + private LocalDate clearedDate; + + @Column(name = "advice_status", nullable = false, length = 20) + private String adviceStatus; + + @Column(name = "advice_sent_date") + private LocalDateTime adviceSentDate; + + @Version + @Column(name = "version", nullable = false) + private Integer version; + + @ManyToOne + @JoinColumn(name = "createdby_id", nullable = false) + private AppUser createdBy; + + @Column(name = "created_date", nullable = false) + private LocalDateTime createdDate; + + @ManyToOne + @JoinColumn(name = "lastmodifiedby_id") + private AppUser lastModifiedBy; + + @Column(name = "lastmodified_date") + private LocalDateTime lastModifiedDate; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDLeafletRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDLeafletRepository.java new file mode 100644 index 000000000..ee8e62922 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDLeafletRepository.java @@ -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.sponsordd.domain; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface SponsorDDLeafletRepository extends JpaRepository, JpaSpecificationExecutor { + + Optional findByDdNumber(String ddNumber); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoBatch.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoBatch.java new file mode 100644 index 000000000..75de7e38d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoBatch.java @@ -0,0 +1,80 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sponsordd.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.useradministration.domain.AppUser; + +@Getter +@Setter +@Entity +@Table(name = "m_sponsor_dd_reco_batch") +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDRecoBatch extends AbstractPersistableCustom { + + @Column(name = "batch_reference", nullable = false, unique = true, length = 50) + private String batchReference; + + @Column(name = "file_name", nullable = false, length = 150) + private String fileName; + + @Column(name = "file_hash", nullable = false, length = 64) + private String fileHash; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @Column(name = "total_records", nullable = false) + private Integer totalRecords; + + @Column(name = "total_amount", nullable = false, precision = 19, scale = 6) + private BigDecimal totalAmount; + + @Column(name = "success_count", nullable = false) + private Integer successCount; + + @Column(name = "mismatch_count", nullable = false) + private Integer mismatchCount; + + @ManyToOne + @JoinColumn(name = "createdby_id", nullable = false) + private AppUser createdBy; + + @Column(name = "created_date", nullable = false) + private LocalDateTime createdDate; + + @ManyToOne + @JoinColumn(name = "lastmodifiedby_id") + private AppUser lastModifiedBy; + + @Column(name = "lastmodified_date") + private LocalDateTime lastModifiedDate; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoBatchRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoBatchRepository.java new file mode 100644 index 000000000..0eccd125c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoBatchRepository.java @@ -0,0 +1,29 @@ +/** + * 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.sponsordd.domain; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface SponsorDDRecoBatchRepository extends JpaRepository, JpaSpecificationExecutor { + + Optional findByFileHash(String fileHash); + Optional findByBatchReference(String batchReference); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoRecord.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoRecord.java new file mode 100644 index 000000000..598adfcff --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoRecord.java @@ -0,0 +1,76 @@ +/** + * 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.sponsordd.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.useradministration.domain.AppUser; + +@Getter +@Setter +@Entity +@Table(name = "m_sponsor_dd_reco_record") +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDRecoRecord extends AbstractPersistableCustom { + + @ManyToOne + @JoinColumn(name = "batch_id", nullable = false) + private SponsorDDRecoBatch batch; + + @Column(name = "dd_number", nullable = false, length = 30) + private String ddNumber; + + @Column(name = "clearing_amount", nullable = false, precision = 19, scale = 6) + private BigDecimal clearingAmount; + + @Column(name = "clearing_date", nullable = false) + private LocalDate clearingDate; + + @Column(name = "clearing_ref_number", nullable = false, length = 50) + private String clearingRefNumber; + + @Column(name = "match_status", nullable = false, length = 25) + private String matchStatus; + + @ManyToOne + @JoinColumn(name = "matched_leaflet_id") + private SponsorDDLeaflet matchedLeaflet; + + @Column(name = "remarks", length = 255) + private String remarks; + + @ManyToOne + @JoinColumn(name = "resolved_by_id") + private AppUser resolvedBy; + + @Column(name = "resolved_date") + private LocalDateTime resolvedDate; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoRecordRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoRecordRepository.java new file mode 100644 index 000000000..4bec3c805 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoRecordRepository.java @@ -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.sponsordd.domain; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface SponsorDDRecoRecordRepository extends JpaRepository, JpaSpecificationExecutor { + + List findByBatchId(Long batchId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDTransaction.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDTransaction.java new file mode 100644 index 000000000..d7e0f64f7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDTransaction.java @@ -0,0 +1,69 @@ +/** + * 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.sponsordd.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.accounting.journalentry.domain.JournalEntry; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; + +@Getter +@Setter +@Entity +@Table(name = "m_sponsor_dd_transaction") +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDTransaction extends AbstractPersistableCustom { + + @ManyToOne + @JoinColumn(name = "leaflet_id", nullable = false) + private SponsorDDLeaflet leaflet; + + @ManyToOne + @JoinColumn(name = "savings_transaction_id") + private SavingsAccountTransaction savingsTransaction; + + @ManyToOne + @JoinColumn(name = "current_transaction_id") + private CurrentAccountTransaction currentTransaction; + + @ManyToOne + @JoinColumn(name = "journal_entry_id") + private JournalEntry journalEntry; + + @Column(name = "transaction_type", nullable = false, length = 20) + private String transactionType; + + @Column(name = "amount", nullable = false, precision = 19, scale = 6) + private BigDecimal amount; + + @Column(name = "created_date", nullable = false) + private LocalDateTime createdDate; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDTransactionRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDTransactionRepository.java new file mode 100644 index 000000000..600327e15 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDTransactionRepository.java @@ -0,0 +1,25 @@ +/** + * 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.sponsordd.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface SponsorDDTransactionRepository extends JpaRepository, JpaSpecificationExecutor { +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java new file mode 100644 index 000000000..b5dcc10c4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java @@ -0,0 +1,94 @@ +/** + * 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.sponsordd.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine; + +public interface SponsorDDIssueRequestWritePlatformService { + + /** + * Clerk (maker) step: records a PENDING issue request. + * clientId is required for ACCOUNT_DEBIT/CHEQUE; for CASH it must be null and the walk-in + * purchaser's own details (name, address, raw Aadhaar — masked to last 4 digits before storage) + * are supplied instead, since the purchaser need not be a bank client at all. For CASH, + * cashierDepositRequestId must reference an already-APPROVED cashier deposit request (submitted + * by the cashier via Transaction Entry, AC.TP=GL, target = the DD Cash Suspense GL account) for + * exactly the face value + charges + GST total - the cash itself is never posted by this class; + * it's only validated and linked here, since the cashier's own maker-checker flow already moved + * the money and updated their tracked cash position. + */ + CommandProcessingResult submitIssueRequest(Long leafletId, Long clientId, String walkinPurchaserName, + String walkinPurchaserAddress, String walkinPurchaserAadhaar, String beneficiaryName, String payableAtBranch, + String payableAtCity, BigDecimal faceValue, BigDecimal chargesAmount, Long issuingBranchId, LocalDate issuedDate, + String paymentMethod, Long savingsAccountId, Long currentAccountId, String chequeNumber, String chequeBankName, + Long cashierDepositRequestId); + + /** + * Manager/head-cashier (checker) step: performs the real debit/credit GL posting + * (client account debit, cheque-clearing GL debit, or cash-in-hand GL debit, credited to + * Sponsor DD Payable — plus a Charges Income credit leg if charges apply), then issues the + * demand draft. + */ + CommandProcessingResult approveIssueRequest(Long issueRequestId); + + CommandProcessingResult rejectIssueRequest(Long issueRequestId, String rejectionReason); + + /** + * Clerk (maker) step for a multi-DD "slip": one customer account debit (or one cheque) funds + * several drafts in one submission - a physical leaflet is auto-allocated per line from the given + * inventory, and every resulting row shares one batch id so the checker approves/rejects the whole + * slip as a unit rather than line by line. CASH is not supported here - only ACCOUNT_DEBIT/CHEQUE, + * since a cash slip would also need Transaction Entry changes to record one lump intake for N + * drafts, which is a separate piece of work. Returns exactly which physical leaflet (DD number) + * was auto-allocated to each line and for what amount, since that allocation only happens inside + * this call (locked against a concurrent slip grabbing the same leaflet) and can't be known by the + * caller beforehand. + */ + SponsorDDBatchIssueResult submitIssueRequestBatch(Long inventoryId, Long clientId, Long issuingBranchId, LocalDate issuedDate, + String paymentMethod, Long savingsAccountId, Long currentAccountId, String chequeNumber, String chequeBankName, + List lines); + + /** + * Checker step for a multi-DD slip: one combined withdrawal for the slip's total face value, one + * combined GL journal entry (Dr control/clearing GL for total face value + charges + GST, Cr DD + * Payable / Charges Income / GST Payable), then every line's leaflet is issued and every row in the + * batch moves to APPROVED together. + */ + CommandProcessingResult approveIssueRequestBatch(Long batchId); + + /** Rejects every line of a pending multi-DD slip together; no money has moved yet for ACCOUNT_DEBIT/CHEQUE. */ + CommandProcessingResult rejectIssueRequestBatch(Long batchId, String rejectionReason); + + /** + * Back-office configuration: which GL accounts to use for a given sponsor bank / payment type, + * the GST rate applied to DD issuance charges, and the DD limit amount - kept here as data so it + * can be updated at any time (e.g. when the government revises the GST rate, or compliance + * revises the DD limit) without a code deployment. ddLimitAmount, if set, caps both a single DD's + * face value and the combined face value of all DDs issued to the same beneficiary on the same + * day; null/unset means no limit is enforced. + */ + CommandProcessingResult upsertGlMapping(Long paymentTypeId, Long ddPayableGlAccountId, Long chequeClearingGlAccountId, + Long cashGlAccountId, Long chargesIncomeGlAccountId, Long cashSuspenseGlAccountId, BigDecimal gstPercentage, + Long gstPayableGlAccountId, BigDecimal ddLimitAmount); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java new file mode 100644 index 000000000..792daa49c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java @@ -0,0 +1,1213 @@ +/** + * 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.sponsordd.service; + +import com.google.gson.JsonObject; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.util.Objects; +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.commands.service.SynchronousCommandProcessingService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; + +@Service +@RequiredArgsConstructor +public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDIssueRequestWritePlatformService { + + private static final String STATUS_PENDING = "PENDING"; + private static final String STATUS_APPROVED = "APPROVED"; + private static final String STATUS_REJECTED = "REJECTED"; + private static final String METHOD_ACCOUNT_DEBIT = "ACCOUNT_DEBIT"; + private static final String METHOD_CHEQUE = "CHEQUE"; + private static final String METHOD_CASH = "CASH"; + + // Strips everything but letters/digits (spaces, periods, hyphens, commas...) before uppercasing, + // so "KK", "K K", "K.K.", "K-K" etc. are all recognised as the same beneficiary for the DD-limit + // aggregate check below - a bare UPPER(TRIM(...)) only normalises case and outer whitespace, which + // is trivial to defeat by just inserting a space into the name on a later DD. + private static final String NORMALIZED_NAME_COLUMN = "regexp_replace(upper(beneficiary_name), '[^A-Z0-9]', '', 'g')"; + private static final String NORMALIZED_NAME_PARAM = "regexp_replace(upper(?), '[^A-Z0-9]', '', 'g')"; + private static final int MAX_SLIP_LINES = 20; + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final FineractRequestContextHolder fineractRequestContextHolder; + private final SponsorDDWritePlatformService sponsorDDWritePlatformService; + + // ── Maker step ───────────────────────────────────────────────────────── + + @Override + @Transactional + public CommandProcessingResult submitIssueRequest(final Long leafletId, final Long clientId, final String walkinPurchaserName, + final String walkinPurchaserAddress, final String walkinPurchaserAadhaar, final String beneficiaryName, + final String payableAtBranch, final String payableAtCity, final BigDecimal faceValue, final BigDecimal chargesAmount, + final Long issuingBranchId, final LocalDate issuedDate, final String paymentMethod, final Long savingsAccountId, + final Long currentAccountId, final String chequeNumber, final String chequeBankName, + final Long cashierDepositRequestId) { + + final AppUser currentUser = this.context.authenticatedUser(); + + final Map leaflet = findLeafletOrThrow(leafletId); + if (!"AVAILABLE".equals(leaflet.get("status"))) { + throw new IllegalStateException("Demand Draft leaflet is not available. Current status: " + leaflet.get("status")); + } + if (beneficiaryName == null || beneficiaryName.isBlank()) { + throw new IllegalArgumentException("Beneficiary Name is required."); + } + if (beneficiaryName.length() > 120) { + throw new IllegalArgumentException("Beneficiary Name cannot exceed 120 characters."); + } + if (payableAtBranch == null || payableAtBranch.isBlank()) { + throw new IllegalArgumentException("Payable At - Branch is required."); + } + if (payableAtCity == null || payableAtCity.isBlank()) { + throw new IllegalArgumentException("Payable At - City is required."); + } + if (!this.jdbcTemplate.queryForList( + "SELECT id FROM m_sponsor_dd_issue_request WHERE leaflet_id = ? AND status = 'PENDING'", leafletId).isEmpty()) { + throw new IllegalStateException( + "A DD issuance request is already pending for this leaflet. Wait for it to be approved or rejected before submitting another."); + } + final Long inventoryId = ((Number) leaflet.get("inventory_id")).longValue(); + final Long sponsorBankId = findInventorySponsorBankId(inventoryId); + final BigDecimal effectiveCharges = chargesAmount != null ? chargesAmount : BigDecimal.ZERO; + final Map glMapping = findGlMappingOrThrow(paymentMethod, effectiveCharges); + final BigDecimal gstPercentage = (BigDecimal) glMapping.get("gst_percentage"); + final BigDecimal gstAmount = computeGst(effectiveCharges, gstPercentage); + validateDdLimit(glMapping, beneficiaryName, faceValue, issuedDate, null); + validatePaymentMethodFields(paymentMethod, clientId, savingsAccountId, currentAccountId, chequeNumber, chequeBankName, + walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaar); + + String walkinPurchaserAadhaarMasked = null; + Long cashReceiptJournalEntryId = null; + if (METHOD_CASH.equals(paymentMethod)) { + walkinPurchaserAadhaarMasked = maskAadhaar(walkinPurchaserAadhaar); + final Long cashSuspenseGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue(); + final BigDecimal expectedTotal = faceValue.add(effectiveCharges).add(gstAmount); + final Map depositRequest = validateCashierDepositRequestOrThrow(cashierDepositRequestId, expectedTotal, + cashSuspenseGlAccountId); + cashReceiptJournalEntryId = findJournalEntryIdForCashierDeposit(depositRequest, cashSuspenseGlAccountId); + } else { + validateClientExists(clientId); + if (savingsAccountId != null) validateAccountBelongsToClient("m_savings_account", savingsAccountId, clientId, "Savings account"); + if (currentAccountId != null) validateAccountBelongsToClient("m_current_account", currentAccountId, clientId, "Current account"); + if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) { + // The account is actually debited for face value + charges + GST (see approveIssueRequest), + // so the balance check must cover that same total, not face value alone. + validateSufficientBalance(savingsAccountId, currentAccountId, faceValue.add(effectiveCharges).add(gstAmount)); + } + } + + final KeyHolder kh = new GeneratedKeyHolder(); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + final String finalWalkinAadhaarMasked = walkinPurchaserAadhaarMasked; + final Long finalCashReceiptJournalEntryId = cashReceiptJournalEntryId; + final Long finalCashierDepositRequestId = METHOD_CASH.equals(paymentMethod) ? cashierDepositRequestId : null; + this.jdbcTemplate.update(con -> { + final java.sql.PreparedStatement ps = con.prepareStatement( + "INSERT INTO m_sponsor_dd_issue_request " + + "(leaflet_id, client_id, walkin_purchaser_name, walkin_purchaser_address, walkin_purchaser_aadhaar_masked, " + + "beneficiary_name, payable_at_branch, payable_at_city, face_value, charges_amount, gst_amount, " + + "gst_percentage_applied, issuing_branch_id, issued_date, payment_method, savings_account_id, " + + "current_account_id, cheque_number, cheque_bank_name, status, maker_id, created_date, " + + "cashier_deposit_request_id, cash_receipt_journal_entry_id) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + new String[] { "id" }); + ps.setLong(1, leafletId); + if (clientId != null) ps.setLong(2, clientId); else ps.setNull(2, java.sql.Types.BIGINT); + ps.setString(3, walkinPurchaserName); + ps.setString(4, walkinPurchaserAddress); + ps.setString(5, finalWalkinAadhaarMasked); + ps.setString(6, beneficiaryName); + ps.setString(7, payableAtBranch); + ps.setString(8, payableAtCity); + ps.setBigDecimal(9, faceValue); + ps.setBigDecimal(10, effectiveCharges); + ps.setBigDecimal(11, gstAmount); + ps.setBigDecimal(12, gstPercentage != null ? gstPercentage : BigDecimal.ZERO); + ps.setLong(13, issuingBranchId); + ps.setObject(14, issuedDate); + ps.setString(15, paymentMethod); + if (savingsAccountId != null) ps.setLong(16, savingsAccountId); else ps.setNull(16, java.sql.Types.BIGINT); + if (currentAccountId != null) ps.setLong(17, currentAccountId); else ps.setNull(17, java.sql.Types.BIGINT); + ps.setString(18, chequeNumber); + ps.setString(19, chequeBankName); + ps.setString(20, STATUS_PENDING); + ps.setLong(21, currentUser.getId()); + ps.setObject(22, now); + if (finalCashierDepositRequestId != null) ps.setLong(23, finalCashierDepositRequestId); else ps.setNull(23, java.sql.Types.BIGINT); + if (finalCashReceiptJournalEntryId != null) ps.setLong(24, finalCashReceiptJournalEntryId); else ps.setNull(24, java.sql.Types.BIGINT); + return ps; + }, kh); + + final long newId = Objects.requireNonNull(kh.getKey()).longValue(); + return new CommandProcessingResultBuilder().withEntityId(newId).build(); + } + + // ── Maker step: multi-DD slip ─────────────────────────────────────────── + + @Override + @Transactional + public SponsorDDBatchIssueResult submitIssueRequestBatch(final Long inventoryId, final Long clientId, final Long issuingBranchId, + final LocalDate issuedDate, final String paymentMethod, final Long savingsAccountId, final Long currentAccountId, + final String chequeNumber, final String chequeBankName, final List lines) { + + final AppUser currentUser = this.context.authenticatedUser(); + + if (!METHOD_ACCOUNT_DEBIT.equals(paymentMethod) && !METHOD_CHEQUE.equals(paymentMethod)) { + throw new IllegalArgumentException("A multi-DD slip only supports ACCOUNT_DEBIT or CHEQUE payment methods."); + } + if (lines == null || lines.isEmpty()) { + throw new IllegalArgumentException("At least one DD line is required."); + } + if (lines.size() > MAX_SLIP_LINES) { + throw new IllegalArgumentException("A single DD slip cannot exceed " + MAX_SLIP_LINES + " drafts."); + } + for (final SponsorDDSlipLine line : lines) { + if (line.beneficiaryName() == null || line.beneficiaryName().isBlank()) { + throw new IllegalArgumentException("Beneficiary Name is required for every line."); + } + if (line.beneficiaryName().length() > 120) { + throw new IllegalArgumentException("Beneficiary Name cannot exceed 120 characters."); + } + if (line.payableAtBranch() == null || line.payableAtBranch().isBlank()) { + throw new IllegalArgumentException("Payable At - Branch is required for every line."); + } + if (line.payableAtCity() == null || line.payableAtCity().isBlank()) { + throw new IllegalArgumentException("Payable At - City is required for every line."); + } + if (line.faceValue() == null || line.faceValue().compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("Face Value must be greater than zero for every line."); + } + } + + validatePaymentMethodFields(paymentMethod, clientId, savingsAccountId, currentAccountId, chequeNumber, chequeBankName, + null, null, null); + + final BigDecimal totalFaceValue = lines.stream().map(SponsorDDSlipLine::faceValue).reduce(BigDecimal.ZERO, BigDecimal::add); + final BigDecimal totalCharges = lines.stream() + .map(l -> l.chargesAmount() != null ? l.chargesAmount() : BigDecimal.ZERO) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + final Map glMapping = findGlMappingOrThrow(paymentMethod, totalCharges); + final BigDecimal gstPercentage = (BigDecimal) glMapping.get("gst_percentage"); + final BigDecimal ddLimitAmount = (BigDecimal) glMapping.get("dd_limit_amount"); + + // Each line's own face value must not exceed the limit on its own... + if (ddLimitAmount != null && ddLimitAmount.compareTo(BigDecimal.ZERO) > 0) { + for (final SponsorDDSlipLine line : lines) { + if (line.faceValue().compareTo(ddLimitAmount) > 0) { + throw new IllegalArgumentException("DD face value (" + line.faceValue() + ") for beneficiary '" + + line.beneficiaryName() + "' exceeds the configured DD limit of " + ddLimitAmount + "."); + } + } + } + // ...and lines in THIS slip for the SAME beneficiary must be added together too, on top of + // whatever that beneficiary already has issued/pending elsewhere today - otherwise splitting a + // large amount across two lines of one slip would defeat the limit just as easily as splitting + // across two separate submissions. + final Map inSlipTotalsByNormalizedName = new java.util.HashMap<>(); + for (final SponsorDDSlipLine line : lines) { + inSlipTotalsByNormalizedName.merge(normalizeBeneficiaryName(line.beneficiaryName()), line.faceValue(), BigDecimal::add); + } + for (final Map.Entry entry : inSlipTotalsByNormalizedName.entrySet()) { + validateDdLimitForBatch(glMapping, entry.getKey(), entry.getValue(), issuedDate, null); + } + + if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) { + validateClientExists(clientId); + if (savingsAccountId != null) validateAccountBelongsToClient("m_savings_account", savingsAccountId, clientId, "Savings account"); + if (currentAccountId != null) validateAccountBelongsToClient("m_current_account", currentAccountId, clientId, "Current account"); + // The account is actually debited for the combined face value + charges + GST across every + // line (see approveIssueRequestBatch), so the balance check must cover that same total. + final BigDecimal totalGstForBalanceCheck = lines.stream() + .map(l -> computeGst(l.chargesAmount() != null ? l.chargesAmount() : BigDecimal.ZERO, gstPercentage)) + .reduce(BigDecimal.ZERO, BigDecimal::add); + validateSufficientBalance(savingsAccountId, currentAccountId, totalFaceValue.add(totalCharges).add(totalGstForBalanceCheck)); + } else { + validateClientExists(clientId); + } + + final List> allocatedLeaflets = allocateAvailableLeaflets(inventoryId, lines.size()); + + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + Long batchId = null; + final List resultLines = new java.util.ArrayList<>(); + for (int i = 0; i < lines.size(); i++) { + final SponsorDDSlipLine line = lines.get(i); + final Long leafletId = ((Number) allocatedLeaflets.get(i).get("id")).longValue(); + final String ddNumber = (String) allocatedLeaflets.get(i).get("dd_number"); + final BigDecimal effectiveCharges = line.chargesAmount() != null ? line.chargesAmount() : BigDecimal.ZERO; + final BigDecimal gstAmount = computeGst(effectiveCharges, gstPercentage); + final Long batchIdForThisRow = batchId; + + final KeyHolder kh = new GeneratedKeyHolder(); + this.jdbcTemplate.update(con -> { + final java.sql.PreparedStatement ps = con.prepareStatement( + "INSERT INTO m_sponsor_dd_issue_request " + + "(leaflet_id, client_id, beneficiary_name, payable_at_branch, payable_at_city, face_value, " + + "charges_amount, gst_amount, gst_percentage_applied, issuing_branch_id, issued_date, payment_method, " + + "savings_account_id, current_account_id, cheque_number, cheque_bank_name, status, maker_id, " + + "created_date, batch_id) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + new String[] { "id" }); + ps.setLong(1, leafletId); + ps.setLong(2, clientId); + ps.setString(3, line.beneficiaryName()); + ps.setString(4, line.payableAtBranch()); + ps.setString(5, line.payableAtCity()); + ps.setBigDecimal(6, line.faceValue()); + ps.setBigDecimal(7, effectiveCharges); + ps.setBigDecimal(8, gstAmount); + ps.setBigDecimal(9, gstPercentage != null ? gstPercentage : BigDecimal.ZERO); + ps.setLong(10, issuingBranchId); + ps.setObject(11, issuedDate); + ps.setString(12, paymentMethod); + if (savingsAccountId != null) ps.setLong(13, savingsAccountId); else ps.setNull(13, java.sql.Types.BIGINT); + if (currentAccountId != null) ps.setLong(14, currentAccountId); else ps.setNull(14, java.sql.Types.BIGINT); + ps.setString(15, chequeNumber); + ps.setString(16, chequeBankName); + ps.setString(17, STATUS_PENDING); + ps.setLong(18, currentUser.getId()); + ps.setObject(19, now); + if (batchIdForThisRow != null) ps.setLong(20, batchIdForThisRow); else ps.setNull(20, java.sql.Types.BIGINT); + return ps; + }, kh); + final long newId = Objects.requireNonNull(kh.getKey()).longValue(); + if (batchId == null) { + batchId = newId; + this.jdbcTemplate.update("UPDATE m_sponsor_dd_issue_request SET batch_id = ? WHERE id = ?", batchId, newId); + } + resultLines.add(new SponsorDDBatchIssueResult.Line(newId, leafletId, ddNumber, line.beneficiaryName(), line.faceValue())); + } + + return new SponsorDDBatchIssueResult(batchId, resultLines); + } + + /** Locks and returns the next `count` AVAILABLE leaflets in this inventory, oldest series first. */ + private List> allocateAvailableLeaflets(final Long inventoryId, final int count) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE inventory_id = ? AND status = 'AVAILABLE' " + + "ORDER BY id ASC LIMIT ? FOR UPDATE", + inventoryId, count); + if (rows.size() < count) { + throw new IllegalStateException("Not enough available leaflets in this inventory: need " + count + + ", only " + rows.size() + " available. Register more inventory or reduce the number of drafts."); + } + return rows; + } + + /** + * The cashier records the physical cash intake themselves via Transaction Entry (AC.TP=GL, + * target = the DD Cash Suspense GL account) - a normal cashier GL deposit that goes through its + * own maker-checker approval and correctly updates the cashier's tracked cash position. This + * validates that request rather than posting a competing GL entry of our own. + */ + private Map validateCashierDepositRequestOrThrow(final Long cashierDepositRequestId, final BigDecimal expectedTotal, + final Long cashSuspenseGlAccountId) { + if (cashierDepositRequestId == null) { + throw new IllegalArgumentException( + "Select the cashier's approved cash-deposit request. Ask the cashier to submit it first via Transaction Entry " + + "(AC.TP = GL, target account = the DD Cash Suspense GL account, amount = " + expectedTotal + ")."); + } + final List> rows = this.jdbcTemplate.queryForList( + "SELECT id, status, gl_account_id, amount, txn_id FROM m_cashier_deposit_request WHERE id = ?", cashierDepositRequestId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("Cashier deposit request not found: " + cashierDepositRequestId); + } + final Map deposit = rows.get(0); + if (!"APPROVED".equals(deposit.get("status"))) { + throw new IllegalStateException( + "The referenced cashier deposit request is not yet approved by a manager. Current status: " + deposit.get("status")); + } + final Object depositGlAccountObj = deposit.get("gl_account_id"); + final Long depositGlAccountId = depositGlAccountObj != null ? ((Number) depositGlAccountObj).longValue() : null; + if (!cashSuspenseGlAccountId.equals(depositGlAccountId)) { + throw new IllegalStateException( + "This cashier deposit request wasn't posted to the DD Cash Suspense GL account. Ask the cashier to redo it " + + "via Transaction Entry with AC.TP = GL and the correct target account."); + } + final BigDecimal depositAmount = new BigDecimal(deposit.get("amount").toString()); + if (depositAmount.compareTo(expectedTotal) != 0) { + throw new IllegalStateException("The cashier deposit request amount (" + depositAmount + + ") doesn't match the DD total - face value + charges + GST = " + expectedTotal + "."); + } + if (!this.jdbcTemplate.queryForList( + "SELECT id FROM m_sponsor_dd_issue_request WHERE cashier_deposit_request_id = ?", cashierDepositRequestId).isEmpty()) { + throw new IllegalStateException("This cashier deposit request has already been used for another DD issuance."); + } + return deposit; + } + + private Long findJournalEntryIdForCashierDeposit(final Map depositRequest, final Long cashSuspenseGlAccountId) { + final String txnId = (String) depositRequest.get("txn_id"); + if (txnId == null) return null; + final List> rows = this.jdbcTemplate.queryForList( + "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND account_id = ? LIMIT 1", txnId, cashSuspenseGlAccountId); + return rows.isEmpty() ? null : ((Number) rows.get(0).get("id")).longValue(); + } + + // ── Checker steps ────────────────────────────────────────────────────── + + @Override + @Transactional + public CommandProcessingResult approveIssueRequest(final Long issueRequestId) { + final AppUser currentUser = this.context.authenticatedUser(); + validateCheckerRole(currentUser); + + final Map request = findIssueRequestOrThrow(issueRequestId); + if (!STATUS_PENDING.equals(request.get("status"))) { + throw new IllegalStateException("Issue request is not pending. Current status: " + request.get("status")); + } + final Long makerId = ((Number) request.get("maker_id")).longValue(); + if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) { + throw new IllegalStateException("You cannot approve your own DD issuance request."); + } + + final Long leafletId = ((Number) request.get("leaflet_id")).longValue(); + final Map leaflet = findLeafletOrThrow(leafletId); + if (!"AVAILABLE".equals(leaflet.get("status"))) { + throw new IllegalStateException("Demand Draft leaflet is no longer available. Current status: " + leaflet.get("status")); + } + final String ddNumber = (String) leaflet.get("dd_number"); + final Long inventoryId = ((Number) leaflet.get("inventory_id")).longValue(); + final Long sponsorBankId = findInventorySponsorBankId(inventoryId); + final Long defaultPaymentTypeId = findDefaultPaymentTypeId(); + + final String paymentMethod = (String) request.get("payment_method"); + final BigDecimal chargesAmount = (BigDecimal) request.get("charges_amount"); + // Locked in at submission time - not recomputed from the (possibly since-changed) current + // gst_percentage, so approving a request never applies a different rate than the maker saw. + final BigDecimal gstAmount = (BigDecimal) request.get("gst_amount"); + final Map glMapping = findGlMappingOrThrow(paymentMethod, chargesAmount); + final Long ddPayableGlAccountId = ((Number) glMapping.get("dd_payable_gl_account_id")).longValue(); + final Long chargesGlAccountId = glMapping.get("charges_income_gl_account_id") != null + ? ((Number) glMapping.get("charges_income_gl_account_id")).longValue() : null; + final Long gstGlAccountId = glMapping.get("gst_payable_gl_account_id") != null + ? ((Number) glMapping.get("gst_payable_gl_account_id")).longValue() : null; + + final Long clientId = request.get("client_id") != null ? ((Number) request.get("client_id")).longValue() : null; + final String walkinPurchaserName = (String) request.get("walkin_purchaser_name"); + final String walkinPurchaserAddress = (String) request.get("walkin_purchaser_address"); + final String walkinPurchaserAadhaarMasked = (String) request.get("walkin_purchaser_aadhaar_masked"); + final String beneficiaryName = (String) request.get("beneficiary_name"); + final String payableAtBranch = (String) request.get("payable_at_branch"); + final String payableAtCity = (String) request.get("payable_at_city"); + final BigDecimal faceValue = (BigDecimal) request.get("face_value"); + final Long issuingBranchId = ((Number) request.get("issuing_branch_id")).longValue(); + final LocalDate issuedDate = ((java.sql.Date) request.get("issued_date")).toLocalDate(); + + // Re-checked here (not just at submission) in case the configured limit changed, or another + // request for the same beneficiary/day was approved, in the meantime. + validateDdLimit(glMapping, beneficiaryName, faceValue, issuedDate, issueRequestId); + + Long resultingSavingsTransactionId = null; + Long resultingCurrentTransactionId = null; + + final Long drGlAccountId; + Long linkedAccountId = null; + if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) { + final Long savingsAccountId = request.get("savings_account_id") != null ? ((Number) request.get("savings_account_id")).longValue() : null; + final Long currentAccountId = request.get("current_account_id") != null ? ((Number) request.get("current_account_id")).longValue() : null; + final String note = "Sponsor DD issuance - DD No " + ddNumber; + // Withdraw the FULL amount actually leaving the customer's account - face value + charges + + // GST - so the real account balance drops by exactly what the combined GL entry below debits + // the control account for. Withdrawing face value alone would under-debit the customer's own + // balance relative to the control GL account by the charges+GST amount on every DD. + final BigDecimal effectiveCharges = chargesAmount != null ? chargesAmount : BigDecimal.ZERO; + final BigDecimal effectiveGst = gstAmount != null ? gstAmount : BigDecimal.ZERO; + final BigDecimal totalWithdrawal = faceValue.add(effectiveCharges).add(effectiveGst); + if (savingsAccountId != null) { + final String txnId = executeSavingsWithdrawal(savingsAccountId, totalWithdrawal, issuedDate, defaultPaymentTypeId, note); + resultingSavingsTransactionId = Long.valueOf(txnId); + drGlAccountId = getSavingsControlGlId(savingsAccountId); + markWithdrawalGlEntriesReversed("S" + txnId); + linkedAccountId = savingsAccountId; + } else { + final String txnId = executeCurrentAccountWithdrawal(currentAccountId, totalWithdrawal, issuedDate, defaultPaymentTypeId, note); + resultingCurrentTransactionId = Long.valueOf(txnId); + drGlAccountId = getCurrentControlGlId(currentAccountId); + markWithdrawalGlEntriesReversed("CAT" + txnId); + linkedAccountId = currentAccountId; + } + if (drGlAccountId == null) { + throw new IllegalStateException("Could not resolve the control GL account for this account's product. Check the product's accounting (GL) mapping."); + } + } else if (METHOD_CHEQUE.equals(paymentMethod)) { + drGlAccountId = ((Number) glMapping.get("cheque_clearing_gl_account_id")).longValue(); + } else { + // Cash was already recorded (Dr Cash at Tellers / Cr this suspense account) when the + // request was submitted, so approval clears the suspense rather than debiting cash again. + drGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue(); + } + + final String comments = "Sponsor DD issuance - DD No " + ddNumber; + final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, faceValue, chargesAmount, + chargesGlAccountId, gstAmount, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId); + + final CommandProcessingResult issueResult = this.sponsorDDWritePlatformService.issueDemandDraft( + leafletId, clientId, walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName, + faceValue, issuingBranchId, issuedDate, + resultingSavingsTransactionId, resultingCurrentTransactionId, resultingJournalEntryId, + payableAtBranch, payableAtCity); + + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_issue_request SET status = ?, checker_id = ?, checker_date = ?, " + + "resulting_savings_transaction_id = ?, resulting_current_transaction_id = ?, resulting_journal_entry_id = ? " + + "WHERE id = ?", + STATUS_APPROVED, currentUser.getId(), now, resultingSavingsTransactionId, resultingCurrentTransactionId, + resultingJournalEntryId, issueRequestId); + + return new CommandProcessingResultBuilder().withEntityId(issueRequestId) + .withSubEntityId(issueResult.getResourceId()).build(); + } + + @Override + @Transactional + public CommandProcessingResult rejectIssueRequest(final Long issueRequestId, final String rejectionReason) { + final AppUser currentUser = this.context.authenticatedUser(); + validateCheckerRole(currentUser); + + final Map request = findIssueRequestOrThrow(issueRequestId); + if (!STATUS_PENDING.equals(request.get("status"))) { + throw new IllegalStateException("Issue request is not pending. Current status: " + request.get("status")); + } + final Long makerId = ((Number) request.get("maker_id")).longValue(); + if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) { + throw new IllegalStateException("You cannot reject your own DD issuance request."); + } + + Long cashRefundJournalEntryId = null; + final String paymentMethod = (String) request.get("payment_method"); + if (METHOD_CASH.equals(paymentMethod) && request.get("cash_receipt_journal_entry_id") != null) { + final BigDecimal faceValue = (BigDecimal) request.get("face_value"); + final BigDecimal chargesAmount = (BigDecimal) request.get("charges_amount"); + final BigDecimal effectiveCharges = chargesAmount != null ? chargesAmount : BigDecimal.ZERO; + // Refund exactly what was received at submission time - the gst_amount locked in then, + // not a recomputation from the (possibly since-changed) current gst_percentage. + final BigDecimal gstAmount = (BigDecimal) request.get("gst_amount"); + final BigDecimal effectiveGst = gstAmount != null ? gstAmount : BigDecimal.ZERO; + final Long issuingBranchId = ((Number) request.get("issuing_branch_id")).longValue(); + final LocalDate issuedDate = ((java.sql.Date) request.get("issued_date")).toLocalDate(); + final Map glMapping = findGlMappingOrThrow(paymentMethod, effectiveCharges); + final Long cashGlAccountId = ((Number) glMapping.get("cash_gl_account_id")).longValue(); + final Long cashSuspenseGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue(); + final String refundComments = "DD issuance request #" + issueRequestId + " rejected - cash refunded to walk-in purchaser"; + cashRefundJournalEntryId = postGlOffsetEntry(cashSuspenseGlAccountId, cashGlAccountId, + faceValue.add(effectiveCharges).add(effectiveGst), null, null, issuingBranchId, issuedDate, refundComments, null); + } + + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_issue_request SET status = ?, checker_id = ?, checker_date = ?, rejection_reason = ?, " + + "cash_refund_journal_entry_id = ? WHERE id = ?", + STATUS_REJECTED, currentUser.getId(), now, rejectionReason, cashRefundJournalEntryId, issueRequestId); + + return new CommandProcessingResultBuilder().withEntityId(issueRequestId).build(); + } + + // ── Checker steps: multi-DD slip ──────────────────────────────────────── + + @Override + @Transactional + public CommandProcessingResult approveIssueRequestBatch(final Long batchId) { + final AppUser currentUser = this.context.authenticatedUser(); + validateCheckerRole(currentUser); + + final List> requests = findBatchRequestsOrThrow(batchId); + for (final Map request : requests) { + if (!STATUS_PENDING.equals(request.get("status"))) { + throw new IllegalStateException( + "DD slip is not fully pending - request #" + request.get("id") + " is already " + request.get("status") + "."); + } + final Long makerId = ((Number) request.get("maker_id")).longValue(); + if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) { + throw new IllegalStateException("You cannot approve your own DD slip."); + } + } + + final Map first = requests.get(0); + final String paymentMethod = (String) first.get("payment_method"); + final Long issuingBranchId = ((Number) first.get("issuing_branch_id")).longValue(); + final LocalDate issuedDate = ((java.sql.Date) first.get("issued_date")).toLocalDate(); + final Long clientId = first.get("client_id") != null ? ((Number) first.get("client_id")).longValue() : null; + final Long savingsAccountId = first.get("savings_account_id") != null ? ((Number) first.get("savings_account_id")).longValue() : null; + final Long currentAccountId = first.get("current_account_id") != null ? ((Number) first.get("current_account_id")).longValue() : null; + + final BigDecimal totalFaceValue = requests.stream() + .map(r -> (BigDecimal) r.get("face_value")).reduce(BigDecimal.ZERO, BigDecimal::add); + final BigDecimal totalCharges = requests.stream() + .map(r -> (BigDecimal) r.get("charges_amount")).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); + final BigDecimal totalGst = requests.stream() + .map(r -> (BigDecimal) r.get("gst_amount")).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); + + // Re-checked here (not just at submission) in case the configured limit, or other same-day + // activity for these beneficiaries, changed in the meantime - same as the single-DD flow. + final Map glMapping = findGlMappingOrThrow(paymentMethod, totalCharges); + final Map inSlipTotalsByNormalizedName = new java.util.HashMap<>(); + for (final Map request : requests) { + inSlipTotalsByNormalizedName.merge(normalizeBeneficiaryName((String) request.get("beneficiary_name")), + (BigDecimal) request.get("face_value"), BigDecimal::add); + } + for (final Map.Entry entry : inSlipTotalsByNormalizedName.entrySet()) { + validateDdLimitForBatch(glMapping, entry.getKey(), entry.getValue(), issuedDate, batchId); + } + + final Long ddPayableGlAccountId = ((Number) glMapping.get("dd_payable_gl_account_id")).longValue(); + final Long chargesGlAccountId = glMapping.get("charges_income_gl_account_id") != null + ? ((Number) glMapping.get("charges_income_gl_account_id")).longValue() : null; + final Long gstGlAccountId = glMapping.get("gst_payable_gl_account_id") != null + ? ((Number) glMapping.get("gst_payable_gl_account_id")).longValue() : null; + + final String ddNumbersSummary = requests.stream() + .map(r -> (String) findLeafletOrThrow(((Number) r.get("leaflet_id")).longValue()).get("dd_number")) + .collect(java.util.stream.Collectors.joining(", ")); + + // ONE combined withdrawal for the whole slip - face value + charges + GST across every line - + // so the real account balance drops by exactly what the combined GL entry below debits the + // control account for. + final BigDecimal totalWithdrawal = totalFaceValue.add(totalCharges).add(totalGst); + Long resultingSavingsTransactionId = null; + Long resultingCurrentTransactionId = null; + final Long drGlAccountId; + Long linkedAccountId = null; + if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) { + final String note = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary; + if (savingsAccountId != null) { + final String txnId = executeSavingsWithdrawal(savingsAccountId, totalWithdrawal, issuedDate, findDefaultPaymentTypeId(), note); + resultingSavingsTransactionId = Long.valueOf(txnId); + drGlAccountId = getSavingsControlGlId(savingsAccountId); + markWithdrawalGlEntriesReversed("S" + txnId); + linkedAccountId = savingsAccountId; + } else { + final String txnId = executeCurrentAccountWithdrawal(currentAccountId, totalWithdrawal, issuedDate, findDefaultPaymentTypeId(), note); + resultingCurrentTransactionId = Long.valueOf(txnId); + drGlAccountId = getCurrentControlGlId(currentAccountId); + markWithdrawalGlEntriesReversed("CAT" + txnId); + linkedAccountId = currentAccountId; + } + if (drGlAccountId == null) { + throw new IllegalStateException( + "Could not resolve the control GL account for this account's product. Check the product's accounting (GL) mapping."); + } + } else { + drGlAccountId = ((Number) glMapping.get("cheque_clearing_gl_account_id")).longValue(); + } + + final String comments = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary; + final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, totalFaceValue, totalCharges, + chargesGlAccountId, totalGst, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId); + + for (final Map request : requests) { + this.sponsorDDWritePlatformService.issueDemandDraft( + ((Number) request.get("leaflet_id")).longValue(), clientId, null, null, null, + (String) request.get("beneficiary_name"), (BigDecimal) request.get("face_value"), issuingBranchId, issuedDate, + resultingSavingsTransactionId, resultingCurrentTransactionId, resultingJournalEntryId, + (String) request.get("payable_at_branch"), (String) request.get("payable_at_city")); + } + + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_issue_request SET status = ?, checker_id = ?, checker_date = ?, " + + "resulting_savings_transaction_id = ?, resulting_current_transaction_id = ?, resulting_journal_entry_id = ? " + + "WHERE batch_id = ?", + STATUS_APPROVED, currentUser.getId(), now, resultingSavingsTransactionId, resultingCurrentTransactionId, + resultingJournalEntryId, batchId); + + return new CommandProcessingResultBuilder().withEntityId(batchId).build(); + } + + @Override + @Transactional + public CommandProcessingResult rejectIssueRequestBatch(final Long batchId, final String rejectionReason) { + final AppUser currentUser = this.context.authenticatedUser(); + validateCheckerRole(currentUser); + + final List> requests = findBatchRequestsOrThrow(batchId); + for (final Map request : requests) { + if (!STATUS_PENDING.equals(request.get("status"))) { + throw new IllegalStateException( + "DD slip is not fully pending - request #" + request.get("id") + " is already " + request.get("status") + "."); + } + final Long makerId = ((Number) request.get("maker_id")).longValue(); + if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) { + throw new IllegalStateException("You cannot reject your own DD slip."); + } + } + + // No cash refund leg here - a slip only supports ACCOUNT_DEBIT/CHEQUE, neither of which moves + // any money before approval (unlike the single-DD CASH path). + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_issue_request SET status = ?, checker_id = ?, checker_date = ?, rejection_reason = ? " + + "WHERE batch_id = ?", + STATUS_REJECTED, currentUser.getId(), now, rejectionReason, batchId); + + return new CommandProcessingResultBuilder().withEntityId(batchId).build(); + } + + private List> findBatchRequestsOrThrow(final Long batchId) { + final List> requests = this.jdbcTemplate.queryForList( + "SELECT * FROM m_sponsor_dd_issue_request WHERE batch_id = ? ORDER BY id ASC", batchId); + if (requests.isEmpty()) { + throw new IllegalArgumentException("DD slip not found: " + batchId); + } + return requests; + } + + // ── GL mapping configuration ─────────────────────────────────────────── + + @Override + @Transactional + public CommandProcessingResult upsertGlMapping(final Long paymentTypeId, final Long ddPayableGlAccountId, + final Long chequeClearingGlAccountId, final Long cashGlAccountId, final Long chargesIncomeGlAccountId, + final Long cashSuspenseGlAccountId, final BigDecimal gstPercentage, final Long gstPayableGlAccountId, + final BigDecimal ddLimitAmount) { + final AppUser currentUser = this.context.authenticatedUser(); + validateCheckerRole(currentUser); + if (gstPercentage != null && (gstPercentage.compareTo(BigDecimal.ZERO) < 0 || gstPercentage.compareTo(BigDecimal.valueOf(100)) > 0)) { + throw new IllegalArgumentException("GST percentage must be between 0 and 100."); + } + if (ddLimitAmount != null && ddLimitAmount.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("DD Limit Amount cannot be negative."); + } + final BigDecimal effectiveGstPercentage = gstPercentage != null ? gstPercentage : BigDecimal.valueOf(18); + + final List> existing = this.jdbcTemplate.queryForList( + "SELECT id FROM m_sponsor_dd_gl_mapping LIMIT 1"); + + if (!existing.isEmpty()) { + final long id = ((Number) existing.get(0).get("id")).longValue(); + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_gl_mapping SET dd_payable_gl_account_id = ?, cheque_clearing_gl_account_id = ?, " + + "cash_gl_account_id = ?, charges_income_gl_account_id = ?, cash_suspense_gl_account_id = ?, " + + "gst_percentage = ?, gst_payable_gl_account_id = ?, dd_limit_amount = ? WHERE id = ?", + ddPayableGlAccountId, chequeClearingGlAccountId, cashGlAccountId, chargesIncomeGlAccountId, + cashSuspenseGlAccountId, effectiveGstPercentage, gstPayableGlAccountId, ddLimitAmount, id); + return new CommandProcessingResultBuilder().withEntityId(id).build(); + } + + final KeyHolder kh = new GeneratedKeyHolder(); + this.jdbcTemplate.update(con -> { + final java.sql.PreparedStatement ps = con.prepareStatement( + "INSERT INTO m_sponsor_dd_gl_mapping (dd_payable_gl_account_id, cheque_clearing_gl_account_id, " + + "cash_gl_account_id, charges_income_gl_account_id, cash_suspense_gl_account_id, gst_percentage, " + + "gst_payable_gl_account_id, dd_limit_amount, created_date) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + new String[] { "id" }); + ps.setLong(1, ddPayableGlAccountId); + if (chequeClearingGlAccountId != null) ps.setLong(2, chequeClearingGlAccountId); else ps.setNull(2, java.sql.Types.BIGINT); + if (cashGlAccountId != null) ps.setLong(3, cashGlAccountId); else ps.setNull(3, java.sql.Types.BIGINT); + if (chargesIncomeGlAccountId != null) ps.setLong(4, chargesIncomeGlAccountId); else ps.setNull(4, java.sql.Types.BIGINT); + if (cashSuspenseGlAccountId != null) ps.setLong(5, cashSuspenseGlAccountId); else ps.setNull(5, java.sql.Types.BIGINT); + ps.setBigDecimal(6, effectiveGstPercentage); + if (gstPayableGlAccountId != null) ps.setLong(7, gstPayableGlAccountId); else ps.setNull(7, java.sql.Types.BIGINT); + if (ddLimitAmount != null) ps.setBigDecimal(8, ddLimitAmount); else ps.setNull(8, java.sql.Types.DECIMAL); + ps.setObject(9, LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); + return ps; + }, kh); + final long newId = Objects.requireNonNull(kh.getKey()).longValue(); + return new CommandProcessingResultBuilder().withEntityId(newId).build(); + } + + // ── Validation helpers ───────────────────────────────────────────────── + + private Map findLeafletOrThrow(final Long leafletId) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT id, status, inventory_id, dd_number FROM m_sponsor_dd_leaflet WHERE id = ?", leafletId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("Demand Draft leaflet not found."); + } + return rows.get(0); + } + + private Long findInventorySponsorBankId(final Long inventoryId) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT sponsor_bank_id FROM m_sponsor_dd_inventory WHERE id = ?", inventoryId); + if (rows.isEmpty()) { + throw new IllegalStateException("Sponsor DD inventory not found for this leaflet."); + } + return ((Number) rows.get(0).get("sponsor_bank_id")).longValue(); + } + + private Long findDefaultPaymentTypeId() { + final List> rows = this.jdbcTemplate.queryForList("SELECT id FROM m_payment_type LIMIT 1"); + return rows.isEmpty() ? 1L : ((Number) rows.get(0).get("id")).longValue(); + } + + private Map findGlMappingOrThrow(final String paymentMethod, final BigDecimal chargesAmount) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT dd_payable_gl_account_id, cheque_clearing_gl_account_id, cash_gl_account_id, charges_income_gl_account_id, " + + "cash_suspense_gl_account_id, gst_percentage, gst_payable_gl_account_id, dd_limit_amount FROM m_sponsor_dd_gl_mapping LIMIT 1"); + if (rows.isEmpty()) { + throw new IllegalStateException( + "No Sponsor DD GL mapping is configured for this sponsor bank. Ask an administrator to configure the Sponsor DD Payable GL account first."); + } + final Map mapping = rows.get(0); + if (METHOD_CHEQUE.equals(paymentMethod) && mapping.get("cheque_clearing_gl_account_id") == null) { + throw new IllegalStateException( + "No cheque-clearing GL account is configured for this sponsor bank. Ask an administrator to configure it before accepting cheque payments."); + } + if (METHOD_CASH.equals(paymentMethod) && mapping.get("cash_gl_account_id") == null) { + throw new IllegalStateException( + "No cash-in-hand GL account is configured for this sponsor bank. Ask an administrator to configure it before accepting cash payments."); + } + if (METHOD_CASH.equals(paymentMethod) && mapping.get("cash_suspense_gl_account_id") == null) { + throw new IllegalStateException( + "No cash-received-pending-approval GL account is configured for this sponsor bank. Ask an administrator to configure it before accepting cash payments."); + } + final boolean hasCharges = chargesAmount != null && chargesAmount.compareTo(BigDecimal.ZERO) > 0; + if (hasCharges && mapping.get("charges_income_gl_account_id") == null) { + throw new IllegalStateException( + "No charges/commission income GL account is configured for this sponsor bank. Ask an administrator to configure it before collecting DD issuance charges."); + } + final BigDecimal gstPercentage = (BigDecimal) mapping.get("gst_percentage"); + if (hasCharges && gstPercentage != null && gstPercentage.compareTo(BigDecimal.ZERO) > 0 + && mapping.get("gst_payable_gl_account_id") == null) { + throw new IllegalStateException( + "No GST payable GL account is configured for this sponsor bank. Ask an administrator to configure it before collecting DD issuance charges."); + } + return mapping; + } + + /** + * Admin-configured ceiling (m_sponsor_dd_gl_mapping.dd_limit_amount) applied two ways: (1) a + * single DD's own face value must not exceed it, and (2) the combined face value of every DD + * already issued or still pending approval for the same beneficiary name on the same issued date + * - plus this one - must not exceed it either, so a large cash amount can't be split into several + * smaller drafts to the same person/day to dodge the limit. Beneficiary name is matched via + * NORMALIZED_NAME_COLUMN/PARAM (case folded, all non-alphanumerics stripped), so "KK", "K K", + * "K.K." etc. are treated as the same beneficiary rather than being trivially split around the + * check by varying spacing/punctuation. Null/zero-or-negative limit means no limit is + * configured/enforced. + */ + private void validateDdLimit(final Map glMapping, final String beneficiaryName, final BigDecimal faceValue, + final LocalDate issuedDate, final Long excludeIssueRequestId) { + final BigDecimal ddLimitAmount = (BigDecimal) glMapping.get("dd_limit_amount"); + if (ddLimitAmount == null || ddLimitAmount.compareTo(BigDecimal.ZERO) <= 0) { + return; + } + if (faceValue.compareTo(ddLimitAmount) > 0) { + throw new IllegalArgumentException( + "DD face value (" + faceValue + ") exceeds the configured DD limit of " + ddLimitAmount + "."); + } + final BigDecimal issuedSoFar = sumIssuedForBeneficiary(beneficiaryName, issuedDate); + final BigDecimal pendingSoFar = sumPendingForBeneficiaryExcludingId(beneficiaryName, issuedDate, excludeIssueRequestId); + throwIfCombinedExceedsLimit(beneficiaryName, issuedDate, faceValue, issuedSoFar.add(pendingSoFar), ddLimitAmount); + } + + /** + * Same DD-limit ceiling, but for a whole multi-DD slip's total against ONE beneficiary at once + * (see submitIssueRequestBatch/approveIssueRequestBatch) instead of a single DD row. batchTotal is + * the sum of every line in THIS slip for this one (already-normalized) beneficiary name - + * excludeBatchId excludes that slip's own still-PENDING rows from the "pending elsewhere" sum + * (they're already folded into batchTotal), while still counting any OTHER pending/issued DDs for + * the same beneficiary/day. + */ + private void validateDdLimitForBatch(final Map glMapping, final String normalizedBeneficiaryName, + final BigDecimal batchTotal, final LocalDate issuedDate, final Long excludeBatchId) { + final BigDecimal ddLimitAmount = (BigDecimal) glMapping.get("dd_limit_amount"); + if (ddLimitAmount == null || ddLimitAmount.compareTo(BigDecimal.ZERO) <= 0) { + return; + } + final BigDecimal issuedSoFar = sumIssuedForBeneficiary(normalizedBeneficiaryName, issuedDate); + final BigDecimal pendingSoFar = sumPendingForBeneficiaryExcludingBatch(normalizedBeneficiaryName, issuedDate, excludeBatchId); + throwIfCombinedExceedsLimit(normalizedBeneficiaryName, issuedDate, batchTotal, issuedSoFar.add(pendingSoFar), ddLimitAmount); + } + + private void throwIfCombinedExceedsLimit(final String beneficiaryName, final LocalDate issuedDate, final BigDecimal thisAmount, + final BigDecimal alreadyCommitted, final BigDecimal ddLimitAmount) { + final BigDecimal combinedTotal = alreadyCommitted.add(thisAmount); + if (combinedTotal.compareTo(ddLimitAmount) > 0) { + throw new IllegalStateException("Combined DD face value for beneficiary '" + beneficiaryName + "' on " + issuedDate + + " (already issued/pending " + alreadyCommitted + " + this DD " + thisAmount + " = " + combinedTotal + + ") would exceed the configured DD limit of " + ddLimitAmount + "."); + } + } + + private BigDecimal sumIssuedForBeneficiary(final String beneficiaryName, final LocalDate issuedDate) { + return this.jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(face_value), 0) FROM m_sponsor_dd_leaflet " + + "WHERE " + NORMALIZED_NAME_COLUMN + " = " + NORMALIZED_NAME_PARAM + + " AND issued_date = ? AND status = 'ISSUED'", + BigDecimal.class, beneficiaryName, issuedDate); + } + + private BigDecimal sumPendingForBeneficiaryExcludingId(final String beneficiaryName, final LocalDate issuedDate, + final Long excludeIssueRequestId) { + if (excludeIssueRequestId != null) { + return this.jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(face_value), 0) FROM m_sponsor_dd_issue_request " + + "WHERE " + NORMALIZED_NAME_COLUMN + " = " + NORMALIZED_NAME_PARAM + + " AND issued_date = ? AND status = 'PENDING' AND id <> ?", + BigDecimal.class, beneficiaryName, issuedDate, excludeIssueRequestId); + } + return this.jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(face_value), 0) FROM m_sponsor_dd_issue_request " + + "WHERE " + NORMALIZED_NAME_COLUMN + " = " + NORMALIZED_NAME_PARAM + + " AND issued_date = ? AND status = 'PENDING'", + BigDecimal.class, beneficiaryName, issuedDate); + } + + private BigDecimal sumPendingForBeneficiaryExcludingBatch(final String beneficiaryName, final LocalDate issuedDate, + final Long excludeBatchId) { + if (excludeBatchId != null) { + return this.jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(face_value), 0) FROM m_sponsor_dd_issue_request " + + "WHERE " + NORMALIZED_NAME_COLUMN + " = " + NORMALIZED_NAME_PARAM + + " AND issued_date = ? AND status = 'PENDING' AND (batch_id IS NULL OR batch_id <> ?)", + BigDecimal.class, beneficiaryName, issuedDate, excludeBatchId); + } + return this.jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(face_value), 0) FROM m_sponsor_dd_issue_request " + + "WHERE " + NORMALIZED_NAME_COLUMN + " = " + NORMALIZED_NAME_PARAM + + " AND issued_date = ? AND status = 'PENDING'", + BigDecimal.class, beneficiaryName, issuedDate); + } + + /** Mirrors NORMALIZED_NAME_COLUMN/PARAM in Java, for grouping a batch's own lines in memory. */ + private static String normalizeBeneficiaryName(final String name) { + return name == null ? "" : name.toUpperCase().replaceAll("[^A-Z0-9]", ""); + } + + /** GST is charged on the DD issuance commission/charges only, never on the DD face value. */ + private static BigDecimal computeGst(final BigDecimal chargesAmount, final BigDecimal gstPercentage) { + if (chargesAmount == null || gstPercentage == null || chargesAmount.compareTo(BigDecimal.ZERO) <= 0 + || gstPercentage.compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ZERO; + } + return chargesAmount.multiply(gstPercentage).divide(BigDecimal.valueOf(100), 2, java.math.RoundingMode.HALF_UP); + } + + private void validateClientExists(final Long clientId) { + final List> rows = this.jdbcTemplate.queryForList("SELECT id FROM m_client WHERE id = ?", clientId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("Client not found with ID: " + clientId + + ". Enter the client's internal ID (visible in the URL on their profile page), not their account number."); + } + } + + private void validateAccountBelongsToClient(final String tableName, final Long accountId, final Long clientId, final String label) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT client_id, account_no FROM " + tableName + " WHERE id = ?", accountId); + if (rows.isEmpty()) { + throw new IllegalArgumentException(label + " not found with ID: " + accountId + + ". Enter the account's internal ID, not its printed account number."); + } + final Object ownerClientId = rows.get(0).get("client_id"); + if (ownerClientId == null || ((Number) ownerClientId).longValue() != clientId.longValue()) { + throw new IllegalArgumentException(label + " " + accountId + " (account no. " + rows.get(0).get("account_no") + + ") does not belong to client " + clientId + "."); + } + } + + /** + * Mirrors the balance check Fineract's own withdrawal command runs at approval time + * (running balance + overdraft limit, if allowed - minimum required balance, if enforced), + * but at submission time so a clerk gets an immediate error instead of the manager discovering + * insufficient funds only when trying to approve. withdrawalAmount must be the FULL amount that + * will actually leave the account at approval - face value + charges + GST - matching what + * executeSavingsWithdrawal/executeCurrentAccountWithdrawal actually withdraw. + */ + private void validateSufficientBalance(final Long savingsAccountId, final Long currentAccountId, final BigDecimal withdrawalAmount) { + final String tableName = savingsAccountId != null ? "m_savings_account" : "m_current_account"; + final Long accountId = savingsAccountId != null ? savingsAccountId : currentAccountId; + final List> rows = this.jdbcTemplate.queryForList( + "SELECT account_balance_derived, min_required_balance, enforce_min_required_balance, allow_overdraft, overdraft_limit " + + "FROM " + tableName + " WHERE id = ?", accountId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("Account not found with ID: " + accountId); + } + final Map row = rows.get(0); + final BigDecimal balance = (BigDecimal) row.get("account_balance_derived"); + final boolean enforceMin = Boolean.TRUE.equals(row.get("enforce_min_required_balance")); + final BigDecimal minRequired = enforceMin && row.get("min_required_balance") != null + ? (BigDecimal) row.get("min_required_balance") : BigDecimal.ZERO; + final boolean allowOverdraft = Boolean.TRUE.equals(row.get("allow_overdraft")); + final BigDecimal overdraftLimit = allowOverdraft && row.get("overdraft_limit") != null + ? (BigDecimal) row.get("overdraft_limit") : BigDecimal.ZERO; + final BigDecimal withdrawable = balance.add(overdraftLimit).subtract(minRequired); + if (withdrawalAmount.compareTo(withdrawable) > 0) { + final StringBuilder message = new StringBuilder("Insufficient balance in the customer's account for this DD. ") + .append("Account balance: ").append(balance); + if (minRequired.compareTo(BigDecimal.ZERO) > 0) { + message.append(", minus minimum balance to maintain: ").append(minRequired); + } + if (overdraftLimit.compareTo(BigDecimal.ZERO) > 0) { + message.append(", plus overdraft limit: ").append(overdraftLimit); + } + message.append(" = amount available to withdraw: ").append(withdrawable) + .append(". DD amount requested (face value + charges + GST): ").append(withdrawalAmount).append("."); + throw new IllegalStateException(message.toString()); + } + } + + private void validatePaymentMethodFields(final String paymentMethod, final Long clientId, final Long savingsAccountId, + final Long currentAccountId, final String chequeNumber, final String chequeBankName, final String walkinPurchaserName, + final String walkinPurchaserAddress, final String walkinPurchaserAadhaar) { + if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) { + if (clientId == null) { + throw new IllegalArgumentException("Client ID is required for an account-debit DD issuance."); + } + final boolean hasSavings = savingsAccountId != null; + final boolean hasCurrent = currentAccountId != null; + if (hasSavings == hasCurrent) { + throw new IllegalArgumentException("Provide exactly one of Savings Account or Current Account for an account-debit DD issuance."); + } + } else if (METHOD_CHEQUE.equals(paymentMethod)) { + if (clientId == null) { + throw new IllegalArgumentException("Client ID is required for a cheque-funded DD issuance."); + } + if (chequeNumber == null || chequeNumber.isBlank() || chequeBankName == null || chequeBankName.isBlank()) { + throw new IllegalArgumentException("Cheque Number and Cheque Bank Name are required for a cheque-funded DD issuance."); + } + if (savingsAccountId != null || currentAccountId != null) { + throw new IllegalArgumentException("Do not provide a Savings/Current Account when paying by cheque."); + } + } else if (METHOD_CASH.equals(paymentMethod)) { + if (clientId != null) { + throw new IllegalArgumentException( + "Do not provide a Client ID for a cash DD issuance — capture the walk-in purchaser's own details instead."); + } + if (savingsAccountId != null || currentAccountId != null || (chequeNumber != null && !chequeNumber.isBlank())) { + throw new IllegalArgumentException("Do not provide an account or cheque details when paying by cash."); + } + if (walkinPurchaserName == null || walkinPurchaserName.isBlank() + || walkinPurchaserAddress == null || walkinPurchaserAddress.isBlank() + || walkinPurchaserAadhaar == null || walkinPurchaserAadhaar.isBlank()) { + throw new IllegalArgumentException("Purchaser Name, Address and Aadhaar Number are required for a cash DD issuance."); + } + if (!walkinPurchaserAadhaar.replaceAll("\\s", "").matches("\\d{12}")) { + throw new IllegalArgumentException("Aadhaar Number must be exactly 12 digits."); + } + } else { + throw new IllegalArgumentException("paymentMethod must be ACCOUNT_DEBIT, CHEQUE, or CASH."); + } + } + + /** Aadhaar is never stored in full — only the last 4 digits are retained, per UIDAI/RBI guidance. */ + private static String maskAadhaar(final String aadhaar) { + final String digitsOnly = aadhaar.replaceAll("\\s", ""); + final String last4 = digitsOnly.substring(digitsOnly.length() - 4); + return "XXXX-XXXX-" + last4; + } + + private Map findIssueRequestOrThrow(final Long issueRequestId) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT * FROM m_sponsor_dd_issue_request WHERE id = ?", issueRequestId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("Sponsor DD issue request not found."); + } + return rows.get(0); + } + + private boolean isCheckerRole(final AppUser user) { + if (user.hasAnyPermission("ALL_FUNCTIONS")) return true; + return user.getRoles().stream() + .map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim()) + .anyMatch(n -> n.contains("manager") || n.contains("head cashier")); + } + + private void validateCheckerRole(final AppUser user) { + if (!isCheckerRole(user)) { + throw new IllegalStateException("Only a Bank Manager (or Head Cashier) can approve, reject, or configure Sponsor DD GL mappings."); + } + } + + // ── Command / GL posting helpers (mirrors the pattern used by the Clerk Disbursement workstation) ── + + private String executeSavingsWithdrawal(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate, + final Long paymentTypeId, final String note) { + final JsonObject payload = new JsonObject(); + payload.addProperty("locale", "en"); + payload.addProperty("dateFormat", "dd MMMM yyyy"); + payload.addProperty("transactionDate", formatForFineract(txnDate)); + payload.addProperty("transactionAmount", amount); + payload.addProperty("paymentTypeId", paymentTypeId); + payload.addProperty("note", note); + + final CommandWrapper cmd = new CommandWrapperBuilder() + .savingsAccountWithdrawal(savingsAccountId) + .withJson(payload.toString()) + .build(); + this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); + return result.getResourceId().toString(); + } + + private String executeCurrentAccountWithdrawal(final Long currentAccountId, final BigDecimal amount, final LocalDate txnDate, + final Long paymentTypeId, final String note) { + final JsonObject payload = new JsonObject(); + payload.addProperty("locale", "en"); + payload.addProperty("dateFormat", "dd MMMM yyyy"); + payload.addProperty("transactionDate", formatForFineract(txnDate)); + payload.addProperty("transactionAmount", amount); + payload.addProperty("paymentTypeId", paymentTypeId); + payload.addProperty("note", note); + + final CommandWrapper cmd = new CommandWrapperBuilder() + .currentAccountWithdrawal(currentAccountId) + .withJson(payload.toString()) + .build(); + this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); + return result.getResourceId().toString(); + } + + /** + * A savings/current withdrawal auto-creates its own Dr SavingsControl / Cr CashVault journal entry — + * but the DD money isn't leaving as physical cash, it's becoming a liability to the sponsor bank. + * Hides that auto-created entry from GL reports (mirrors the same technique the Clerk Disbursement + * module uses for its own "Collection" flow) so only our combined Dr SavingsControl / Cr Sponsor DD + * Payable entry, posted via postGlOffsetEntry() right after this call, is visible. + */ + private void markWithdrawalGlEntriesReversed(final String transactionId) { + this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", transactionId); + } + + /** Returns the Savings Control GL account ID for this savings account's product (from acc_product_mapping). */ + private Long getSavingsControlGlId(final Long savingsAccountId) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT apm.gl_account_id FROM m_savings_account sa " + + "JOIN acc_product_mapping apm ON apm.product_id = sa.product_id " + + "WHERE sa.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", + savingsAccountId); + return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue(); + } + + /** Returns the Savings Control GL account ID for this current account's product (from acc_product_mapping). */ + private Long getCurrentControlGlId(final Long currentAccountId) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT apm.gl_account_id FROM m_current_account ca " + + "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id " + + "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", + currentAccountId); + return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue(); + } + + /** + * Posts a direct GL journal entry through Fineract's real accounting command: Dr drGlAccountId for + * (amount + chargesAmount) / Cr crGlAccountId for amount / Cr chargesGlAccountId for chargesAmount + * (only if chargesAmount > 0 — charges are the bank's fee income, not part of the Sponsor DD Payable + * liability, so they get their own credit leg rather than being folded into the liability). Resolves + * the resulting row ID from acc_gl_journal_entry so it can be linked from m_sponsor_dd_issue_request / + * m_sponsor_dd_transaction for audit traceability. When linkedAccountId is given (the client's + * savings/current account for an account-debit DD), the Dr-side row is tagged entity_type_enum=2 / + * entity_id=linkedAccountId so GL reports show the account number instead of a blank "—", matching + * how Fineract's own auto-generated entries display. + */ + private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount, + final BigDecimal chargesAmount, final Long chargesGlAccountId, final Long officeId, final LocalDate txnDate, + final String comments, final Long linkedAccountId) { + return postGlOffsetEntry(drGlAccountId, crGlAccountId, amount, chargesAmount, chargesGlAccountId, null, null, officeId, + txnDate, comments, linkedAccountId); + } + + /** As above, with an extra optional GST credit leg (GST is charged on chargesAmount only). */ + private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount, + final BigDecimal chargesAmount, final Long chargesGlAccountId, final BigDecimal gstAmount, final Long gstGlAccountId, + final Long officeId, final LocalDate txnDate, final String comments, final Long linkedAccountId) { + + final boolean hasCharges = chargesAmount != null && chargesAmount.compareTo(BigDecimal.ZERO) > 0; + final boolean hasGst = gstAmount != null && gstAmount.compareTo(BigDecimal.ZERO) > 0; + BigDecimal drAmount = amount; + if (hasCharges) drAmount = drAmount.add(chargesAmount); + if (hasGst) drAmount = drAmount.add(gstAmount); + + final com.google.gson.JsonArray debits = new com.google.gson.JsonArray(); + final JsonObject debitEntry = new JsonObject(); + debitEntry.addProperty("glAccountId", drGlAccountId); + debitEntry.addProperty("amount", drAmount); + debits.add(debitEntry); + + final com.google.gson.JsonArray credits = new com.google.gson.JsonArray(); + final JsonObject creditEntry = new JsonObject(); + creditEntry.addProperty("glAccountId", crGlAccountId); + creditEntry.addProperty("amount", amount); + credits.add(creditEntry); + + if (hasCharges) { + final JsonObject chargesCreditEntry = new JsonObject(); + chargesCreditEntry.addProperty("glAccountId", chargesGlAccountId); + chargesCreditEntry.addProperty("amount", chargesAmount); + credits.add(chargesCreditEntry); + } + + if (hasGst) { + final JsonObject gstCreditEntry = new JsonObject(); + gstCreditEntry.addProperty("glAccountId", gstGlAccountId); + gstCreditEntry.addProperty("amount", gstAmount); + credits.add(gstCreditEntry); + } + + final JsonObject payload = new JsonObject(); + payload.addProperty("officeId", officeId); + payload.addProperty("transactionDate", formatForFineract(txnDate)); + payload.addProperty("dateFormat", "dd MMMM yyyy"); + payload.addProperty("locale", "en"); + payload.addProperty("currencyCode", "INR"); + payload.addProperty("comments", comments); + payload.add("debits", debits); + payload.add("credits", credits); + + final CommandWrapper cmd = new CommandWrapperBuilder() + .createJournalEntry() + .withJson(payload.toString()) + .build(); + this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); + final String transactionId = result != null ? result.getTransactionId() : null; + if (transactionId == null) return null; + + if (linkedAccountId != null) { + this.jdbcTemplate.update( + "UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE transaction_id = ? AND type_enum = 2", + linkedAccountId, transactionId); + } + + final List> rows = this.jdbcTemplate.queryForList( + "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? LIMIT 1", transactionId); + return rows.isEmpty() ? null : ((Number) rows.get(0).get("id")).longValue(); + } + + private String formatForFineract(final LocalDate date) { + return date.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformService.java new file mode 100644 index 000000000..b013e284b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformService.java @@ -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.sponsordd.service; + +import java.util.Collection; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDCashierDepositOptionData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDGlMappingData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDInventoryData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData; + +public interface SponsorDDReadPlatformService { + + Collection retrieveAllInventories(); + + SponsorDDInventoryData retrieveInventory(Long inventoryId); + + Collection retrieveAllLeafletsByInventory(Long inventoryId); + + SponsorDDLeafletData retrieveLeaflet(Long leafletId); + + SponsorDDLeafletData retrieveLeafletByDdNumber(String ddNumber); + + Collection retrieveLeaflets(Long sponsorBankId, String status, String adviceStatus); + + Collection retrieveAllReconciliationBatches(); + + org.apache.fineract.portfolio.sponsordd.data.SponsorDDRecoBatchData retrieveReconciliationBatch(Long batchId); + + Collection retrieveReconciliationRecordsByBatch(Long batchId); + + Collection retrieveAllGlMappings(); + + SponsorDDGlMappingData retrieveGlMappingBySponsorBank(Long sponsorBankId); + + Collection retrieveAllIssueRequests(String status); + + Collection retrieveMyIssueRequests(Long makerId); + + SponsorDDIssueRequestData retrieveIssueRequest(Long issueRequestId); + + Collection retrieveAllReprintRequests(String status); + + /** + * Approved cashier Transaction Entry (AC.TP=GL) deposit requests, credited to the DD Cash + * Suspense GL account, and not yet linked to any Sponsor DD issue request - candidates a clerk + * can pick from when submitting a new CASH DD issuance request. + */ + Collection retrieveAvailableCashierDepositRequests(); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java new file mode 100644 index 000000000..010b6bee4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java @@ -0,0 +1,465 @@ +/** + * 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.sponsordd.service; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.ArrayList; +import java.util.List; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDCashierDepositOptionData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDGlMappingData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDInventoryData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +@Service +public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformService { + + private final JdbcTemplate jdbcTemplate; + private final InventoryMapper inventoryMapper = new InventoryMapper(); + private final LeafletMapper leafletMapper = new LeafletMapper(); + + @Autowired + public SponsorDDReadPlatformServiceImpl(final JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + private static final String INVENTORY_SELECT = + "select inv.id, inv.sponsor_bank_id, sb.name as sponsor_bank_name, inv.branch_id, o.name as branch_name, " + + "inv.series_start, inv.series_end, inv.total_leaves, inv.received_date, inv.status, " + + "(select count(*) from m_sponsor_dd_leaflet lf where lf.inventory_id = inv.id and lf.status = 'AVAILABLE') as available_leaves " + + "from m_sponsor_dd_inventory inv " + + "join m_sponsor_bank sb on sb.id = inv.sponsor_bank_id " + + "join m_office o on o.id = inv.branch_id "; + + @Override + public Collection retrieveAllInventories() { + final String sql = INVENTORY_SELECT + "order by inv.id desc"; + return this.jdbcTemplate.query(sql, this.inventoryMapper); + } + + @Override + public SponsorDDInventoryData retrieveInventory(final Long inventoryId) { + try { + final String sql = INVENTORY_SELECT + "where inv.id = ?"; + return this.jdbcTemplate.queryForObject(sql, this.inventoryMapper, inventoryId); + } catch (final EmptyResultDataAccessException e) { + return null; + } + } + + private static final String LEAFLET_SELECT = + "select leaf.id, leaf.inventory_id, leaf.dd_number, leaf.status, leaf.face_value, leaf.issued_to_client_id, " + + "c.display_name as client_name, leaf.walkin_purchaser_name, leaf.walkin_purchaser_address, " + + "leaf.walkin_purchaser_aadhaar_masked, leaf.beneficiary_name, leaf.payable_at_branch, leaf.payable_at_city, " + + "leaf.issuing_branch_id, o.name as branch_name, " + + "leaf.issued_date, leaf.cleared_date, leaf.advice_status, leaf.advice_sent_date, pir.id as pending_issue_request_id, " + + "leaf.print_count, prr.id as pending_reprint_request_id, arr.id as approved_reprint_request_id, " + + "apr.checker_id as approver_user_id, approver.username as approver_username " + + "from m_sponsor_dd_leaflet leaf " + + "left join m_client c on c.id = leaf.issued_to_client_id " + + "left join m_office o on o.id = leaf.issuing_branch_id " + + "left join m_sponsor_dd_issue_request pir on pir.leaflet_id = leaf.id and pir.status = 'PENDING' " + + "left join m_sponsor_dd_reprint_request prr on prr.leaflet_id = leaf.id and prr.status = 'PENDING' " + + "left join m_sponsor_dd_reprint_request arr on arr.leaflet_id = leaf.id and arr.status = 'APPROVED' and arr.consumed = false " + + "left join m_sponsor_dd_issue_request apr on apr.leaflet_id = leaf.id and apr.status = 'APPROVED' " + + "left join m_appuser approver on approver.id = apr.checker_id "; + + @Override + public Collection retrieveAllLeafletsByInventory(final Long inventoryId) { + final String sql = LEAFLET_SELECT + "where leaf.inventory_id = ? order by leaf.dd_number asc"; + return this.jdbcTemplate.query(sql, this.leafletMapper, inventoryId); + } + + @Override + public SponsorDDLeafletData retrieveLeaflet(final Long leafletId) { + try { + final String sql = LEAFLET_SELECT + "where leaf.id = ?"; + return this.jdbcTemplate.queryForObject(sql, this.leafletMapper, leafletId); + } catch (final EmptyResultDataAccessException e) { + return null; + } + } + + @Override + public SponsorDDLeafletData retrieveLeafletByDdNumber(final String ddNumber) { + try { + final String sql = LEAFLET_SELECT + "where leaf.dd_number = ?"; + return this.jdbcTemplate.queryForObject(sql, this.leafletMapper, ddNumber); + } catch (final EmptyResultDataAccessException e) { + return null; + } + } + + private static final class InventoryMapper implements RowMapper { + @Override + public SponsorDDInventoryData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long id = rs.getLong("id"); + final Long sponsorBankId = rs.getLong("sponsor_bank_id"); + final String sponsorBankName = rs.getString("sponsor_bank_name"); + final Long branchId = rs.getLong("branch_id"); + final String branchName = rs.getString("branch_name"); + final Long seriesStart = rs.getLong("series_start"); + final Long seriesEnd = rs.getLong("series_end"); + final Integer totalLeaves = rs.getInt("total_leaves"); + final LocalDate receivedDate = rs.getDate("received_date") != null ? rs.getDate("received_date").toLocalDate() : null; + final String status = rs.getString("status"); + final Integer availableLeaves = rs.getInt("available_leaves"); + return new SponsorDDInventoryData(id, sponsorBankId, sponsorBankName, branchId, branchName, seriesStart, seriesEnd, totalLeaves, receivedDate, status, availableLeaves); + } + } + + private static final class LeafletMapper implements RowMapper { + @Override + public SponsorDDLeafletData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long id = rs.getLong("id"); + final Long inventoryId = rs.getLong("inventory_id"); + final String ddNumber = rs.getString("dd_number"); + final String status = rs.getString("status"); + final BigDecimal faceValue = rs.getBigDecimal("face_value"); + final Long issuedToClientId = rs.getObject("issued_to_client_id") != null ? rs.getLong("issued_to_client_id") : null; + final String clientName = rs.getString("client_name"); + final String walkinPurchaserName = rs.getString("walkin_purchaser_name"); + final String walkinPurchaserAddress = rs.getString("walkin_purchaser_address"); + final String walkinPurchaserAadhaarMasked = rs.getString("walkin_purchaser_aadhaar_masked"); + final String beneficiaryName = rs.getString("beneficiary_name"); + final String payableAtBranch = rs.getString("payable_at_branch"); + final String payableAtCity = rs.getString("payable_at_city"); + final Long issuingBranchId = rs.getObject("issuing_branch_id") != null ? rs.getLong("issuing_branch_id") : null; + final String branchName = rs.getString("branch_name"); + final LocalDate issuedDate = rs.getDate("issued_date") != null ? rs.getDate("issued_date").toLocalDate() : null; + final LocalDate clearedDate = rs.getDate("cleared_date") != null ? rs.getDate("cleared_date").toLocalDate() : null; + final String adviceStatus = rs.getString("advice_status"); + final LocalDateTime adviceSentDate = rs.getTimestamp("advice_sent_date") != null ? rs.getTimestamp("advice_sent_date").toLocalDateTime() : null; + final Long pendingIssueRequestId = rs.getObject("pending_issue_request_id") != null ? rs.getLong("pending_issue_request_id") : null; + final Integer printCount = rs.getObject("print_count") != null ? rs.getInt("print_count") : 0; + final Long pendingReprintRequestId = rs.getObject("pending_reprint_request_id") != null ? rs.getLong("pending_reprint_request_id") : null; + final Long approvedUnconsumedReprintRequestId = rs.getObject("approved_reprint_request_id") != null ? rs.getLong("approved_reprint_request_id") : null; + final Long approverUserId = rs.getObject("approver_user_id") != null ? rs.getLong("approver_user_id") : null; + final String approverUsername = rs.getString("approver_username"); + return new SponsorDDLeafletData(id, inventoryId, ddNumber, status, faceValue, issuedToClientId, clientName, + walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName, payableAtBranch, + payableAtCity, issuingBranchId, branchName, issuedDate, clearedDate, adviceStatus, adviceSentDate, + pendingIssueRequestId, printCount, pendingReprintRequestId, approvedUnconsumedReprintRequestId, + approverUserId, approverUsername); + } + } + + @Override + public Collection retrieveAllReconciliationBatches() { + final String sql = "select b.id, b.batch_reference, b.file_name, b.file_hash, b.status, b.total_records, b.total_amount, b.success_count, b.mismatch_count, u.username as maker_username, b.created_date from m_sponsor_dd_reco_batch b join m_appuser u on u.id = b.createdby_id order by b.id desc"; + return this.jdbcTemplate.query(sql, new RecoBatchMapper()); + } + + @Override + public org.apache.fineract.portfolio.sponsordd.data.SponsorDDRecoBatchData retrieveReconciliationBatch(final Long batchId) { + try { + final String sql = "select b.id, b.batch_reference, b.file_name, b.file_hash, b.status, b.total_records, b.total_amount, b.success_count, b.mismatch_count, u.username as maker_username, b.created_date from m_sponsor_dd_reco_batch b join m_appuser u on u.id = b.createdby_id where b.id = ?"; + return this.jdbcTemplate.queryForObject(sql, new RecoBatchMapper(), batchId); + } catch (final EmptyResultDataAccessException e) { + return null; + } + } + + @Override + public Collection retrieveReconciliationRecordsByBatch(final Long batchId) { + final String sql = "select r.id, r.batch_id, r.dd_number, r.clearing_amount, r.clearing_date, r.clearing_ref_number, r.match_status, r.matched_leaflet_id, r.remarks, u.username as resolver_username, r.resolved_date from m_sponsor_dd_reco_record r left join m_appuser u on u.id = r.resolved_by_id where r.batch_id = ? order by r.id asc"; + return this.jdbcTemplate.query(sql, new RecoRecordMapper(), batchId); + } + + private static final class RecoBatchMapper implements RowMapper { + @Override + public org.apache.fineract.portfolio.sponsordd.data.SponsorDDRecoBatchData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long id = rs.getLong("id"); + final String batchReference = rs.getString("batch_reference"); + final String fileName = rs.getString("file_name"); + final String fileHash = rs.getString("file_hash"); + final String status = rs.getString("status"); + final Integer totalRecords = rs.getInt("total_records"); + final BigDecimal totalAmount = rs.getBigDecimal("total_amount"); + final Integer successCount = rs.getInt("success_count"); + final Integer mismatchCount = rs.getInt("mismatch_count"); + final String makerUsername = rs.getString("maker_username"); + final LocalDateTime createdDate = rs.getTimestamp("created_date") != null ? rs.getTimestamp("created_date").toLocalDateTime() : null; + return new org.apache.fineract.portfolio.sponsordd.data.SponsorDDRecoBatchData(id, batchReference, fileName, fileHash, status, totalRecords, totalAmount, successCount, mismatchCount, makerUsername, createdDate); + } + } + + private static final class RecoRecordMapper implements RowMapper { + @Override + public org.apache.fineract.portfolio.sponsordd.data.SponsorDDRecoRecordData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long id = rs.getLong("id"); + final Long batchId = rs.getLong("batch_id"); + final String ddNumber = rs.getString("dd_number"); + final BigDecimal clearingAmount = rs.getBigDecimal("clearing_amount"); + final LocalDate clearingDate = rs.getDate("clearing_date") != null ? rs.getDate("clearing_date").toLocalDate() : null; + final String clearingRefNumber = rs.getString("clearing_ref_number"); + final String matchStatus = rs.getString("match_status"); + final Long matchedLeafletId = rs.getObject("matched_leaflet_id") != null ? rs.getLong("matched_leaflet_id") : null; + final String remarks = rs.getString("remarks"); + final String resolverUsername = rs.getString("resolver_username"); + final LocalDateTime resolvedDate = rs.getTimestamp("resolved_date") != null ? rs.getTimestamp("resolved_date").toLocalDateTime() : null; + return new org.apache.fineract.portfolio.sponsordd.data.SponsorDDRecoRecordData(id, batchId, ddNumber, clearingAmount, clearingDate, clearingRefNumber, matchStatus, matchedLeafletId, remarks, resolverUsername, resolvedDate); + } + } + + private static final String GL_MAPPING_SELECT = + "select m.id, null as sponsor_bank_id, 'Global' as sponsor_bank_name, m.dd_payable_gl_account_id, " + + "payable.name as dd_payable_gl_account_name, m.cheque_clearing_gl_account_id, clearing.name as cheque_clearing_gl_account_name, " + + "m.cash_gl_account_id, cash.name as cash_gl_account_name, m.charges_income_gl_account_id, charges.name as charges_income_gl_account_name, " + + "m.cash_suspense_gl_account_id, suspense.name as cash_suspense_gl_account_name, " + + "m.gst_percentage, m.gst_payable_gl_account_id, gst.name as gst_payable_gl_account_name, " + + "m.dd_limit_amount " + + "from m_sponsor_dd_gl_mapping m " + + "join acc_gl_account payable on payable.id = m.dd_payable_gl_account_id " + + "left join acc_gl_account clearing on clearing.id = m.cheque_clearing_gl_account_id " + + "left join acc_gl_account cash on cash.id = m.cash_gl_account_id " + + "left join acc_gl_account charges on charges.id = m.charges_income_gl_account_id " + + "left join acc_gl_account suspense on suspense.id = m.cash_suspense_gl_account_id " + + "left join acc_gl_account gst on gst.id = m.gst_payable_gl_account_id "; + + @Override + public Collection retrieveAllGlMappings() { + final String sql = GL_MAPPING_SELECT; + return this.jdbcTemplate.query(sql, new GlMappingMapper()); + } + + @Override + public SponsorDDGlMappingData retrieveGlMappingBySponsorBank(final Long sponsorBankId) { + try { + final String sql = GL_MAPPING_SELECT + "limit 1"; + return this.jdbcTemplate.queryForObject(sql, new GlMappingMapper()); + } catch (final EmptyResultDataAccessException e) { + return null; + } + } + + private static final class GlMappingMapper implements RowMapper { + @Override + public SponsorDDGlMappingData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long id = rs.getLong("id"); + final Long sponsorBankId = rs.getObject("sponsor_bank_id") != null ? rs.getLong("sponsor_bank_id") : null; + final String sponsorBankName = rs.getString("sponsor_bank_name"); + final Long ddPayableGlAccountId = rs.getLong("dd_payable_gl_account_id"); + final String ddPayableGlAccountName = rs.getString("dd_payable_gl_account_name"); + final Long chequeClearingGlAccountId = rs.getObject("cheque_clearing_gl_account_id") != null ? rs.getLong("cheque_clearing_gl_account_id") : null; + final String chequeClearingGlAccountName = rs.getString("cheque_clearing_gl_account_name"); + final Long cashGlAccountId = rs.getObject("cash_gl_account_id") != null ? rs.getLong("cash_gl_account_id") : null; + final String cashGlAccountName = rs.getString("cash_gl_account_name"); + final Long chargesIncomeGlAccountId = rs.getObject("charges_income_gl_account_id") != null ? rs.getLong("charges_income_gl_account_id") : null; + final String chargesIncomeGlAccountName = rs.getString("charges_income_gl_account_name"); + final Long cashSuspenseGlAccountId = rs.getObject("cash_suspense_gl_account_id") != null ? rs.getLong("cash_suspense_gl_account_id") : null; + final String cashSuspenseGlAccountName = rs.getString("cash_suspense_gl_account_name"); + final BigDecimal gstPercentage = rs.getBigDecimal("gst_percentage"); + final Long gstPayableGlAccountId = rs.getObject("gst_payable_gl_account_id") != null ? rs.getLong("gst_payable_gl_account_id") : null; + final String gstPayableGlAccountName = rs.getString("gst_payable_gl_account_name"); + final BigDecimal ddLimitAmount = rs.getBigDecimal("dd_limit_amount"); + return new SponsorDDGlMappingData(id, sponsorBankId, sponsorBankName, ddPayableGlAccountId, ddPayableGlAccountName, + chequeClearingGlAccountId, chequeClearingGlAccountName, cashGlAccountId, cashGlAccountName, + chargesIncomeGlAccountId, chargesIncomeGlAccountName, cashSuspenseGlAccountId, cashSuspenseGlAccountName, + gstPercentage, gstPayableGlAccountId, gstPayableGlAccountName, ddLimitAmount); + } + } + + private static final String ISSUE_REQUEST_SELECT = + "select r.id, r.leaflet_id, leaf.dd_number, r.client_id, c.display_name as client_name, r.walkin_purchaser_name, " + + "r.walkin_purchaser_address, r.walkin_purchaser_aadhaar_masked, r.beneficiary_name, r.payable_at_branch, " + + "r.payable_at_city, " + + "r.face_value, r.issuing_branch_id, o.name as branch_name, r.issued_date, r.payment_method, r.savings_account_id, " + + "r.current_account_id, r.cheque_number, r.cheque_bank_name, r.charges_amount, r.gst_amount, r.gst_percentage_applied, " + + "r.status, r.maker_id, maker.username as maker_username, " + + "r.created_date, r.checker_id, checker.username as checker_username, r.checker_date, r.rejection_reason, " + + "r.resulting_savings_transaction_id, r.resulting_current_transaction_id, r.resulting_journal_entry_id, " + + "r.cash_receipt_journal_entry_id, r.cash_refund_journal_entry_id, r.batch_id " + + "from m_sponsor_dd_issue_request r " + + "join m_sponsor_dd_leaflet leaf on leaf.id = r.leaflet_id " + + "left join m_client c on c.id = r.client_id " + + "join m_office o on o.id = r.issuing_branch_id " + + "join m_appuser maker on maker.id = r.maker_id " + + "left join m_appuser checker on checker.id = r.checker_id "; + + @Override + public Collection retrieveAllIssueRequests(final String status) { + if (status != null && !status.isBlank()) { + final String sql = ISSUE_REQUEST_SELECT + "where r.status = ? order by r.id desc"; + return this.jdbcTemplate.query(sql, new IssueRequestMapper(), status); + } + final String sql = ISSUE_REQUEST_SELECT + "order by r.id desc"; + return this.jdbcTemplate.query(sql, new IssueRequestMapper()); + } + + @Override + public Collection retrieveMyIssueRequests(final Long makerId) { + final String sql = ISSUE_REQUEST_SELECT + "where r.maker_id = ? order by r.id desc"; + return this.jdbcTemplate.query(sql, new IssueRequestMapper(), makerId); + } + + @Override + public SponsorDDIssueRequestData retrieveIssueRequest(final Long issueRequestId) { + try { + final String sql = ISSUE_REQUEST_SELECT + "where r.id = ?"; + return this.jdbcTemplate.queryForObject(sql, new IssueRequestMapper(), issueRequestId); + } catch (final EmptyResultDataAccessException e) { + return null; + } + } + + private static final class IssueRequestMapper implements RowMapper { + @Override + public SponsorDDIssueRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long id = rs.getLong("id"); + final Long leafletId = rs.getLong("leaflet_id"); + final String ddNumber = rs.getString("dd_number"); + final Long clientId = rs.getObject("client_id") != null ? rs.getLong("client_id") : null; + final String clientName = rs.getString("client_name"); + final String walkinPurchaserName = rs.getString("walkin_purchaser_name"); + final String walkinPurchaserAddress = rs.getString("walkin_purchaser_address"); + final String walkinPurchaserAadhaarMasked = rs.getString("walkin_purchaser_aadhaar_masked"); + final String beneficiaryName = rs.getString("beneficiary_name"); + final String payableAtBranch = rs.getString("payable_at_branch"); + final String payableAtCity = rs.getString("payable_at_city"); + final BigDecimal faceValue = rs.getBigDecimal("face_value"); + final Long issuingBranchId = rs.getLong("issuing_branch_id"); + final String branchName = rs.getString("branch_name"); + final LocalDate issuedDate = rs.getDate("issued_date") != null ? rs.getDate("issued_date").toLocalDate() : null; + final String paymentMethod = rs.getString("payment_method"); + final Long savingsAccountId = rs.getObject("savings_account_id") != null ? rs.getLong("savings_account_id") : null; + final Long currentAccountId = rs.getObject("current_account_id") != null ? rs.getLong("current_account_id") : null; + final String chequeNumber = rs.getString("cheque_number"); + final String chequeBankName = rs.getString("cheque_bank_name"); + final BigDecimal chargesAmount = rs.getBigDecimal("charges_amount"); + final BigDecimal gstAmount = rs.getBigDecimal("gst_amount"); + final BigDecimal gstPercentageApplied = rs.getBigDecimal("gst_percentage_applied"); + final String status = rs.getString("status"); + final Long makerId = rs.getLong("maker_id"); + final String makerUsername = rs.getString("maker_username"); + final LocalDateTime createdDate = rs.getTimestamp("created_date") != null ? rs.getTimestamp("created_date").toLocalDateTime() : null; + final Long checkerId = rs.getObject("checker_id") != null ? rs.getLong("checker_id") : null; + final String checkerUsername = rs.getString("checker_username"); + final LocalDateTime checkerDate = rs.getTimestamp("checker_date") != null ? rs.getTimestamp("checker_date").toLocalDateTime() : null; + final String rejectionReason = rs.getString("rejection_reason"); + final Long resultingSavingsTransactionId = rs.getObject("resulting_savings_transaction_id") != null ? rs.getLong("resulting_savings_transaction_id") : null; + final Long resultingCurrentTransactionId = rs.getObject("resulting_current_transaction_id") != null ? rs.getLong("resulting_current_transaction_id") : null; + final Long resultingJournalEntryId = rs.getObject("resulting_journal_entry_id") != null ? rs.getLong("resulting_journal_entry_id") : null; + final Long cashReceiptJournalEntryId = rs.getObject("cash_receipt_journal_entry_id") != null ? rs.getLong("cash_receipt_journal_entry_id") : null; + final Long cashRefundJournalEntryId = rs.getObject("cash_refund_journal_entry_id") != null ? rs.getLong("cash_refund_journal_entry_id") : null; + final Long batchId = rs.getObject("batch_id") != null ? rs.getLong("batch_id") : null; + return new SponsorDDIssueRequestData(id, leafletId, ddNumber, clientId, clientName, beneficiaryName, payableAtBranch, + payableAtCity, faceValue, issuingBranchId, branchName, issuedDate, paymentMethod, savingsAccountId, + currentAccountId, chequeNumber, chequeBankName, walkinPurchaserName, walkinPurchaserAddress, + walkinPurchaserAadhaarMasked, chargesAmount, gstAmount, gstPercentageApplied, status, makerId, makerUsername, + createdDate, checkerId, checkerUsername, checkerDate, rejectionReason, resultingSavingsTransactionId, + resultingCurrentTransactionId, resultingJournalEntryId, cashReceiptJournalEntryId, cashRefundJournalEntryId, + batchId); + } + } + + private static final String REPRINT_REQUEST_SELECT = + "select rr.id, rr.leaflet_id, leaf.dd_number, rr.reason, rr.status, rr.consumed, rr.maker_id, " + + "maker.username as maker_username, rr.created_date, rr.checker_id, checker.username as checker_username, " + + "rr.checker_date, rr.rejection_reason " + + "from m_sponsor_dd_reprint_request rr " + + "join m_sponsor_dd_leaflet leaf on leaf.id = rr.leaflet_id " + + "join m_appuser maker on maker.id = rr.maker_id " + + "left join m_appuser checker on checker.id = rr.checker_id "; + + @Override + public Collection retrieveAllReprintRequests(final String status) { + if (status != null && !status.isBlank()) { + final String sql = REPRINT_REQUEST_SELECT + "where rr.status = ? order by rr.id desc"; + return this.jdbcTemplate.query(sql, new ReprintRequestMapper(), status); + } + final String sql = REPRINT_REQUEST_SELECT + "order by rr.id desc"; + return this.jdbcTemplate.query(sql, new ReprintRequestMapper()); + } + + private static final class ReprintRequestMapper implements RowMapper { + @Override + public SponsorDDReprintRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long id = rs.getLong("id"); + final Long leafletId = rs.getLong("leaflet_id"); + final String ddNumber = rs.getString("dd_number"); + final String reason = rs.getString("reason"); + final String status = rs.getString("status"); + final Boolean consumed = rs.getBoolean("consumed"); + final Long makerId = rs.getLong("maker_id"); + final String makerUsername = rs.getString("maker_username"); + final LocalDateTime createdDate = rs.getTimestamp("created_date") != null ? rs.getTimestamp("created_date").toLocalDateTime() : null; + final Long checkerId = rs.getObject("checker_id") != null ? rs.getLong("checker_id") : null; + final String checkerUsername = rs.getString("checker_username"); + final LocalDateTime checkerDate = rs.getTimestamp("checker_date") != null ? rs.getTimestamp("checker_date").toLocalDateTime() : null; + final String rejectionReason = rs.getString("rejection_reason"); + return new SponsorDDReprintRequestData(id, leafletId, ddNumber, reason, status, consumed, makerId, makerUsername, + createdDate, checkerId, checkerUsername, checkerDate, rejectionReason); + } + } + + @Override + public Collection retrieveLeaflets(final Long sponsorBankId, final String status, final String adviceStatus) { + final List params = new ArrayList<>(); + String sql = LEAFLET_SELECT + "join m_sponsor_dd_inventory inv on inv.id = leaf.inventory_id WHERE 1=1 "; + if (sponsorBankId != null) { + sql += " AND inv.sponsor_bank_id = ? "; + params.add(sponsorBankId); + } + if (status != null) { + sql += " AND leaf.status = ? "; + params.add(status); + } + if (adviceStatus != null) { + sql += " AND leaf.advice_status = ? "; + params.add(adviceStatus); + } + sql += " ORDER BY leaf.dd_number ASC"; + return this.jdbcTemplate.query(sql, this.leafletMapper, params.toArray()); + } + + @Override + public Collection retrieveAvailableCashierDepositRequests() { + final String sql = "select cdr.id, cdr.voucher_no, cdr.amount, cdr.description, cdr.cashier_username, cdr.actioned_at, " + + "cdr.dd_face_value, cdr.dd_charges, cdr.dd_gst_percentage, cdr.dd_gst_amount " + + "from m_cashier_deposit_request cdr " + + "where cdr.status = 'APPROVED' " + + "and cdr.gl_account_id = (select cash_suspense_gl_account_id from m_sponsor_dd_gl_mapping limit 1) " + + "and cdr.id not in (select cashier_deposit_request_id from m_sponsor_dd_issue_request " + + "where cashier_deposit_request_id is not null) " + + "order by cdr.actioned_at desc"; + return this.jdbcTemplate.query(sql, (rs, rowNum) -> new SponsorDDCashierDepositOptionData( + rs.getLong("id"), + rs.getString("voucher_no"), + rs.getBigDecimal("amount"), + rs.getString("description"), + rs.getString("cashier_username"), + rs.getTimestamp("actioned_at") != null ? rs.getTimestamp("actioned_at").toLocalDateTime() : null, + rs.getBigDecimal("dd_face_value"), + rs.getBigDecimal("dd_charges"), + rs.getBigDecimal("dd_gst_percentage"), + rs.getBigDecimal("dd_gst_amount"))); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReconciliationService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReconciliationService.java new file mode 100644 index 000000000..fe62527b1 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReconciliationService.java @@ -0,0 +1,29 @@ +/** + * 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.sponsordd.service; + +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDRecoBatch; + +public interface SponsorDDReconciliationService { + + SponsorDDRecoBatch uploadAndReconcile(String fileName, String fileContent); + + CommandProcessingResult resolveMismatch(Long recordId, String actionType, Long resolvedSavingsTxnId, Long resolvedCurrentTxnId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReconciliationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReconciliationServiceImpl.java new file mode 100644 index 000000000..214623549 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReconciliationServiceImpl.java @@ -0,0 +1,364 @@ +/** + * 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.sponsordd.service; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDAuditLog; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDAuditLogRepository; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDLeaflet; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDLeafletRepository; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDRecoBatch; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDRecoBatchRepository; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDRecoRecord; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDRecoRecordRepository; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDTransaction; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDTransactionRepository; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; + +@Service +public class SponsorDDReconciliationServiceImpl implements SponsorDDReconciliationService { + + private final PlatformSecurityContext context; + private final SponsorDDRecoBatchRepository batchRepository; + private final SponsorDDRecoRecordRepository recordRepository; + private final SponsorDDLeafletRepository leafletRepository; + private final SponsorDDTransactionRepository transactionRepository; + private final SponsorDDAuditLogRepository auditLogRepository; + private final SavingsAccountTransactionRepository savingsTransactionRepository; + private final CurrentAccountTransactionRepository currentTransactionRepository; + + @Autowired + public SponsorDDReconciliationServiceImpl( + final PlatformSecurityContext context, + final SponsorDDRecoBatchRepository batchRepository, + final SponsorDDRecoRecordRepository recordRepository, + final SponsorDDLeafletRepository leafletRepository, + final SponsorDDTransactionRepository transactionRepository, + final SponsorDDAuditLogRepository auditLogRepository, + final SavingsAccountTransactionRepository savingsTransactionRepository, + final CurrentAccountTransactionRepository currentTransactionRepository) { + this.context = context; + this.batchRepository = batchRepository; + this.recordRepository = recordRepository; + this.leafletRepository = leafletRepository; + this.transactionRepository = transactionRepository; + this.auditLogRepository = auditLogRepository; + this.savingsTransactionRepository = savingsTransactionRepository; + this.currentTransactionRepository = currentTransactionRepository; + } + + @Override + @Transactional + @SuppressWarnings("StringSplitter") + public SponsorDDRecoBatch uploadAndReconcile(final String fileName, final String fileContent) { + final AppUser currentUser = this.context.authenticatedUser(); + + // 1. Check file uniqueness using SHA-256 hash + final String fileHash = calculateSHA256(fileContent); + final Optional existingBatch = this.batchRepository.findByFileHash(fileHash); + if (existingBatch.isPresent()) { + throw new IllegalStateException("This clearing file has already been uploaded and processed under reference: " + + existingBatch.get().getBatchReference()); + } + + // 2. Parse CSV rows + final String[] rows = fileContent.split("\\r?\\n"); + final List parsedRecords = new ArrayList<>(); + BigDecimal totalAmount = BigDecimal.ZERO; + + final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + for (int i = 0; i < rows.length; i++) { + final String row = rows[i].trim(); + if (row.isEmpty() || (i == 0 && row.toLowerCase(Locale.ROOT).contains("dd_number"))) { + // Skip empty lines or CSV header + continue; + } + + final String[] columns = row.split(","); + if (columns.length < 4) { + // Malformed row + continue; + } + + final String ddNumber = columns[0].trim(); + final BigDecimal clearingAmount = new BigDecimal(columns[1].trim()); + final LocalDate clearingDate = LocalDate.parse(columns[2].trim(), dateFormatter); + final String clearingRef = columns[3].trim(); + + parsedRecords.add(new ParsedRecord(ddNumber, clearingAmount, clearingDate, clearingRef)); + totalAmount = totalAmount.add(clearingAmount); + } + + // 3. Create batch record + final String batchRef = "RECO-" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString().replace("-", "") + "-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase(); + SponsorDDRecoBatch batch = new SponsorDDRecoBatch( + batchRef, + fileName, + fileHash, + "PROCESSING", + parsedRecords.size(), + totalAmount, + 0, + 0, + currentUser, + LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()), + null, + null + ); + batch = this.batchRepository.saveAndFlush(batch); + + int successCount = 0; + int mismatchCount = 0; + + final List recoRecords = new ArrayList<>(); + + // 4. Run matching rules + for (final ParsedRecord pr : parsedRecords) { + final Optional optLeaflet = this.leafletRepository.findByDdNumber(pr.ddNumber); + + String matchStatus = "MATCHED"; + SponsorDDLeaflet leaflet = null; + String remarks = null; + + if (optLeaflet.isPresent()) { + leaflet = optLeaflet.get(); + + if ("CLEARED".equals(leaflet.getStatus())) { + matchStatus = "DUPLICATE_CLEARING"; + remarks = "Instrument was already cleared on " + leaflet.getClearedDate(); + mismatchCount++; + } else if (!"ISSUED".equals(leaflet.getStatus())) { + matchStatus = "UNADVISED_CLEARING"; + remarks = "Leaflet status is " + leaflet.getStatus() + " instead of ISSUED"; + mismatchCount++; + } else if (leaflet.getFaceValue().compareTo(pr.clearingAmount) != 0) { + matchStatus = "AMOUNT_MISMATCH"; + remarks = "Amount mismatch: expected " + leaflet.getFaceValue() + ", cleared " + pr.clearingAmount; + mismatchCount++; + } else { + // Rule 1: Exact Match (Success) + successCount++; + leaflet.setStatus("CLEARED"); + leaflet.setClearedDate(pr.clearingDate); + leaflet.setLastModifiedBy(currentUser); + leaflet.setLastModifiedDate(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); + this.leafletRepository.save(leaflet); + + // Create clearing transaction mapping + final SponsorDDTransaction ddTxn = new SponsorDDTransaction( + leaflet, + null, + null, + null, + "CLEARING", + pr.clearingAmount, + LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()) + ); + this.transactionRepository.save(ddTxn); + + // Audit Log + final SponsorDDAuditLog auditLog = new SponsorDDAuditLog( + leaflet, + "CLEARING_POSTED", + "ISSUED", + "CLEARED", + currentUser, + "127.0.0.1", + null, + "Sponsor clearing scroll auto-match. Ref: " + pr.clearingRef, + LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()) + ); + this.auditLogRepository.save(auditLog); + } + } else { + // Rule 3: Unadvised / Not found + matchStatus = "UNADVISED_CLEARING"; + remarks = "DD number not found in internal leaflet registry"; + mismatchCount++; + } + + final SponsorDDRecoRecord record = new SponsorDDRecoRecord( + batch, + pr.ddNumber, + pr.clearingAmount, + pr.clearingDate, + pr.clearingRef, + matchStatus, + leaflet, + remarks, + null, + null + ); + recoRecords.add(record); + } + + this.recordRepository.saveAllAndFlush(recoRecords); + + // 5. Update batch totals + batch.setSuccessCount(successCount); + batch.setMismatchCount(mismatchCount); + batch.setStatus(mismatchCount > 0 ? "PARTIALLY_RECONCILED" : "RECONCILED"); + batch.setLastModifiedBy(currentUser); + batch.setLastModifiedDate(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); + this.batchRepository.save(batch); + + return batch; + } + + @Override + @Transactional + public CommandProcessingResult resolveMismatch(final Long recordId, final String actionType, + final Long resolvedSavingsTxnId, final Long resolvedCurrentTxnId) { + + final AppUser currentUser = this.context.authenticatedUser(); + final SponsorDDRecoRecord record = this.recordRepository.findById(recordId) + .orElseThrow(() -> new IllegalArgumentException("Reconciliation record not found.")); + + if (!"AMOUNT_MISMATCH".equals(record.getMatchStatus()) && !"UNADVISED_CLEARING".equals(record.getMatchStatus())) { + throw new IllegalStateException("Only exception records can be resolved. Current status: " + record.getMatchStatus()); + } + + final SponsorDDRecoBatch batch = record.getBatch(); + + SavingsAccountTransaction savingsTxn = null; + if (resolvedSavingsTxnId != null) { + savingsTxn = this.savingsTransactionRepository.findById(resolvedSavingsTxnId) + .orElseThrow(() -> new IllegalArgumentException("Resolved savings transaction not found.")); + } + + CurrentAccountTransaction currentTxn = null; + if (resolvedCurrentTxnId != null) { + currentTxn = this.currentTransactionRepository.findById(resolvedCurrentTxnId) + .orElseThrow(() -> new IllegalArgumentException("Resolved current account transaction not found.")); + } + + if ("FORCE_MATCH".equals(actionType)) { + final SponsorDDLeaflet leaflet = record.getMatchedLeaflet(); + if (leaflet != null) { + final String preStatus = leaflet.getStatus(); + leaflet.setStatus("CLEARED"); + leaflet.setClearedDate(record.getClearingDate()); + leaflet.setLastModifiedBy(currentUser); + leaflet.setLastModifiedDate(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); + this.leafletRepository.saveAndFlush(leaflet); + + // Create transaction link + final SponsorDDTransaction ddTxn = new SponsorDDTransaction( + leaflet, + savingsTxn, + currentTxn, + null, + "CLEARING", + record.getClearingAmount(), + LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()) + ); + this.transactionRepository.save(ddTxn); + + // Audit Log + final SponsorDDAuditLog auditLog = new SponsorDDAuditLog( + leaflet, + "CLEARING_POSTED", + preStatus, + "CLEARED", + currentUser, + "127.0.0.1", + null, + "Manual reconciliation override (Force Match) approved by checker.", + LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()) + ); + this.auditLogRepository.save(auditLog); + } + + record.setMatchStatus("RESOLVED"); + record.setRemarks("Manually resolved by force-match override."); + record.setResolvedBy(currentUser); + record.setResolvedDate(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); + this.recordRepository.save(record); + + // Update batch counts + batch.setSuccessCount(batch.getSuccessCount() + 1); + batch.setMismatchCount(batch.getMismatchCount() - 1); + if (batch.getMismatchCount() == 0) { + batch.setStatus("RECONCILED"); + } + batch.setLastModifiedBy(currentUser); + batch.setLastModifiedDate(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); + this.batchRepository.save(batch); + } + + return new CommandProcessingResultBuilder() + .withEntityId(record.getId()) + .build(); + } + + private String calculateSHA256(final String text) { + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + final byte[] hash = digest.digest(text.getBytes(StandardCharsets.UTF_8)); + final StringBuilder hexString = new StringBuilder(); + for (final byte b : hash) { + final String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (final NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 algorithm not available.", e); + } + } + + private static final class ParsedRecord { + private final String ddNumber; + private final BigDecimal clearingAmount; + private final LocalDate clearingDate; + private final String clearingRef; + + public ParsedRecord(final String ddNumber, final BigDecimal clearingAmount, final LocalDate clearingDate, final String clearingRef) { + this.ddNumber = ddNumber; + this.clearingAmount = clearingAmount; + this.clearingDate = clearingDate; + this.clearingRef = clearingRef; + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReprintRequestWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReprintRequestWritePlatformService.java new file mode 100644 index 000000000..618645f92 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReprintRequestWritePlatformService.java @@ -0,0 +1,40 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sponsordd.service; + +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +public interface SponsorDDReprintRequestWritePlatformService { + + /** + * Records a print of the given leaflet. The first print of a leaflet is always allowed. Any + * print after that requires a previously APPROVED, not-yet-consumed reprint request - if one + * exists it is consumed by this call; otherwise this throws, and the caller should submit a + * reprint request instead via {@link #requestReprint}. + */ + CommandProcessingResult recordPrint(Long leafletId); + + /** Clerk (maker) step: requests permission to print an already-printed leaflet again. */ + CommandProcessingResult requestReprint(Long leafletId, String reason); + + /** Manager (checker) step: approves a pending reprint request, allowing one further print. */ + CommandProcessingResult approveReprintRequest(Long reprintRequestId); + + CommandProcessingResult rejectReprintRequest(Long reprintRequestId, String rejectionReason); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReprintRequestWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReprintRequestWritePlatformServiceImpl.java new file mode 100644 index 000000000..d9fb69083 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReprintRequestWritePlatformServiceImpl.java @@ -0,0 +1,196 @@ +/** + * 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.sponsordd.service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.infrastructure.core.service.DateUtils; + +@Service +@RequiredArgsConstructor +public class SponsorDDReprintRequestWritePlatformServiceImpl implements SponsorDDReprintRequestWritePlatformService { + + private static final String STATUS_PENDING = "PENDING"; + private static final String STATUS_APPROVED = "APPROVED"; + private static final String STATUS_REJECTED = "REJECTED"; + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + + @Override + @Transactional + public CommandProcessingResult recordPrint(final Long leafletId) { + final Map leaflet = findLeafletOrThrow(leafletId); + if (!"ISSUED".equals(leaflet.get("status"))) { + throw new IllegalStateException("Only an issued Demand Draft can be printed. Current status: " + leaflet.get("status")); + } + final int printCount = ((Number) leaflet.get("print_count")).intValue(); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + + if (printCount == 0) { + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_leaflet SET print_count = print_count + 1, last_printed_date = ? WHERE id = ?", now, leafletId); + return new CommandProcessingResultBuilder().withEntityId(leafletId).build(); + } + + final List> approved = this.jdbcTemplate.queryForList( + "SELECT id FROM m_sponsor_dd_reprint_request WHERE leaflet_id = ? AND status = 'APPROVED' AND consumed = false " + + "ORDER BY id DESC LIMIT 1", leafletId); + if (approved.isEmpty()) { + throw new IllegalStateException( + "This Demand Draft has already been printed. Submit a reprint request for manager approval before printing again."); + } + final long reprintRequestId = ((Number) approved.get(0).get("id")).longValue(); + this.jdbcTemplate.update("UPDATE m_sponsor_dd_reprint_request SET consumed = true WHERE id = ?", reprintRequestId); + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_leaflet SET print_count = print_count + 1, last_printed_date = ? WHERE id = ?", now, leafletId); + return new CommandProcessingResultBuilder().withEntityId(leafletId).withSubEntityId(reprintRequestId).build(); + } + + @Override + @Transactional + public CommandProcessingResult requestReprint(final Long leafletId, final String reason) { + final AppUser currentUser = this.context.authenticatedUser(); + final Map leaflet = findLeafletOrThrow(leafletId); + if (!"ISSUED".equals(leaflet.get("status"))) { + throw new IllegalStateException("Only an issued Demand Draft can be reprinted. Current status: " + leaflet.get("status")); + } + final int printCount = ((Number) leaflet.get("print_count")).intValue(); + if (printCount == 0) { + throw new IllegalStateException("This Demand Draft has not been printed yet - use Print, not Request Reprint."); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("A reason is required to request a reprint."); + } + if (!this.jdbcTemplate.queryForList( + "SELECT id FROM m_sponsor_dd_reprint_request WHERE leaflet_id = ? AND status = 'PENDING'", leafletId).isEmpty()) { + throw new IllegalStateException( + "A reprint request is already pending for this Demand Draft. Wait for it to be approved or rejected."); + } + if (!this.jdbcTemplate.queryForList( + "SELECT id FROM m_sponsor_dd_reprint_request WHERE leaflet_id = ? AND status = 'APPROVED' AND consumed = false", + leafletId).isEmpty()) { + throw new IllegalStateException("A reprint has already been approved for this Demand Draft - use Print to consume it."); + } + + final KeyHolder kh = new GeneratedKeyHolder(); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + this.jdbcTemplate.update(con -> { + final java.sql.PreparedStatement ps = con.prepareStatement( + "INSERT INTO m_sponsor_dd_reprint_request (leaflet_id, reason, status, maker_id, created_date) " + + "VALUES (?, ?, ?, ?, ?)", + new String[] { "id" }); + ps.setLong(1, leafletId); + ps.setString(2, reason); + ps.setString(3, STATUS_PENDING); + ps.setLong(4, currentUser.getId()); + ps.setObject(5, now); + return ps; + }, kh); + final long newId = Objects.requireNonNull(kh.getKey()).longValue(); + return new CommandProcessingResultBuilder().withEntityId(newId).build(); + } + + @Override + @Transactional + public CommandProcessingResult approveReprintRequest(final Long reprintRequestId) { + final AppUser currentUser = this.context.authenticatedUser(); + validateCheckerRole(currentUser); + + final Map request = findReprintRequestOrThrow(reprintRequestId); + if (!STATUS_PENDING.equals(request.get("status"))) { + throw new IllegalStateException("Reprint request is not pending. Current status: " + request.get("status")); + } + final Long makerId = ((Number) request.get("maker_id")).longValue(); + if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) { + throw new IllegalStateException("You cannot approve your own reprint request."); + } + + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_reprint_request SET status = ?, checker_id = ?, checker_date = ? WHERE id = ?", + STATUS_APPROVED, currentUser.getId(), now, reprintRequestId); + return new CommandProcessingResultBuilder().withEntityId(reprintRequestId).build(); + } + + @Override + @Transactional + public CommandProcessingResult rejectReprintRequest(final Long reprintRequestId, final String rejectionReason) { + final AppUser currentUser = this.context.authenticatedUser(); + validateCheckerRole(currentUser); + + final Map request = findReprintRequestOrThrow(reprintRequestId); + if (!STATUS_PENDING.equals(request.get("status"))) { + throw new IllegalStateException("Reprint request is not pending. Current status: " + request.get("status")); + } + final Long makerId = ((Number) request.get("maker_id")).longValue(); + if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) { + throw new IllegalStateException("You cannot reject your own reprint request."); + } + + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_reprint_request SET status = ?, checker_id = ?, checker_date = ?, rejection_reason = ? WHERE id = ?", + STATUS_REJECTED, currentUser.getId(), now, rejectionReason, reprintRequestId); + return new CommandProcessingResultBuilder().withEntityId(reprintRequestId).build(); + } + + private Map findLeafletOrThrow(final Long leafletId) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT id, status, print_count FROM m_sponsor_dd_leaflet WHERE id = ?", leafletId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("Demand Draft leaflet not found."); + } + return rows.get(0); + } + + private Map findReprintRequestOrThrow(final Long reprintRequestId) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT * FROM m_sponsor_dd_reprint_request WHERE id = ?", reprintRequestId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("Sponsor DD reprint request not found."); + } + return rows.get(0); + } + + private boolean isCheckerRole(final AppUser user) { + if (user.hasAnyPermission("ALL_FUNCTIONS")) return true; + return user.getRoles().stream() + .map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim()) + .anyMatch(n -> n.contains("manager") || n.contains("head cashier")); + } + + private void validateCheckerRole(final AppUser user) { + if (!isCheckerRole(user)) { + throw new IllegalStateException("Only a Bank Manager (or Head Cashier) can approve or reject reprint requests."); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDWritePlatformService.java new file mode 100644 index 000000000..e294e16b4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDWritePlatformService.java @@ -0,0 +1,43 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sponsordd.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +public interface SponsorDDWritePlatformService { + + CommandProcessingResult registerInventory(Long sponsorBankId, Long branchId, Long seriesStart, Long seriesEnd, LocalDate receivedDate); + + /** + * clientId may be null for a walk-in purchaser who isn't a bank client (cash payment) — in that + * case walkinPurchaserName/Address/AadhaarMasked identify the purchaser instead. + */ + CommandProcessingResult issueDemandDraft(Long leafletId, Long clientId, String walkinPurchaserName, + String walkinPurchaserAddress, String walkinPurchaserAadhaarMasked, String beneficiaryName, BigDecimal faceValue, + Long issuingBranchId, LocalDate issuedDate, Long savingsTransactionId, Long currentTransactionId, Long journalEntryId, + String payableAtBranch, String payableAtCity); + + CommandProcessingResult cancelDemandDraft(Long leafletId, String remarks, Long refundSavingsTransactionId, Long refundCurrentTransactionId); + + CommandProcessingResult stopPaymentDemandDraft(Long leafletId, String remarks); + + CommandProcessingResult sendAdvice(Long sponsorBankId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDWritePlatformServiceImpl.java new file mode 100644 index 000000000..624162726 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDWritePlatformServiceImpl.java @@ -0,0 +1,404 @@ +/** + * 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.sponsordd.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import java.util.ArrayList; +import java.util.List; +import org.apache.fineract.accounting.journalentry.domain.JournalEntry; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper; +import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorBank; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorBankRepository; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDAuditLog; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDAuditLogRepository; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDInventory; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDInventoryRepository; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDLeaflet; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDLeafletRepository; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDTransaction; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDTransactionRepository; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class SponsorDDWritePlatformServiceImpl implements SponsorDDWritePlatformService { + + private final PlatformSecurityContext context; + private final SponsorDDInventoryRepository inventoryRepository; + private final SponsorDDLeafletRepository leafletRepository; + private final SponsorDDTransactionRepository transactionRepository; + private final SponsorDDAuditLogRepository auditLogRepository; + private final SponsorBankRepository sponsorBankRepository; + private final OfficeRepositoryWrapper officeRepositoryWrapper; + private final ClientRepositoryWrapper clientRepositoryWrapper; + private final SavingsAccountTransactionRepository savingsTransactionRepository; + private final CurrentAccountTransactionRepository currentTransactionRepository; + private final JournalEntryRepository journalEntryRepository; + private final JdbcTemplate jdbcTemplate; + + @Autowired + public SponsorDDWritePlatformServiceImpl( + final PlatformSecurityContext context, + final SponsorDDInventoryRepository inventoryRepository, + final SponsorDDLeafletRepository leafletRepository, + final SponsorDDTransactionRepository transactionRepository, + final SponsorDDAuditLogRepository auditLogRepository, + final SponsorBankRepository sponsorBankRepository, + final OfficeRepositoryWrapper officeRepositoryWrapper, + final ClientRepositoryWrapper clientRepositoryWrapper, + final SavingsAccountTransactionRepository savingsTransactionRepository, + final CurrentAccountTransactionRepository currentTransactionRepository, + final JournalEntryRepository journalEntryRepository, + final JdbcTemplate jdbcTemplate) { + this.context = context; + this.inventoryRepository = inventoryRepository; + this.leafletRepository = leafletRepository; + this.transactionRepository = transactionRepository; + this.auditLogRepository = auditLogRepository; + this.sponsorBankRepository = sponsorBankRepository; + this.officeRepositoryWrapper = officeRepositoryWrapper; + this.clientRepositoryWrapper = clientRepositoryWrapper; + this.savingsTransactionRepository = savingsTransactionRepository; + this.currentTransactionRepository = currentTransactionRepository; + this.journalEntryRepository = journalEntryRepository; + this.jdbcTemplate = jdbcTemplate; + } + + @Override + @Transactional + public CommandProcessingResult registerInventory(final Long sponsorBankId, final Long branchId, final Long seriesStart, + final Long seriesEnd, final LocalDate receivedDate) { + + final AppUser currentUser = this.context.authenticatedUser(); + + if (seriesStart > seriesEnd) { + throw new IllegalArgumentException("Series start number must be less than or equal to series end number."); + } + + final SponsorBank sponsorBank = this.sponsorBankRepository.findById(sponsorBankId) + .orElseThrow(() -> new IllegalArgumentException("Sponsor Bank not found with ID: " + sponsorBankId)); + final Office branch = this.officeRepositoryWrapper.findOneWithNotFoundDetection(branchId); + final int totalLeaves = (int) (seriesEnd - seriesStart) + 1; + + final SponsorDDInventory inventory = new SponsorDDInventory( + sponsorBank, + branch, + seriesStart, + seriesEnd, + totalLeaves, + receivedDate, + "ACTIVE", + currentUser, + DateUtils.getLocalDateTimeOfTenant(), + null, + null + ); + + this.inventoryRepository.saveAndFlush(inventory); + + // Pre-create leaflets in bulk + final List leaflets = new ArrayList<>(); + for (long currentVal = seriesStart; currentVal <= seriesEnd; currentVal++) { + final String ddNumber = String.valueOf(currentVal); + final SponsorDDLeaflet leaflet = new SponsorDDLeaflet( + inventory, + ddNumber, + "AVAILABLE", + BigDecimal.ZERO, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "NOT_SENT", + null, + 1, + currentUser, + DateUtils.getLocalDateTimeOfTenant(), + null, + null + ); + leaflets.add(leaflet); + } + this.leafletRepository.saveAllAndFlush(leaflets); + + // Audit the creation of each leaflet + final List auditLogs = new ArrayList<>(); + for (final SponsorDDLeaflet leaflet : leaflets) { + final SponsorDDAuditLog auditLog = new SponsorDDAuditLog( + leaflet, + "STOCK_RECEIPT", + null, + "AVAILABLE", + currentUser, + "127.0.0.1", + null, + "Draft leaflet registered into inventory.", + DateUtils.getLocalDateTimeOfTenant() + ); + auditLogs.add(auditLog); + } + this.auditLogRepository.saveAll(auditLogs); + + return new CommandProcessingResultBuilder() + .withEntityId(inventory.getId()) + .build(); + } + + @Override + @Transactional + public CommandProcessingResult issueDemandDraft(final Long leafletId, final Long clientId, final String walkinPurchaserName, + final String walkinPurchaserAddress, final String walkinPurchaserAadhaarMasked, final String beneficiaryName, + final BigDecimal faceValue, final Long issuingBranchId, final LocalDate issuedDate, + final Long savingsTransactionId, final Long currentTransactionId, final Long journalEntryId, + final String payableAtBranch, final String payableAtCity) { + + final AppUser currentUser = this.context.authenticatedUser(); + final SponsorDDLeaflet leaflet = this.leafletRepository.findById(leafletId) + .orElseThrow(() -> new IllegalArgumentException("Demand Draft leaflet not found.")); + + if (!"AVAILABLE".equals(leaflet.getStatus())) { + throw new IllegalStateException("Demand Draft leaflet is not available. Current status: " + leaflet.getStatus()); + } + + final Client client = clientId != null ? this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId) : null; + final Office branch = this.officeRepositoryWrapper.findOneWithNotFoundDetection(issuingBranchId); + + // Find linked financial items + SavingsAccountTransaction savingsTxn = null; + if (savingsTransactionId != null) { + savingsTxn = this.savingsTransactionRepository.findById(savingsTransactionId) + .orElseThrow(() -> new IllegalArgumentException("Savings transaction not found.")); + } + + CurrentAccountTransaction currentTxn = null; + if (currentTransactionId != null) { + currentTxn = this.currentTransactionRepository.findById(currentTransactionId) + .orElseThrow(() -> new IllegalArgumentException("Current account transaction not found.")); + } + + JournalEntry journalEntry = null; + if (journalEntryId != null) { + journalEntry = this.journalEntryRepository.findById(journalEntryId) + .orElseThrow(() -> new IllegalArgumentException("Journal Entry not found.")); + } + + final String preStatus = leaflet.getStatus(); + leaflet.setStatus("ISSUED"); + leaflet.setFaceValue(faceValue); + leaflet.setIssuedToClient(client); + leaflet.setWalkinPurchaserName(walkinPurchaserName); + leaflet.setWalkinPurchaserAddress(walkinPurchaserAddress); + leaflet.setWalkinPurchaserAadhaarMasked(walkinPurchaserAadhaarMasked); + leaflet.setBeneficiaryName(beneficiaryName); + leaflet.setPayableAtBranch(payableAtBranch); + leaflet.setPayableAtCity(payableAtCity); + leaflet.setIssuingBranch(branch); + leaflet.setIssuedDate(issuedDate); + leaflet.setAdviceStatus("NOT_SENT"); + leaflet.setLastModifiedBy(currentUser); + leaflet.setLastModifiedDate(DateUtils.getLocalDateTimeOfTenant()); + + this.leafletRepository.saveAndFlush(leaflet); + + // Link transaction + final SponsorDDTransaction ddTransaction = new SponsorDDTransaction( + leaflet, + savingsTxn, + currentTxn, + journalEntry, + "ISSUANCE", + faceValue, + DateUtils.getLocalDateTimeOfTenant() + ); + this.transactionRepository.save(ddTransaction); + + // Audit Log + final SponsorDDAuditLog auditLog = new SponsorDDAuditLog( + leaflet, + "ISSUED", + preStatus, + "ISSUED", + currentUser, + "127.0.0.1", + null, + "Demand draft issued to customer.", + DateUtils.getLocalDateTimeOfTenant() + ); + this.auditLogRepository.save(auditLog); + + return new CommandProcessingResultBuilder() + .withEntityId(leaflet.getId()) + .build(); + } + + @Override + @Transactional + public CommandProcessingResult cancelDemandDraft(final Long leafletId, final String remarks, + final Long refundSavingsTransactionId, final Long refundCurrentTransactionId) { + + final AppUser currentUser = this.context.authenticatedUser(); + final SponsorDDLeaflet leaflet = this.leafletRepository.findById(leafletId) + .orElseThrow(() -> new IllegalArgumentException("Demand Draft leaflet not found.")); + + if (!"ISSUED".equals(leaflet.getStatus())) { + throw new IllegalStateException("Only ISSUED Demand Drafts can be cancelled. Current status: " + leaflet.getStatus()); + } + + SavingsAccountTransaction savingsTxn = null; + if (refundSavingsTransactionId != null) { + savingsTxn = this.savingsTransactionRepository.findById(refundSavingsTransactionId) + .orElseThrow(() -> new IllegalArgumentException("Refund savings transaction not found.")); + } + + CurrentAccountTransaction currentTxn = null; + if (refundCurrentTransactionId != null) { + currentTxn = this.currentTransactionRepository.findById(refundCurrentTransactionId) + .orElseThrow(() -> new IllegalArgumentException("Refund current account transaction not found.")); + } + + final String preStatus = leaflet.getStatus(); + leaflet.setStatus("CANCELLED"); + leaflet.setLastModifiedBy(currentUser); + leaflet.setLastModifiedDate(DateUtils.getLocalDateTimeOfTenant()); + + this.leafletRepository.saveAndFlush(leaflet); + + // Link refund transaction + final SponsorDDTransaction ddTransaction = new SponsorDDTransaction( + leaflet, + savingsTxn, + currentTxn, + null, + "CANCELLATION", + leaflet.getFaceValue(), + DateUtils.getLocalDateTimeOfTenant() + ); + this.transactionRepository.save(ddTransaction); + + // Audit Log + final SponsorDDAuditLog auditLog = new SponsorDDAuditLog( + leaflet, + "CANCELLED", + preStatus, + "CANCELLED", + currentUser, + "127.0.0.1", + null, + remarks != null ? remarks : "Demand draft cancelled and refunded.", + DateUtils.getLocalDateTimeOfTenant() + ); + this.auditLogRepository.save(auditLog); + + return new CommandProcessingResultBuilder() + .withEntityId(leaflet.getId()) + .build(); + } + + @Override + @Transactional + public CommandProcessingResult stopPaymentDemandDraft(final Long leafletId, final String remarks) { + + final AppUser currentUser = this.context.authenticatedUser(); + final SponsorDDLeaflet leaflet = this.leafletRepository.findById(leafletId) + .orElseThrow(() -> new IllegalArgumentException("Demand Draft leaflet not found.")); + + if (!"ISSUED".equals(leaflet.getStatus())) { + throw new IllegalStateException("Only ISSUED Demand Drafts can be stopped. Current status: " + leaflet.getStatus()); + } + + final String preStatus = leaflet.getStatus(); + leaflet.setStatus("STOPPED"); + leaflet.setLastModifiedBy(currentUser); + leaflet.setLastModifiedDate(DateUtils.getLocalDateTimeOfTenant()); + + this.leafletRepository.saveAndFlush(leaflet); + + // Link stop transaction + final SponsorDDTransaction ddTransaction = new SponsorDDTransaction( + leaflet, + null, + null, + null, + "STOP_PAYMENT", + leaflet.getFaceValue(), + DateUtils.getLocalDateTimeOfTenant() + ); + this.transactionRepository.save(ddTransaction); + + // Audit Log + final SponsorDDAuditLog auditLog = new SponsorDDAuditLog( + leaflet, + "STOP_PAYMENT", + preStatus, + "STOPPED", + currentUser, + "127.0.0.1", + null, + remarks != null ? remarks : "Stop payment instruction processed.", + DateUtils.getLocalDateTimeOfTenant() + ); + this.auditLogRepository.save(auditLog); + + return new CommandProcessingResultBuilder() + .withEntityId(leaflet.getId()) + .build(); + } + + @Override + @Transactional + public CommandProcessingResult sendAdvice(final Long sponsorBankId) { + final AppUser currentUser = this.context.authenticatedUser(); + final LocalDateTime now = DateUtils.getLocalDateTimeOfTenant(); + + // Update m_sponsor_dd_leaflet for the sponsor bank where status is ISSUED and advice_status is NOT_SENT + final String sql = "UPDATE m_sponsor_dd_leaflet leaf " + + "SET advice_status = 'SENT', advice_sent_date = ?, lastmodifiedby_id = ?, lastmodified_date = ? " + + "WHERE leaf.status = 'ISSUED' AND leaf.advice_status = 'NOT_SENT' " + + "AND leaf.inventory_id IN (SELECT id FROM m_sponsor_dd_inventory WHERE sponsor_bank_id = ?)"; + + this.jdbcTemplate.update(sql, now, currentUser.getId(), now, sponsorBankId); + + return new CommandProcessingResultBuilder() + .withEntityId(sponsorBankId) + .build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/api/UserAgreementApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/api/UserAgreementApiResource.java new file mode 100644 index 000000000..4ffae883e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/api/UserAgreementApiResource.java @@ -0,0 +1,164 @@ +/** + * 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.useradministration.agreement.api; + +import com.google.common.base.Splitter; +import com.google.common.collect.Iterables; +import com.google.gson.JsonObject; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +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.Context; +import jakarta.ws.rs.core.MediaType; +import java.util.List; +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.DefaultToApiJsonSerializer; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.agreement.data.UserAgreementData; +import org.apache.fineract.useradministration.agreement.service.UserAgreementReadPlatformService; +import org.apache.fineract.useradministration.agreement.service.UserAgreementWritePlatformService; +import org.springframework.stereotype.Component; + +/** + * Admin API for publishing/reading the versioned "user agreement" text (bank policy/warning) shown to every user + * right after they finish logging in (OTP included, if their account has it enrolled). {@code retrieveAll()}, + * {@code retrieveActive()} and {@code create()} are admin-only (gated by USERAGREEMENT permissions) for managing the + * text itself. {@code status()} and {@code accept()} are for the ordinary end-user post-login flow - any + * authenticated user may call them for their own acceptance record, no special permission required. + */ +@Path("/v1/useragreements") +@Component +@Tag(name = "User Agreements", description = "Publish/read versioned user-agreement text (admin) and check/record the current user's own acceptance of it (post-login).") +@RequiredArgsConstructor +public class UserAgreementApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSIONS = "USERAGREEMENT"; + + private final PlatformSecurityContext context; + private final UserAgreementReadPlatformService readPlatformService; + private final UserAgreementWritePlatformService writePlatformService; + private final DefaultToApiJsonSerializer toApiJsonSerializer; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "List all User Agreement versions", description = "Newest version first.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserAgreementData.class)))) }) + public String retrieveAll() { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + final List all = this.readPlatformService.retrieveAll(); + return this.toApiJsonSerializer.serialize(all); + } + + @GET + @Path("active") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve the currently active User Agreement", description = "The version every user must accept after logging in.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = UserAgreementData.class))) }) + public String retrieveActive() { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + final UserAgreementData active = this.readPlatformService.retrieveActive(); + return this.toApiJsonSerializer.serialize(active); + } + + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Publish a new User Agreement version", description = "Transactionally deactivates the previously-active version " + + "and activates the new one; version number is server-assigned (currentMax + 1). Mandatory fields: title, body.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = CommandProcessingResult.class))) }) + public String create(@Parameter(hidden = true) final String apiRequestBodyAsJson) { + final CommandWrapper commandRequest = new CommandWrapperBuilder() // + .createUserAgreement() // + .withJson(apiRequestBodyAsJson) // + .build(); + + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + + return this.toApiJsonSerializer.serialize(result); + } + + @GET + @Path("active/status") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Check whether the current user has accepted the active agreement", description = "Called right after " + + "login (OTP included, if the account has it enrolled) to decide whether to show the agreement dialog. No special " + + "permission required beyond being logged in - reflects only the caller's own acceptance record. Includes the agreement " + + "text when not yet accepted, so the client doesn't need a second round-trip.") + public String status() { + final boolean accepted = this.writePlatformService.hasCurrentUserAcceptedActive(); + final JsonObject json = new JsonObject(); + json.addProperty("accepted", accepted); + if (!accepted) { + final UserAgreementData active = this.readPlatformService.retrieveActive(); + json.addProperty("id", active.getId()); + json.addProperty("version", active.getVersion()); + json.addProperty("title", active.getTitle()); + json.addProperty("body", active.getBody()); + } + return json.toString(); + } + + @POST + @Path("active/accept") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Accept the currently active agreement", description = "Records the caller's own acceptance. Idempotent - " + + "calling it again after already accepting is a no-op.") + public String accept(@Context final HttpServletRequest httpRequest) { + final String ipAddress = clientIp(httpRequest); + final String userAgent = httpRequest != null ? httpRequest.getHeader("User-Agent") : null; + this.writePlatformService.acceptActive(ipAddress, userAgent); + final JsonObject json = new JsonObject(); + json.addProperty("accepted", true); + return json.toString(); + } + + private String clientIp(final HttpServletRequest req) { + if (req == null) { + return null; + } + final String fwd = req.getHeader("X-Forwarded-For"); + if (fwd != null && !fwd.isBlank()) { + return Iterables.get(Splitter.on(',').split(fwd), 0).trim(); + } + return req.getRemoteAddr(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/data/UserAgreementData.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/data/UserAgreementData.java new file mode 100644 index 000000000..b48e92054 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/data/UserAgreementData.java @@ -0,0 +1,41 @@ +/** + * 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.useradministration.agreement.data; + +import java.io.Serializable; +import java.time.OffsetDateTime; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@ToString +@AllArgsConstructor +@Getter +@Setter +public class UserAgreementData implements Serializable { + + private Long id; + private Integer version; + private String title; + private String body; + private boolean active; + private String createdByUsername; + private OffsetDateTime createdOn; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/exception/UserAgreementNotFoundException.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/exception/UserAgreementNotFoundException.java new file mode 100644 index 000000000..561d67f6f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/exception/UserAgreementNotFoundException.java @@ -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.useradministration.agreement.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException; + +/** + * A {@link RuntimeException} thrown when there is no currently-active {@code UserAgreement} - i.e. nothing has + * ever been published via {@code POST /v1/useragreements}. + */ +public class UserAgreementNotFoundException extends AbstractPlatformResourceNotFoundException { + + public UserAgreementNotFoundException() { + super("error.msg.useragreement.active.not.found", "There is currently no active user agreement."); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementReadPlatformService.java new file mode 100644 index 000000000..3111b59e9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementReadPlatformService.java @@ -0,0 +1,29 @@ +/** + * 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.useradministration.agreement.service; + +import java.util.List; +import org.apache.fineract.useradministration.agreement.data.UserAgreementData; + +public interface UserAgreementReadPlatformService { + + UserAgreementData retrieveActive(); + + List retrieveAll(); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementReadPlatformServiceImpl.java new file mode 100644 index 000000000..1444d1980 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementReadPlatformServiceImpl.java @@ -0,0 +1,49 @@ +/** + * 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.useradministration.agreement.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.useradministration.agreement.data.UserAgreementData; +import org.apache.fineract.useradministration.agreement.domain.UserAgreement; +import org.apache.fineract.useradministration.agreement.domain.UserAgreementRepository; +import org.apache.fineract.useradministration.agreement.exception.UserAgreementNotFoundException; + +@RequiredArgsConstructor +public class UserAgreementReadPlatformServiceImpl implements UserAgreementReadPlatformService { + + private final UserAgreementRepository userAgreementRepository; + + @Override + public UserAgreementData retrieveActive() { + final UserAgreement agreement = this.userAgreementRepository.findByActiveTrue() + .orElseThrow(UserAgreementNotFoundException::new); + return toData(agreement); + } + + @Override + public List retrieveAll() { + return this.userAgreementRepository.findAllByOrderByVersionDesc().stream().map(this::toData).toList(); + } + + private UserAgreementData toData(final UserAgreement agreement) { + return new UserAgreementData(agreement.getId(), agreement.getVersion(), agreement.getTitle(), agreement.getBody(), + agreement.isActive(), agreement.getCreatedBy().getUsername(), agreement.getCreatedOn()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementWritePlatformService.java new file mode 100644 index 000000000..a63ad012e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementWritePlatformService.java @@ -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.useradministration.agreement.service; + +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +public interface UserAgreementWritePlatformService { + + CommandProcessingResult create(JsonCommand command); + + /** Whether the currently authenticated user has already accepted the currently active agreement. */ + boolean hasCurrentUserAcceptedActive(); + + /** Records the currently authenticated user's acceptance of the currently active agreement. */ + void acceptActive(String ipAddress, String userAgent); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementWritePlatformServiceImpl.java new file mode 100644 index 000000000..3de25c4f1 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementWritePlatformServiceImpl.java @@ -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.useradministration.agreement.service; + +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +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.useradministration.agreement.domain.UserAgreement; +import org.apache.fineract.useradministration.agreement.domain.UserAgreementAcceptance; +import org.apache.fineract.useradministration.agreement.domain.UserAgreementAcceptanceRepository; +import org.apache.fineract.useradministration.agreement.domain.UserAgreementRepository; +import org.apache.fineract.useradministration.agreement.exception.UserAgreementNotFoundException; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +public class UserAgreementWritePlatformServiceImpl implements UserAgreementWritePlatformService { + + public static final String TITLE = "title"; + public static final String BODY = "body"; + + private final PlatformSecurityContext context; + private final UserAgreementRepository userAgreementRepository; + private final UserAgreementAcceptanceRepository userAgreementAcceptanceRepository; + + @Override + @Transactional + public CommandProcessingResult create(final JsonCommand command) { + final AppUser currentUser = this.context.authenticatedUser(); + + final String title = command.stringValueOfParameterNamed(TITLE); + final String body = command.stringValueOfParameterNamed(BODY); + validate(title, body); + + // Deactivate the currently-active agreement (if any) before activating the new one - this must happen + // in the same transaction as the insert below so a reader can never observe zero or two active rows. + this.userAgreementRepository.findByActiveTrue().ifPresent(UserAgreement::deactivate); + + final Integer nextVersion = this.userAgreementRepository.findAllByOrderByVersionDesc().stream().findFirst() + .map(UserAgreement::getVersion).map(v -> v + 1).orElse(1); + + final UserAgreement agreement = UserAgreement.create(nextVersion, title, body, currentUser); + this.userAgreementRepository.saveAndFlush(agreement); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(agreement.getId()) // + .build(); + } + + @Override + public boolean hasCurrentUserAcceptedActive() { + final AppUser currentUser = this.context.authenticatedUser(); + final UserAgreement active = this.userAgreementRepository.findByActiveTrue().orElse(null); + if (active == null) { + // No agreement has ever been published - nothing to accept. + return true; + } + return this.userAgreementAcceptanceRepository.existsByAppUser_IdAndAgreement_Id(currentUser.getId(), active.getId()); + } + + @Override + @Transactional + public void acceptActive(final String ipAddress, final String userAgent) { + final AppUser currentUser = this.context.authenticatedUser(); + final UserAgreement active = this.userAgreementRepository.findByActiveTrue().orElseThrow(UserAgreementNotFoundException::new); + if (this.userAgreementAcceptanceRepository.existsByAppUser_IdAndAgreement_Id(currentUser.getId(), active.getId())) { + // Already accepted (e.g. duplicate click) - idempotent no-op. + return; + } + final UserAgreementAcceptance acceptance = UserAgreementAcceptance.create(currentUser, active, ipAddress, userAgent); + this.userAgreementAcceptanceRepository.saveAndFlush(acceptance); + } + + private void validate(final String title, final String body) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("useragreement"); + + baseDataValidator.reset().parameter(TITLE).value(title).notBlank().notExceedingLengthOf(200); + baseDataValidator.reset().parameter(BODY).value(body).notBlank(); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java index 03f7428d4..de2e9e338 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java @@ -124,6 +124,10 @@ final class UsersApiResourceSwagger { public String email; @Schema(example = "9999999999") public String mobileNo; + @Schema(description = "Mandatory. Channel used to enroll this user's OTP-based 2FA at creation time - a verified " + + "m_user_mfa_factor row is created immediately, so the existing 2FA system challenges them for OTP on their very " + + "first login. One of EMAIL, SMS. SMS requires mobileNo to be set.", example = "EMAIL") + public String otpDeliveryMethod; @Schema(example = "1") public Long officeId; @Schema(example = "1") @@ -205,6 +209,10 @@ final class UsersApiResourceSwagger { public String email; @Schema(example = "9999999999") public String mobileNo; + @Schema(description = "Optional. One of EMAIL, SMS. Currently accepted for validation parity with user creation but not " + + "otherwise used on update - changing an existing user's enrolled 2FA channel is a separate Access Control action.", + example = "EMAIL") + public String otpDeliveryMethod; @Schema(example = "1") public Long officeId; @Schema(example = "1") diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/dsc/data/BankUserDscData.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/dsc/data/BankUserDscData.java index 1b5d8b6b6..2a40065ee 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/dsc/data/BankUserDscData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/dsc/data/BankUserDscData.java @@ -19,6 +19,7 @@ package org.apache.fineract.useradministration.dsc.data; import java.time.LocalDate; +import org.apache.fineract.infrastructure.core.service.DateUtils; import java.time.OffsetDateTime; import lombok.Builder; import lombok.Getter; @@ -83,7 +84,7 @@ public class BankUserDscData { return null; } if (stored == CertStatus.ACTIVE && d.getDscExpiryDate() != null - && d.getDscExpiryDate().isBefore(LocalDate.now())) { + && d.getDscExpiryDate().isBefore(DateUtils.getLocalDateOfTenant())) { return CertStatus.EXPIRED.name(); } return stored.name(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/handler/CreateUserAgreementCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/handler/CreateUserAgreementCommandHandler.java new file mode 100644 index 000000000..542d29e22 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/handler/CreateUserAgreementCommandHandler.java @@ -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.useradministration.handler; + +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.useradministration.agreement.service.UserAgreementWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "USERAGREEMENT", action = "CREATE") +public class CreateUserAgreementCommandHandler implements NewCommandSourceHandler { + + private final UserAgreementWritePlatformService writePlatformService; + + @Autowired + public CreateUserAgreementCommandHandler(final UserAgreementWritePlatformService writePlatformService) { + this.writePlatformService = writePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.create(command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserWritePlatformServiceJpaRepositoryImpl.java index 0ba57146f..7ab6a9866 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserWritePlatformServiceJpaRepositoryImpl.java @@ -23,6 +23,8 @@ import static org.apache.fineract.useradministration.service.AppUserConstants.CL import com.google.gson.JsonArray; import com.google.gson.JsonElement; import jakarta.persistence.PersistenceException; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.util.Collection; import java.util.HashSet; import java.util.List; @@ -59,6 +61,9 @@ import org.apache.fineract.useradministration.domain.UserDomainService; import org.apache.fineract.useradministration.exception.PasswordPreviouslyUsedException; import org.apache.fineract.useradministration.exception.RoleNotFoundException; import org.apache.fineract.useradministration.exception.UserNotFoundException; +import org.apache.fineract.useradministration.mfa.domain.MfaFactorType; +import org.apache.fineract.useradministration.mfa.domain.UserMfaFactor; +import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Caching; import org.springframework.dao.DataIntegrityViolationException; @@ -84,6 +89,7 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit private final StaffRepositoryWrapper staffRepositoryWrapper; private final ClientRepositoryWrapper clientRepositoryWrapper; private final ConfigurationDomainService configurationDomainService; + private final UserMfaFactorRepository userMfaFactorRepository; @Override @Transactional @@ -143,6 +149,24 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit final Boolean sendPasswordToEmail = command.booleanObjectValueOfParameterNamed("sendPasswordToEmail"); this.userDomainService.create(appUser, sendPasswordToEmail); + // Admin picks the OTP delivery channel for this user at creation time (mandatory field + // in the Create User form). Enrolling it here as an already-verified MFA factor means the + // existing 2FA system (MfaFactorBackedTwoFactorServiceImpl + /twofactor + the + // TwoFactorAuthenticationComponent login dialog) challenges this user for OTP on their very + // first login, with no separate first-login flow needed - it's the same experience any + // MFA-enrolled user already gets. + final String otpDeliveryMethod = command.stringValueOfParameterNamed("otpDeliveryMethod"); + if (otpDeliveryMethod != null) { + final MfaFactorType factorType = MfaFactorType.fromString(otpDeliveryMethod); + final UserMfaFactor factor = new UserMfaFactor(); + factor.setAppUser(appUser); + factor.setFactorType(factorType); + factor.setDestination(factorType == MfaFactorType.SMS ? appUser.getMobileNo() : appUser.getEmail()); + factor.setVerified(true); + factor.setVerifiedOn(OffsetDateTime.now(ZoneOffset.UTC)); + this.userMfaFactorRepository.save(factor); + } + return new CommandProcessingResultBuilder() // .withCommandId(command.commandId()) // .withEntityId(appUser.getId()) // diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java index 2f87652de..762562c85 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java @@ -59,15 +59,18 @@ public final class UserDataValidator { public static final String SEND_PASSWORD_TO_EMAIL = "sendPasswordToEmail"; public static final String STAFF_ID = "staffId"; public static final String PASSWORD_NEVER_EXPIRES = "passwordNeverExpires"; + public static final String OTP_DELIVERY_METHOD = "otpDeliveryMethod"; /** * The parameters supported for this command. */ private static final Set CREATE_SUPPORTED_PARAMETERS = new HashSet<>( Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD, REPEAT_PASSWORD, EMAIL, MOBILE_NO, OFFICE_ID, NOT_SELECTED_ROLES, - ROLES, SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_SELF_SERVICE_USER, CLIENTS)); + ROLES, SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_SELF_SERVICE_USER, CLIENTS, + OTP_DELIVERY_METHOD)); private static final Set UPDATE_SUPPORTED_PARAMETERS = new HashSet<>( Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD, REPEAT_PASSWORD, EMAIL, MOBILE_NO, OFFICE_ID, NOT_SELECTED_ROLES, - ROLES, SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_SELF_SERVICE_USER, CLIENTS)); + ROLES, SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_SELF_SERVICE_USER, CLIENTS, + OTP_DELIVERY_METHOD)); private static final Set CHANGE_PASSWORD_SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList(PASSWORD, REPEAT_PASSWORD)); public static final String PASSWORD_NEVER_EXPIRE = "passwordNeverExpire"; @@ -120,6 +123,14 @@ public final class UserDataValidator { final Long officeId = this.fromApiJsonHelper.extractLongNamed(OFFICE_ID, element); baseDataValidator.reset().parameter(OFFICE_ID).value(officeId).notNull().integerGreaterThanZero(); + final String otpDeliveryMethod = this.fromApiJsonHelper.extractStringNamed(OTP_DELIVERY_METHOD, element); + baseDataValidator.reset().parameter(OTP_DELIVERY_METHOD).value(otpDeliveryMethod).notBlank() + .isOneOfTheseStringValues(Arrays.asList("EMAIL", "SMS")); + if ("SMS".equalsIgnoreCase(otpDeliveryMethod)) { + final String mobileNo = this.fromApiJsonHelper.extractStringNamed(MOBILE_NO, element); + baseDataValidator.reset().parameter(MOBILE_NO).value(mobileNo).notBlank(); + } + if (this.fromApiJsonHelper.parameterExists(STAFF_ID, element)) { final Long staffId = this.fromApiJsonHelper.extractLongNamed(STAFF_ID, element); baseDataValidator.reset().parameter(STAFF_ID).value(staffId).notNull().integerGreaterThanZero(); @@ -258,6 +269,12 @@ public final class UserDataValidator { baseDataValidator.reset().parameter(EMAIL).value(email).notBlank().notExceedingLengthOf(100); } + if (this.fromApiJsonHelper.parameterExists(OTP_DELIVERY_METHOD, element)) { + final String otpDeliveryMethod = this.fromApiJsonHelper.extractStringNamed(OTP_DELIVERY_METHOD, element); + baseDataValidator.reset().parameter(OTP_DELIVERY_METHOD).value(otpDeliveryMethod).notBlank() + .isOneOfTheseStringValues(Arrays.asList("EMAIL", "SMS")); + } + if (this.fromApiJsonHelper.parameterExists(ROLES, element)) { final String[] roles = this.fromApiJsonHelper.extractArrayNamed(ROLES, element); baseDataValidator.reset().parameter(ROLES).value(roles).arrayNotEmpty(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/starter/UserAdministrationConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/starter/UserAdministrationConfiguration.java index 9ef25cac8..ae7e5ec9e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/starter/UserAdministrationConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/starter/UserAdministrationConfiguration.java @@ -52,6 +52,13 @@ import org.apache.fineract.useradministration.service.RoleReadPlatformService; import org.apache.fineract.useradministration.service.RoleReadPlatformServiceImpl; import org.apache.fineract.useradministration.service.RoleWritePlatformService; import org.apache.fineract.useradministration.service.RoleWritePlatformServiceJpaRepositoryImpl; +import org.apache.fineract.useradministration.agreement.domain.UserAgreementAcceptanceRepository; +import org.apache.fineract.useradministration.agreement.domain.UserAgreementRepository; +import org.apache.fineract.useradministration.agreement.service.UserAgreementReadPlatformService; +import org.apache.fineract.useradministration.agreement.service.UserAgreementReadPlatformServiceImpl; +import org.apache.fineract.useradministration.agreement.service.UserAgreementWritePlatformService; +import org.apache.fineract.useradministration.agreement.service.UserAgreementWritePlatformServiceImpl; +import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository; import org.apache.fineract.useradministration.service.UserDataValidator; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; @@ -76,10 +83,11 @@ public class UserAdministrationConfiguration { PlatformPasswordEncoder platformPasswordEncoder, AppUserRepository appUserRepository, OfficeRepositoryWrapper officeRepositoryWrapper, RoleRepository roleRepository, UserDataValidator fromApiJsonDeserializer, AppUserPreviousPasswordRepository appUserPreviewPasswordRepository, StaffRepositoryWrapper staffRepositoryWrapper, - ClientRepositoryWrapper clientRepositoryWrapper, ConfigurationDomainService configurationDomainService) { + ClientRepositoryWrapper clientRepositoryWrapper, ConfigurationDomainService configurationDomainService, + UserMfaFactorRepository userMfaFactorRepository) { return new AppUserWritePlatformServiceJpaRepositoryImpl(context, userDomainService, platformPasswordEncoder, appUserRepository, officeRepositoryWrapper, roleRepository, fromApiJsonDeserializer, appUserPreviewPasswordRepository, staffRepositoryWrapper, - clientRepositoryWrapper, configurationDomainService); + clientRepositoryWrapper, configurationDomainService, userMfaFactorRepository); } @Bean @@ -124,4 +132,17 @@ public class UserAdministrationConfiguration { return new RoleWritePlatformServiceJpaRepositoryImpl(context, roleRepository, permissionRepository, roleCommandFromApiJsonDeserializer, permissionsFromApiJsonDeserializer); } + + @Bean + @ConditionalOnMissingBean(UserAgreementReadPlatformService.class) + public UserAgreementReadPlatformService userAgreementReadPlatformService(UserAgreementRepository userAgreementRepository) { + return new UserAgreementReadPlatformServiceImpl(userAgreementRepository); + } + + @Bean + @ConditionalOnMissingBean(UserAgreementWritePlatformService.class) + public UserAgreementWritePlatformService userAgreementWritePlatformService(PlatformSecurityContext context, + UserAgreementRepository userAgreementRepository, UserAgreementAcceptanceRepository userAgreementAcceptanceRepository) { + return new UserAgreementWritePlatformServiceImpl(context, userAgreementRepository, userAgreementAcceptanceRepository); + } } diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index f78e18d81..02f4b9b34 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -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:313863} +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:313863} +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} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index fe46285d1..972c0081b 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -436,4 +436,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0341_add_current_account_pending_edit_table.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0341_add_current_account_pending_edit_table.xml new file mode 100644 index 000000000..9c8e57efd --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0341_add_current_account_pending_edit_table.xml @@ -0,0 +1,218 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0342_add_current_account_edit_approval_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0342_add_current_account_edit_approval_permissions.xml new file mode 100644 index 000000000..53d231abe --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0342_add_current_account_edit_approval_permissions.xml @@ -0,0 +1,85 @@ + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'SUBMITEDIT_CURRENTACCOUNT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVEEDIT_CURRENTACCOUNT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'REJECTEDIT_CURRENTACCOUNT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVEEDIT_CURRENTACCOUNT_CHECKER' + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0343_create_sponsor_bank_dd_tables.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0343_create_sponsor_bank_dd_tables.xml new file mode 100644 index 000000000..8a21ba3fa --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0343_create_sponsor_bank_dd_tables.xml @@ -0,0 +1,1290 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DELETE FROM m_sponsor_dd_audit_log; + + + + + + + + + DELETE FROM m_sponsor_dd_transaction; + + + + + + + + + DELETE FROM m_sponsor_dd_reco_record; + + + + + + + + + DELETE FROM m_sponsor_dd_reco_batch; + + + + + + + + + DELETE FROM m_sponsor_dd_leaflet; + + + + + + + + + DELETE FROM m_sponsor_dd_inventory; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0344_create_sponsor_dd_issue_request_tables.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0344_create_sponsor_dd_issue_request_tables.xml new file mode 100644 index 000000000..b93c18cd5 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0344_create_sponsor_dd_issue_request_tables.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0345_add_sponsor_dd_payable_gl_account_sbi.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0345_add_sponsor_dd_payable_gl_account_sbi.xml new file mode 100644 index 000000000..09ae13f0c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0345_add_sponsor_dd_payable_gl_account_sbi.xml @@ -0,0 +1,73 @@ + + + + + + + + + select count(*) from acc_gl_account where gl_code = '9.3' + + + + + + + + + + + + + + + + + + + select count(*) from acc_gl_account where gl_code = '9.3' and hierarchy = '.' + + + UPDATE acc_gl_account SET hierarchy = CONCAT('.', id, '.') WHERE gl_code = '9.3' AND hierarchy = '.'; + + + + + + + + select count(*) from m_sponsor_dd_gl_mapping m join m_payment_type pt on pt.id = m.payment_type_id where pt.value = 'SBI' + + + + INSERT INTO m_sponsor_dd_gl_mapping (payment_type_id, dd_payable_gl_account_id, created_date) + SELECT pt.id, gl.id, CURRENT_TIMESTAMP + FROM m_payment_type pt, acc_gl_account gl + WHERE pt.value = 'SBI' AND gl.gl_code = '9.3'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0347_add_sponsor_dd_walkin_cash_support.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0347_add_sponsor_dd_walkin_cash_support.xml new file mode 100644 index 000000000..d18351925 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0347_add_sponsor_dd_walkin_cash_support.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0348_add_sponsor_dd_cash_charges_gl_mapping.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0348_add_sponsor_dd_cash_charges_gl_mapping.xml new file mode 100644 index 000000000..c7494f36a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0348_add_sponsor_dd_cash_charges_gl_mapping.xml @@ -0,0 +1,77 @@ + + + + + + + + + select count(*) from acc_gl_account where gl_code = '10.3' + + + + + + + + + + + + + + + + + + + select count(*) from acc_gl_account where gl_code = '10.3' and hierarchy = '.' + + + UPDATE acc_gl_account SET hierarchy = CONCAT('.', id, '.') WHERE gl_code = '10.3' AND hierarchy = '.'; + + + + + + + + + select count(*) from m_sponsor_dd_gl_mapping where cash_gl_account_id is null or charges_income_gl_account_id is null + + + + + UPDATE m_sponsor_dd_gl_mapping + SET cash_gl_account_id = (select id from acc_gl_account where gl_code = '2'), + charges_income_gl_account_id = (select id from acc_gl_account where gl_code = '10.3') + WHERE cash_gl_account_id is null or charges_income_gl_account_id is null; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0349_replace_payment_type_with_sponsor_bank.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0349_replace_payment_type_with_sponsor_bank.xml new file mode 100644 index 000000000..71854b537 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0349_replace_payment_type_with_sponsor_bank.xml @@ -0,0 +1,117 @@ + + + + + + + + + + + + DROP TABLE IF EXISTS m_sponsor_bank CASCADE; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -- Insert unique sponsor banks from payment types into m_sponsor_bank + INSERT INTO m_sponsor_bank (name, description, createdby_id, created_date) + SELECT DISTINCT pt.value, pt.description, 1, CURRENT_TIMESTAMP + FROM m_payment_type pt + WHERE (pt.description = 'Sponsor Bank' + OR pt.id IN (SELECT DISTINCT payment_type_id FROM m_sponsor_dd_inventory)) + AND pt.value NOT IN (SELECT name FROM m_sponsor_bank); + + + + + + + + + + + + + + + + + + + + + + + + -- Update sponsor_bank_id based on name matching from m_payment_type + UPDATE m_sponsor_dd_inventory + SET sponsor_bank_id = ( + SELECT sb.id + FROM m_sponsor_bank sb + JOIN m_payment_type pt ON pt.value = sb.name + WHERE pt.id = m_sponsor_dd_inventory.payment_type_id + LIMIT 1 + ) + WHERE sponsor_bank_id IS NULL; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0350_add_sponsor_dd_cash_receipt_suspense.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0350_add_sponsor_dd_cash_receipt_suspense.xml new file mode 100644 index 000000000..a762a676a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0350_add_sponsor_dd_cash_receipt_suspense.xml @@ -0,0 +1,119 @@ + + + + + + + + + select count(*) from acc_gl_account where gl_code = '9.4' + + + + + + + + + + + + + + + + + + select count(*) from acc_gl_account where gl_code = '9.4' and hierarchy = '.' + + + UPDATE acc_gl_account SET hierarchy = CONCAT('.', id, '.') WHERE gl_code = '9.4' AND hierarchy = '.'; + + + + + + + + + + + + + + + + + + + + + + select count(*) from m_sponsor_dd_gl_mapping where cash_suspense_gl_account_id is null + + + + + UPDATE m_sponsor_dd_gl_mapping + SET cash_suspense_gl_account_id = (select id from acc_gl_account where gl_code = '9.4') + WHERE cash_suspense_gl_account_id is null; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0351_add_sponsor_dd_reprint_approval.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0351_add_sponsor_dd_reprint_approval.xml new file mode 100644 index 000000000..fe0d6682f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0351_add_sponsor_dd_reprint_approval.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0352_add_sponsor_dd_payable_at.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0352_add_sponsor_dd_payable_at.xml new file mode 100644 index 000000000..57004ed69 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0352_add_sponsor_dd_payable_at.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0353_add_sponsor_dd_gst_on_charges.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0353_add_sponsor_dd_gst_on_charges.xml new file mode 100644 index 000000000..02c79e220 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0353_add_sponsor_dd_gst_on_charges.xml @@ -0,0 +1,109 @@ + + + + + + + + + select count(*) from acc_gl_account where gl_code = '9.5' + + + + + + + + + + + + + + + + + + select count(*) from acc_gl_account where gl_code = '9.5' and hierarchy = '.' + + + UPDATE acc_gl_account SET hierarchy = CONCAT('.', id, '.') WHERE gl_code = '9.5' AND hierarchy = '.'; + + + + + + + + + + + + + + + + + + + + + + + + + + select count(*) from m_sponsor_dd_gl_mapping where gst_payable_gl_account_id is null + + + + + UPDATE m_sponsor_dd_gl_mapping + SET gst_payable_gl_account_id = (select id from acc_gl_account where gl_code = '9.5') + WHERE gst_payable_gl_account_id is null; + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0354_add_sponsor_bank_identification_codes.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0354_add_sponsor_bank_identification_codes.xml new file mode 100644 index 000000000..c7e6f02ec --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0354_add_sponsor_bank_identification_codes.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0355_add_sponsor_dd_cashier_deposit_link.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0355_add_sponsor_dd_cashier_deposit_link.xml new file mode 100644 index 000000000..8af9908fd --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0355_add_sponsor_dd_cashier_deposit_link.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + select count(*) from acc_gl_account where gl_code = '1' + + + UPDATE m_sponsor_dd_gl_mapping + SET cash_gl_account_id = (select id from acc_gl_account where gl_code = '1') + WHERE cash_gl_account_id = (select id from acc_gl_account where gl_code = '2'); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0356_add_dd_breakdown_to_cashier_deposit_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0356_add_dd_breakdown_to_cashier_deposit_request.xml new file mode 100644 index 000000000..6303e9d4e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0356_add_dd_breakdown_to_cashier_deposit_request.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0357_add_dd_limit_to_sponsor_dd_gl_mapping.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0357_add_dd_limit_to_sponsor_dd_gl_mapping.xml new file mode 100644 index 000000000..ec10d954f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0357_add_dd_limit_to_sponsor_dd_gl_mapping.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0358_add_batch_id_to_sponsor_dd_issue_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0358_add_batch_id_to_sponsor_dd_issue_request.xml new file mode 100644 index 000000000..9a838f0b9 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0358_add_batch_id_to_sponsor_dd_issue_request.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0359_reclassify_dd_gst_payable_as_income.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0359_reclassify_dd_gst_payable_as_income.xml new file mode 100644 index 000000000..4504274ff --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0359_reclassify_dd_gst_payable_as_income.xml @@ -0,0 +1,41 @@ + + + + + + + + select count(*) from acc_gl_account where gl_code = '9.5' and classification_enum = 2 + + + UPDATE acc_gl_account + SET classification_enum = 4, parent_id = 25 + WHERE gl_code = '9.5'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0360_add_resolution_number_to_share_account.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0360_add_resolution_number_to_share_account.xml new file mode 100644 index 000000000..4bf83ca25 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0360_add_resolution_number_to_share_account.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0361_add_share_account_pending_edit_table.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0361_add_share_account_pending_edit_table.xml new file mode 100644 index 000000000..ca3597500 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0361_add_share_account_pending_edit_table.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0362_add_share_account_edit_approval_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0362_add_share_account_edit_approval_permissions.xml new file mode 100644 index 000000000..3973b4140 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0362_add_share_account_edit_approval_permissions.xml @@ -0,0 +1,83 @@ + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'SUBMITEDIT_SHAREACCOUNT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVEEDIT_SHAREACCOUNT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'REJECTEDIT_SHAREACCOUNT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVEEDIT_SHAREACCOUNT_CHECKER' + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0415_add_otp_delivery_method_and_first_login_gate_to_appuser.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0415_add_otp_delivery_method_and_first_login_gate_to_appuser.xml new file mode 100644 index 000000000..a4914e7f9 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0415_add_otp_delivery_method_and_first_login_gate_to_appuser.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0416_create_user_agreement_tables.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0416_create_user_agreement_tables.xml new file mode 100644 index 000000000..3408c9384 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0416_create_user_agreement_tables.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0417_add_first_login_gate_config.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0417_add_first_login_gate_config.xml new file mode 100644 index 000000000..8338de2bb --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0417_add_first_login_gate_config.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0418_add_user_agreement_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0418_add_user_agreement_permissions.xml new file mode 100644 index 000000000..67efb4d4c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0418_add_user_agreement_permissions.xml @@ -0,0 +1,27 @@ + + + + + + + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'organisation', 'CREATE_USERAGREEMENT', 'USERAGREEMENT', 'CREATE', false + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_USERAGREEMENT'); + + + + + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'organisation', 'READ_USERAGREEMENT', 'USERAGREEMENT', 'READ', false + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_USERAGREEMENT'); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0419_revert_first_login_gate_column_and_config.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0419_revert_first_login_gate_column_and_config.xml new file mode 100644 index 000000000..b01145c59 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0419_revert_first_login_gate_column_and_config.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + DELETE FROM c_configuration WHERE name = 'enable-first-login-otp-and-agreement-verification'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0420_add_missing_clientkyc_action_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0420_add_missing_clientkyc_action_permissions.xml new file mode 100644 index 000000000..58f02ee4a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0420_add_missing_clientkyc_action_permissions.xml @@ -0,0 +1,106 @@ + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'GENERATEAADHAROTP_CLIENTKYC' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0421_add_master_salutation_value.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0421_add_master_salutation_value.xml new file mode 100644 index 000000000..77423341a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0421_add_master_salutation_value.xml @@ -0,0 +1,43 @@ + + + + + + + + + SELECT COUNT(*) FROM m_code_value + WHERE code_id = (SELECT id FROM m_code WHERE code_name = 'Salutation') + AND code_value = 'Master' + + + + INSERT INTO m_code_value (code_id, code_value, order_position, is_active, is_mandatory) + SELECT id, 'Master', (SELECT COALESCE(MAX(order_position), 0) + 1 FROM m_code_value WHERE code_id = (SELECT id FROM m_code WHERE code_name = 'Salutation')), true, false + FROM m_code + WHERE code_name = 'Salutation'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0421_add_signature_id_to_client.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0421_add_signature_id_to_client.xml new file mode 100644 index 000000000..1ccbf716b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0421_add_signature_id_to_client.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE m_client c + SET signature_id = ( + SELECT MAX(d.id) FROM m_document d + WHERE d.parent_entity_type = 'clients' AND d.parent_entity_id = c.id AND d.name = 'clientSignature' + ) + WHERE EXISTS ( + SELECT 1 FROM m_document d + WHERE d.parent_entity_type = 'clients' AND d.parent_entity_id = c.id AND d.name = 'clientSignature' + ) + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0422_add_monthly_turnover_to_client.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0422_add_monthly_turnover_to_client.xml new file mode 100644 index 000000000..b14c60a87 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0422_add_monthly_turnover_to_client.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0423_add_annual_income_to_client.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0423_add_annual_income_to_client.xml new file mode 100644 index 000000000..2e0914705 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0423_add_annual_income_to_client.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0424_add_senior_citizen_pwd_to_client.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0424_add_senior_citizen_pwd_to_client.xml new file mode 100644 index 000000000..a5a014907 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0424_add_senior_citizen_pwd_to_client.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_code WHERE code_name = 'PwdCategory' + + + + + + + + + + SELECT COUNT(*) FROM m_code_value WHERE code_id = (SELECT id FROM m_code WHERE code_name = 'PwdCategory') + + + INSERT INTO m_code_value (code_id, code_value, code_description, order_position, is_active, is_mandatory) + SELECT id, val, val, pos, true, false FROM m_code + CROSS JOIN (VALUES + ('Blindness', 1), + ('Low Vision', 2), + ('Leprosy Cured Persons', 3), + ('Hearing Impairment (Deaf and Hard of Hearing)', 4), + ('Locomotor Disability', 5), + ('Dwarfism', 6), + ('Intellectual Disability', 7), + ('Mental Illness', 8), + ('Autism Spectrum Disorder', 9), + ('Cerebral Palsy', 10), + ('Muscular Dystrophy', 11), + ('Chronic Neurological Conditions', 12), + ('Specific Learning Disabilities', 13), + ('Multiple Sclerosis', 14), + ('Speech and Language Disability', 15), + ('Thalassemia', 16), + ('Hemophilia', 17), + ('Sickle Cell Disease', 18), + ('Multiple Disabilities', 19), + ('Acid Attack Victim', 20), + ('Parkinson''s Disease', 21) + ) AS t(val, pos) + WHERE code_name = 'PwdCategory'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0425_add_diploma_degree_qualification.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0425_add_diploma_degree_qualification.xml new file mode 100644 index 000000000..6c5895868 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0425_add_diploma_degree_qualification.xml @@ -0,0 +1,83 @@ + + + + + + + + INSERT INTO m_code_value (code_id, code_value, order_position, is_active, is_mandatory) + SELECT c.id, 'Diploma', + (SELECT COALESCE(MAX(cv.order_position), 0) FROM m_code_value cv WHERE cv.code_id = c.id) + 1, + true, false + FROM m_code c + WHERE c.code_name = 'EducationLevel' + AND NOT EXISTS ( + SELECT 1 FROM m_code_value cv WHERE cv.code_id = c.id AND cv.code_value = 'Diploma' + ); + + + + + + INSERT INTO m_code_value (code_id, code_value, code_description, order_position, is_active, is_mandatory) + SELECT c.id, v.code_value, 'Diploma', + (SELECT COALESCE(MAX(cv.order_position), 0) FROM m_code_value cv WHERE cv.code_id = c.id) + v.rn, + true, false + FROM m_code c + CROSS JOIN ( + VALUES + ('Diploma in Mechanical Engineering', 1), + ('Diploma in Civil Engineering', 2), + ('Diploma in Electrical Engineering', 3), + ('Diploma in Electronics & Communication', 4), + ('Diploma in Computer Science / IT', 5), + ('Diploma in Automobile Engineering', 6), + ('ITI Certificate', 7), + ('Polytechnic Diploma (Other)', 8), + ('Other Diploma', 9) + ) AS v(code_value, rn) + WHERE c.code_name = 'DegreeQualification' + AND NOT EXISTS ( + SELECT 1 FROM m_code_value cv + WHERE cv.code_id = c.id AND cv.code_value = v.code_value AND cv.code_description = 'Diploma' + ); + + + + + + + UPDATE m_code_value cv + SET is_active = false + WHERE cv.code_id = (SELECT id FROM m_code WHERE code_name = 'DegreeQualification') + AND cv.code_value = 'Diploma' + AND cv.code_description = 'School'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0426_add_graduate_education_level.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0426_add_graduate_education_level.xml new file mode 100644 index 000000000..e49e862ce --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0426_add_graduate_education_level.xml @@ -0,0 +1,45 @@ + + + + + + + + INSERT INTO m_code_value (code_id, code_value, order_position, is_active, is_mandatory) + SELECT c.id, 'Graduate', + (SELECT COALESCE(MAX(cv.order_position), 0) FROM m_code_value cv WHERE cv.code_id = c.id) + 1, + true, false + FROM m_code c + WHERE c.code_name = 'EducationLevel' + AND NOT EXISTS ( + SELECT 1 FROM m_code_value cv WHERE cv.code_id = c.id AND cv.code_value = 'Graduate' + ); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0427_add_caretaker_relationship_value.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0427_add_caretaker_relationship_value.xml new file mode 100644 index 000000000..4632b67f6 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0427_add_caretaker_relationship_value.xml @@ -0,0 +1,45 @@ + + + + + + + + + SELECT COUNT(*) FROM m_code_value cv JOIN m_code c ON c.id = cv.code_id + WHERE c.code_name = 'RELATIONSHIP' AND cv.code_value = 'CARETAKER' + + + + INSERT INTO m_code_value (code_id, code_value, order_position, is_active, is_mandatory) + SELECT c.id, 'CARETAKER', + (SELECT COALESCE(MAX(cv.order_position), 0) FROM m_code_value cv WHERE cv.code_id = c.id) + 1, + true, false + FROM m_code c + WHERE c.code_name = 'RELATIONSHIP'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0428_add_client_risk_rules.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0428_add_client_risk_rules.xml new file mode 100644 index 000000000..44db6f98c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0428_add_client_risk_rules.xml @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_client_risk_rule + + + INSERT INTO m_client_risk_rule (rule_name, legal_form_id, occupation_category_cv_id, occupation_cv_id, min_monthly_income, min_annual_income, min_annual_turnover, risk_category, reason, order_position) + VALUES + ('Politician / Elected Representative', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Politician / Elected Representative' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 3, 'Politically Exposed Person (PEP) / Elected Representative', 10), + ('Real Estate Dealer', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Real Estate / Property Dealer' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 3, 'High Risk Industry (Real Estate / Property Dealing)', 20), + ('Importer / Exporter', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Importer / Exporter' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 3, 'High Risk Trade (Import / Export)', 30), + ('Commission Agent', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Commission Agent' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 3, 'High Risk Business Type (Commission Agent)', 40), + ('Financial Advisor', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Financial Advisor' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 3, 'High Risk Professional (Financial Advisor)', 50), + ('High Monthly Income (> 2 Lakhs)', 1, null, null, 200000.0, null, null, 3, 'High Income threshold (> ₹2L Monthly)', 60), + ('High Annual Income (> 20 Lakhs)', 1, null, null, null, 2000000.0, null, 3, 'High Income threshold (> ₹20L Annual)', 70), + ('High Business Turnover (> 1 Crore)', 1, null, null, null, null, 10000000.0, 3, 'High Business Turnover (> ₹1 Crore Annual)', 80), + ('NRI Account Profile', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'NRI (Non-Resident Indian)' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 2, 'Non-Resident Indian (NRI) account profile', 90), + ('PIO / OCI Profile', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'PIO / OCI' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 2, 'Foreign/Overseas Citizen profile (PIO / OCI)', 100), + ('Business Owner / Proprietor', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Business Owner / Proprietor' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 2, 'Self-Employed / Business owner profile', 110), + ('Trader / Merchant', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Trader / Merchant' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 2, 'Self-Employed / Business owner profile', 120), + ('Manufacturer', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Manufacturer' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 2, 'Self-Employed / Business owner profile', 130), + ('Contractor', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Contractor' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 2, 'Self-Employed / Business owner profile', 140), + ('Doctor (Private Practice)', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Doctor (Private Practice)' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 2, 'Self-Employed Professional profile', 150), + ('Lawyer / Advocate', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Lawyer / Advocate' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 2, 'Self-Employed Professional profile', 160), + ('Chartered Accountant', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Chartered Accountant' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 2, 'Self-Employed Professional profile', 170), + ('Company Secretary', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Company Secretary' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 2, 'Self-Employed Professional profile', 180), + ('Architect', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Architect' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 2, 'Self-Employed Professional profile', 190), + ('Consultant', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Consultant' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 2, 'Self-Employed Professional profile', 200), + ('IT Professional / Freelancer', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'IT Professional / Freelancer' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 2, 'Self-Employed Professional profile', 210), + ('Auditor', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Auditor' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 2, 'Self-Employed Professional profile', 220), + ('Business / Self Employed Category', 1, (SELECT id FROM m_code_value WHERE code_value = 'Business / Self Employed' AND code_id = (SELECT id FROM m_code WHERE code_name = 'OccupationCategory')), null, null, null, null, 2, 'Business / Self-Employed Category', 230), + ('Professional Category', 1, (SELECT id FROM m_code_value WHERE code_value = 'Professional' AND code_id = (SELECT id FROM m_code WHERE code_name = 'OccupationCategory')), null, null, null, null, 2, 'Professional Category', 240), + ('Medium Monthly Income (> 50K)', 1, null, null, 50000.0, null, null, 2, 'Medium Income threshold (> ₹50K Monthly)', 250), + ('Medium Annual Income (> 6 Lakhs)', 1, null, null, null, 600000.0, null, 2, 'Medium Income threshold (> ₹6L Annual)', 260), + ('Medium Business Turnover (> 20 Lakhs)', 1, null, null, null, null, 2000000.0, 2, 'Medium Business Turnover (> ₹20L Annual)', 270), + ('Student Profile', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Student' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 1, 'Non-Earning profile (Student)', 280), + ('Housewife / Homemaker Profile', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Housewife / Homemaker' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 1, 'Non-Earning profile (Homemaker)', 290), + ('Dependent / Minor Profile', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Dependent / Minor' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 1, 'Non-Earning profile (Dependent / Minor)', 300), + ('Unemployed Profile', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Unemployed' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 1, 'Non-Earning profile (Unemployed)', 310), + ('Government Employee', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Government Employee' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 1, 'Low risk employment (Government Employee)', 320), + ('PSU Employee', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Public Sector Undertaking (PSU) Employee' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 1, 'Low risk employment (PSU Employee)', 330), + ('Defence Personnel', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Defence / Armed Forces Personnel' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 1, 'Low risk employment (Defence Personnel)', 340), + ('Police / Paramilitary', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Police / Paramilitary' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 1, 'Low risk employment (Police / Paramilitary)', 350), + ('Teacher / Professor', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Teacher / Professor' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 1, 'Low risk employment (Teacher / Professor)', 360), + ('Bank Employee', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Bank Employee' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 1, 'Low risk employment (Bank Employee)', 370), + ('Railway Employee', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Railway Employee' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 1, 'Low risk employment (Railway Employee)', 380), + ('Farmer (Own Land)', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Farmer (Own Land)' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 1, 'Low risk agricultural occupation', 390), + ('Tenant Farmer', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Tenant Farmer' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 1, 'Low risk agricultural occupation', 400), + ('Agricultural Labourer', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Agricultural Labourer' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 1, 'Low risk manual/agricultural labour', 410), + ('Labourer / Daily Wage', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Labourer / Daily Wage' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 1, 'Low risk manual labour', 420), + ('Domestic Worker', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Domestic Worker' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 1, 'Low risk manual/domestic service', 430), + ('Driver / Operator', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Driver / Operator' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 1, 'Low risk manual/transport service', 440), + ('Artisan / Craftsman', 1, null, (SELECT id FROM m_code_value WHERE code_value = 'Artisan / Craftsman' AND code_id = (SELECT id FROM m_code WHERE code_name = 'Occupation')), null, null, null, 1, 'Low risk manual/artisan profile', 450), + ('Non-Individual Default Medium Risk', 2, null, null, null, null, null, 2, 'Non-Individual / Entity profile (Default Medium Risk)', 460), + ('Global Default Low Risk', null, null, null, null, null, null, 1, 'Default Low Risk profile (Salary / Service / Agriculture / Others)', 500); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0428_add_minor_kyc_id_proof_codes.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0428_add_minor_kyc_id_proof_codes.xml new file mode 100644 index 000000000..3179a4b90 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0428_add_minor_kyc_id_proof_codes.xml @@ -0,0 +1,65 @@ + + + + + + + + + SELECT COUNT(*) FROM m_code_value cv JOIN m_code c ON c.id = cv.code_id + WHERE c.code_name = 'KYCIdProofType' AND cv.code_value = 'Birth Certificate' + + + + INSERT INTO m_code_value (code_id, code_value, code_description, order_position, is_active, is_mandatory) + SELECT c.id, 'Birth Certificate', 'BIRTH_CERTIFICATE', + (SELECT COALESCE(MAX(cv.order_position), 0) FROM m_code_value cv WHERE cv.code_id = c.id) + 1, + true, false + FROM m_code c + WHERE c.code_name = 'KYCIdProofType'; + + + + + + + SELECT COUNT(*) FROM m_code_value cv JOIN m_code c ON c.id = cv.code_id + WHERE c.code_name = 'KYCIdProofType' AND cv.code_value = 'School ID Card' + + + + INSERT INTO m_code_value (code_id, code_value, code_description, order_position, is_active, is_mandatory) + SELECT c.id, 'School ID Card', 'SCHOOL_ID_CARD', + (SELECT COALESCE(MAX(cv.order_position), 0) FROM m_code_value cv WHERE cv.code_id = c.id) + 1, + true, false + FROM m_code c + WHERE c.code_name = 'KYCIdProofType'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0429_add_client_pending_edit_table.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0429_add_client_pending_edit_table.xml new file mode 100644 index 000000000..b63ae53b7 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0429_add_client_pending_edit_table.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0430_add_client_edit_approval_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0430_add_client_edit_approval_permissions.xml new file mode 100644 index 000000000..9d6a5b2d8 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0430_add_client_edit_approval_permissions.xml @@ -0,0 +1,64 @@ + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'SUBMITEDIT_CLIENT' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0431_add_original_json_to_client_pending_edit.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0431_add_original_json_to_client_pending_edit.xml new file mode 100644 index 000000000..f2b98ca86 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0431_add_original_json_to_client_pending_edit.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0432_add_entity_kyc_document_type_codes.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0432_add_entity_kyc_document_type_codes.xml new file mode 100644 index 000000000..c3219be61 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0432_add_entity_kyc_document_type_codes.xml @@ -0,0 +1,86 @@ + + + + + + + + INSERT INTO m_code_value (code_id, code_value, code_description, order_position, is_active, is_mandatory) + SELECT c.id, v.code_value, v.code_value, + (SELECT COALESCE(MAX(cv.order_position), 0) FROM m_code_value cv WHERE cv.code_id = c.id) + + ROW_NUMBER() OVER (ORDER BY v.code_value), + true, false + FROM m_code c + CROSS JOIN (VALUES + ('Certificate of Incorporation / Registration Certificate'), + ('PAN Card of Entity'), + ('Memorandum & Articles of Association / Partnership Deed / Trust Deed'), + ('Board Resolution / Authorization Letter'), + ('KYC of Authorized Signatory / Power of Attorney Holder'), + ('GST Registration Certificate'), + ('PAN Card of HUF'), + ('Declaration by Karta (with name, address & HUF member details)'), + ('KYC of Karta (OVDs + PAN + Photo)'), + ('Proof of Address of HUF'), + ('Certificate of Incorporation'), + ('Memorandum & Articles of Association'), + ('PAN of Company'), + ('Board Resolution (authorizing signatories)'), + ('KYC of Authorized Signatories'), + ('LLP Agreement'), + ('PAN of LLP'), + ('Designated Partners Resolution'), + ('KYC of Designated Partners / Authorized Signatories'), + ('Registration Certificate'), + ('Partnership Deed'), + ('PAN of Firm'), + ('Trust Deed'), + ('PAN of Trust'), + ('KYC of Trustees / Authorized Signatories'), + ('PAN of Society'), + ('Proof of Business Existence (GST Certificate / Shop License)'), + ('PAN of Proprietor'), + ('Proprietor''s KYC (OVDs + PAN + Photo)'), + ('PAN'), + ('Resolution authorizing signatories'), + ('Government Notification / Order'), + ('PAN (if applicable)'), + ('Authorized Signatory KYC') + ) AS v(code_value) + WHERE c.code_name = 'KYCIdProofType' + AND NOT EXISTS ( + SELECT 1 FROM m_code_value cv2 WHERE cv2.code_id = c.id AND cv2.code_value = v.code_value + ); + + + + diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java index 040e79e11..8bea46e3a 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java @@ -116,6 +116,21 @@ public final class FixedDepositAccountData extends DepositAccountData { private Boolean deceasedSettlement; private Boolean allowDebitTxnAfterDueDate; + // Live per-account dormancy state (distinct from the product-level + // isDormancyTrackingActive/daysToInactive/daysToDormancy/daysToEscheat + // thresholds above): the account's actual current sub-status and the + // computed countdown, mirroring SavingsAccountData. + private SavingsAccountSubStatusEnumData subStatus; + private String reasonForBlock; + private LocalDate lastActiveTransactionDate; + + public void setDormancyStatus(final SavingsAccountSubStatusEnumData subStatus, final String reasonForBlock, + final LocalDate lastActiveTransactionDate) { + this.subStatus = subStatus; + this.reasonForBlock = reasonForBlock; + this.lastActiveTransactionDate = lastActiveTransactionDate; + } + // Joint holders for JOINT account status private java.util.List> jointHolders; @@ -314,6 +329,9 @@ public final class FixedDepositAccountData extends DepositAccountData { this.jointHolders = src.jointHolders; this.isSeniorCitizen = src.isSeniorCitizen; this.seniorCitizenInterestRate = src.seniorCitizenInterestRate; + this.subStatus = src.subStatus; + this.reasonForBlock = src.reasonForBlock; + this.lastActiveTransactionDate = src.lastActiveTransactionDate; } private FixedDepositAccountData(Long clientId, Long productId, Long fieldofficerId, LocalDate submittedOnDate, @@ -428,6 +446,7 @@ public final class FixedDepositAccountData extends DepositAccountData { final Collection transactions, final Collection charges, final PortfolioAccountData linkedAccount, PortfolioAccountData transferToSavingsAccount) { + final boolean templateProvided = template != null; if (template == null) { template = account; } @@ -452,7 +471,7 @@ public final class FixedDepositAccountData extends DepositAccountData { account.transferToSavingsId, transferToSavingsAccount); result.copyCbsFieldsFrom(account); // Merge accountTypeOptions and customerSegmentOptions from template (not in account data) - if (template != null && template != account) { + if (templateProvided) { if (template.getAccountTypeOptions() != null) result.setAccountTypeOptions(template.getAccountTypeOptions()); if (template.getCustomerSegmentOptions() != null) result.setCustomerSegmentOptions(template.getCustomerSegmentOptions()); } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositAccountData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositAccountData.java index 601397d41..690f4c275 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositAccountData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositAccountData.java @@ -147,6 +147,21 @@ public final class RecurringDepositAccountData extends DepositAccountData { private Boolean deceasedSettlement; private Boolean allowDebitTxnAfterDueDate; + // Live per-account dormancy state (distinct from the product-level + // isDormancyTrackingActive/daysToInactive/daysToDormancy/daysToEscheat + // thresholds above): the account's actual current sub-status and the + // computed countdown, mirroring SavingsAccountData. + private SavingsAccountSubStatusEnumData subStatus; + private String reasonForBlock; + private LocalDate lastActiveTransactionDate; + + public void setDormancyStatus(final SavingsAccountSubStatusEnumData subStatus, final String reasonForBlock, + final LocalDate lastActiveTransactionDate) { + this.subStatus = subStatus; + this.reasonForBlock = reasonForBlock; + this.lastActiveTransactionDate = lastActiveTransactionDate; + } + public void setAccountTypeOptions(Collection accountTypeOptions) { this.accountTypeOptions = accountTypeOptions; } @@ -238,6 +253,9 @@ public final class RecurringDepositAccountData extends DepositAccountData { this.jointHolders = src.jointHolders; this.isSeniorCitizen = src.isSeniorCitizen; this.seniorCitizenInterestRate = src.seniorCitizenInterestRate; + this.subStatus = src.subStatus; + this.reasonForBlock = src.reasonForBlock; + this.lastActiveTransactionDate = src.lastActiveTransactionDate; } public void setCbsProductFields(String accountType, String customerSegment, String productCode, diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductDataValidator.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductDataValidator.java index 88dfdfaa8..c3502162f 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductDataValidator.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductDataValidator.java @@ -76,7 +76,6 @@ import org.apache.fineract.portfolio.savings.DepositAccountType; import org.apache.fineract.portfolio.savings.SavingsApiConstants; import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType; import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType; -import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType; import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType; import org.apache.fineract.portfolio.savings.domain.SavingsProduct; import org.springframework.stereotype.Component; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEdit.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEdit.java index 744f18cad..62a593c9d 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEdit.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEdit.java @@ -22,13 +22,13 @@ import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.JoinColumn; -import jakarta.persistence.Lob; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import java.time.LocalDateTime; import lombok.Getter; import lombok.Setter; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.useradministration.domain.AppUser; /** @@ -132,7 +132,7 @@ public class ProductPendingEdit extends AbstractPersistableCustom { this.productType = productType; this.productId = productId; this.maker = maker; - this.createdOn = LocalDateTime.now(); + this.createdOn = DateUtils.getLocalDateTimeOfTenant(); this.status = STATUS_PENDING; this.pendingLockKey = lockKeyFor(productType, productId); } @@ -190,14 +190,14 @@ public class ProductPendingEdit extends AbstractPersistableCustom { public void markApproved(final AppUser checker) { this.status = STATUS_APPROVED; this.checker = checker; - this.checkedOn = LocalDateTime.now(); + this.checkedOn = DateUtils.getLocalDateTimeOfTenant(); this.pendingLockKey = null; } public void markRejected(final AppUser checker, final String reason) { this.status = STATUS_REJECTED; this.checker = checker; - this.checkedOn = LocalDateTime.now(); + this.checkedOn = DateUtils.getLocalDateTimeOfTenant(); this.rejectReason = reason; this.pendingLockKey = null; } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java index 030703976..f95ec0d68 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java @@ -748,10 +748,17 @@ public final class ProductPendingEditFieldMapper { f.setTotalShares(command.longValueOfParameterNamed("totalShares")); present.add("totalShares"); } - if (command.hasParameter("totalSharesIssued") || command.hasParameter("issuedShares")) { - final Long val = command.hasParameter("totalSharesIssued") ? command.longValueOfParameterNamed("totalSharesIssued") : command.longValueOfParameterNamed("issuedShares"); + if (command.hasParameter("sharesIssued") || command.hasParameter("totalSharesIssued") || command.hasParameter("issuedShares")) { + final Long val; + if (command.hasParameter("sharesIssued")) { + val = command.longValueOfParameterNamed("sharesIssued"); + } else if (command.hasParameter("totalSharesIssued")) { + val = command.longValueOfParameterNamed("totalSharesIssued"); + } else { + val = command.longValueOfParameterNamed("issuedShares"); + } f.setTotalSharesIssued(val); - present.add("totalSharesIssued"); + present.add("sharesIssued"); } if (command.hasParameter("unitPrice")) { f.setUnitPrice(command.bigDecimalValueOfParameterNamed("unitPrice")); @@ -820,7 +827,7 @@ public final class ProductPendingEditFieldMapper { return; } putNumber(root, submitted, "totalShares", f.getTotalShares()); - putNumber(root, submitted, "totalSharesIssued", f.getTotalSharesIssued()); + putNumber(root, submitted, "sharesIssued", f.getTotalSharesIssued()); putNumber(root, submitted, "unitPrice", f.getUnitPrice()); putNumber(root, submitted, "minimumShares", f.getMinimumShares()); putNumber(root, submitted, "nominalShares", f.getNominalShares()); diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/RdInterestRateEffective.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/RdInterestRateEffective.java index 90c50ab02..6daf3c4c0 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/RdInterestRateEffective.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/RdInterestRateEffective.java @@ -21,6 +21,7 @@ import java.time.LocalDate; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Entity @Table(name = "m_rd_interest_rate_effective") @@ -66,7 +67,7 @@ public class RdInterestRateEffective { e.isActive = true; e.remarks = remarks; e.createdById = createdById; - e.createdDate = LocalDate.now(); + e.createdDate = DateUtils.getLocalDateOfTenant(); return e; } } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/RdInterestRateSlabHistory.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/RdInterestRateSlabHistory.java index 4bc6caeeb..9ba97e0f6 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/RdInterestRateSlabHistory.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/RdInterestRateSlabHistory.java @@ -21,6 +21,7 @@ import java.math.BigDecimal; import java.time.LocalDate; import lombok.Getter; import lombok.NoArgsConstructor; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Entity @Table(name = "m_rd_interest_rate_slab_history") @@ -66,7 +67,7 @@ public class RdInterestRateSlabHistory { s.fromPeriod = fromPeriod; s.toPeriod = toPeriod; s.annualInterestRate = annualInterestRate; - s.createdDate = LocalDate.now(); + s.createdDate = DateUtils.getLocalDateOfTenant(); return s; } } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java index a3d54240d..ad1434f36 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java @@ -932,6 +932,9 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom } private BigDecimal getEffectiveOverdraftInterestRateAsFraction(MathContext mc) { + if (this.nominalAnnualInterestRateOverdraft == null) { + return BigDecimal.ZERO; + } return this.nominalAnnualInterestRateOverdraft.divide(BigDecimal.valueOf(100L), mc); } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountFacilityConfig.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountFacilityConfig.java index bdf815d1d..dc3b0bfbd 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountFacilityConfig.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountFacilityConfig.java @@ -26,6 +26,7 @@ import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import java.time.LocalDateTime; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Entity @Table(name = "m_savings_account_facility_config") @@ -87,12 +88,12 @@ public class SavingsAccountFacilityConfig extends AbstractPersistableCustom { + + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_APPROVED = "APPROVED"; + public static final String STATUS_REJECTED = "REJECTED"; + + public static final String NOMINEE_OP_ADD = "ADD"; + public static final String NOMINEE_OP_UPDATE = "UPDATE"; + public static final String NOMINEE_OP_DELETE = "DELETE"; + + @ManyToOne + @JoinColumn(name = "savings_account_id", nullable = false) + private SavingsAccount savingsAccount; + + @ManyToOne + @JoinColumn(name = "createdby_id", nullable = false) + private AppUser maker; + + @ManyToOne + @JoinColumn(name = "lastmodifiedby_id") + private AppUser checker; + + @Column(name = "created_date", nullable = false) + private LocalDateTime createdOn; + + @Column(name = "lastmodified_date") + private LocalDateTime checkedOn; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @Column(name = "reject_reason", length = 500) + private String rejectReason; + + /** "SAVINGS_ACCOUNT_EDIT:" while PENDING, NULL otherwise - the real DB-level lock. */ + @Column(name = "pending_lock_key", length = 40) + private String pendingLockKey; + + /** Links to the audit-only m_portfolio_command_source row created for this edit's submit/approve/reject. */ + @Column(name = "command_source_id") + private Long commandSourceId; + + @Column(name = "hold_transaction_id") + private Long holdTransactionId; + + @Column(name = "hold_amount", precision = 19, scale = 6) + private BigDecimal holdAmount; + + @Column(name = "facility_config_submitted", nullable = false) + private boolean facilityConfigSubmitted = false; + + @Column(name = "atm_facility") + private Boolean atmFacility; + + @Column(name = "atm_withdrawal_facility") + private Boolean atmWithdrawalFacility; + + @Column(name = "upi_facility") + private Boolean upiFacility; + + @Column(name = "pos_transactions_facility") + private Boolean posTransactionsFacility; + + @Column(name = "cheque_book") + private Boolean chequeBook; + + @Column(name = "imps_facility") + private Boolean impsFacility; + + @Column(name = "sms_alerts") + private Boolean smsAlerts; + + @Column(name = "bbps_facility") + private Boolean bbpsFacility; + + @Column(name = "insurance_cover") + private Boolean insuranceCover; + + @Column(name = "bepg_facility") + private Boolean bepgFacility; + + @Column(name = "neft_facility") + private Boolean neftFacility; + + @Column(name = "rtgs_facility") + private Boolean rtgsFacility; + + @Column(name = "inter_bank_total_cap_facility") + private Boolean interBankTotalCapFacility; + + @Column(name = "withdrawal_facility") + private Boolean withdrawalFacility; + + @Column(name = "deposit_facility_kyc") + private Boolean depositFacilityKyc; + + @Column(name = "virtual_debit_card") + private Boolean virtualDebitCard; + + @Column(name = "nominee_submitted", nullable = false) + private boolean nomineeSubmitted = false; + + @Column(name = "nominee_operation", length = 10) + private String nomineeOperation; + + @Column(name = "nominee_id") + private Long nomineeId; + + @Column(name = "nominee_client_id") + private Long nomineeClientId; + + @Column(name = "relationship_cv_id") + private Long relationshipCvId; + + @Column(name = "percentage_share", precision = 5, scale = 2) + private BigDecimal percentageShare; + + @Column(name = "nominee_is_active") + private Boolean nomineeIsActive; + + @Column(name = "nominee_is_minor") + private Boolean nomineeIsMinor; + + @Column(name = "guardian_client_id") + private Long guardianClientId; + + @Column(name = "guardian_relationship_cv_id") + private Long guardianRelationshipCvId; + + @Column(name = "effective_date") + private LocalDate effectiveDate; + + @Column(name = "nominee_remarks", length = 500) + private String nomineeRemarks; + + protected SavingsAccountPendingEdit() {} + + public SavingsAccountPendingEdit(final SavingsAccount savingsAccount, final AppUser maker) { + this.savingsAccount = savingsAccount; + this.maker = maker; + this.createdOn = DateUtils.getLocalDateTimeOfTenant(); + this.status = STATUS_PENDING; + this.pendingLockKey = lockKeyFor(savingsAccount.getId()); + } + + public static String lockKeyFor(final Long savingsAccountId) { + return "SAVINGS_ACCOUNT_EDIT:" + savingsAccountId; + } + + public boolean isPending() { + return STATUS_PENDING.equals(this.status); + } + + public void markApproved(final AppUser checker) { + this.status = STATUS_APPROVED; + this.checker = checker; + this.checkedOn = DateUtils.getLocalDateTimeOfTenant(); + this.pendingLockKey = null; + } + + public void markRejected(final AppUser checker, final String reason) { + this.status = STATUS_REJECTED; + this.checker = checker; + this.checkedOn = DateUtils.getLocalDateTimeOfTenant(); + this.rejectReason = reason; + this.pendingLockKey = null; + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountPendingEditRepository.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountPendingEditRepository.java new file mode 100644 index 000000000..d6e509421 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountPendingEditRepository.java @@ -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.savings.domain; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface SavingsAccountPendingEditRepository extends JpaRepository { + + Optional findBySavingsAccountIdAndStatus(Long savingsAccountId, String status); + + List findByStatus(String status); +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java index 8bd5eb247..1f21f0a30 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java @@ -377,6 +377,7 @@ public class SavingsProduct extends AbstractPersistableCustom { this.description = null; } + @SuppressWarnings("ChainingConstructorIgnoresParameter") protected SavingsProduct(final String name, final String shortName, final String description, final MonetaryCurrency currency, final BigDecimal interestRate, final SavingsCompoundingInterestPeriodType interestCompoundingPeriodType, final SavingsPostingInterestPeriodType interestPostingPeriodType, final SavingsInterestCalculationType interestCalculationType, diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductDepositLimitKyc.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductDepositLimitKyc.java index 330774515..6c7483102 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductDepositLimitKyc.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductDepositLimitKyc.java @@ -26,6 +26,7 @@ import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import java.time.LocalDateTime; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.infrastructure.core.service.DateUtils; @Entity @Table(name = "m_savings_product_deposit_limit_kyc") @@ -57,12 +58,12 @@ public class SavingsProductDepositLimitKyc extends AbstractPersistableCustom private LocalDateTime createdDate; protected SavingsProductFacilityLimit() { - this.createdDate = LocalDateTime.now(); } public SavingsProductFacilityLimit(SavingsProduct savingsProduct, String limitType, @@ -62,6 +62,7 @@ public class SavingsProductFacilityLimit extends AbstractPersistableCustom this.generalLimit = generalLimit; this.minorAccountLimit = minorAccountLimit; this.smallSavingLimit = smallSavingLimit; + this.createdDate = DateUtils.getLocalDateTimeOfTenant(); } public static SavingsProductFacilityLimit create(SavingsProduct savingsProduct, String limitType, diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountEditInsufficientBalanceException.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountEditInsufficientBalanceException.java new file mode 100644 index 000000000..6e3c052c2 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountEditInsufficientBalanceException.java @@ -0,0 +1,37 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.exception; + +import java.math.BigDecimal; +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +/** + * Thrown at edit-submission time when the account's available balance cannot cover the charge for + * the digital/account feature(s) the maker is requesting to enable. + */ +public class SavingsAccountEditInsufficientBalanceException extends AbstractPlatformDomainRuleException { + + public SavingsAccountEditInsufficientBalanceException(final Long savingsId, final BigDecimal availableBalance, + final BigDecimal requiredAmount) { + super("error.msg.savingsaccount.edit.insufficient.balance", + "Account balance is insufficient for the requested feature(s). Available balance: " + availableBalance + + ", required: " + requiredAmount, + savingsId, availableBalance, requiredAmount); + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountEditPendingApprovalException.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountEditPendingApprovalException.java new file mode 100644 index 000000000..c672f3479 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountEditPendingApprovalException.java @@ -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.savings.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class SavingsAccountEditPendingApprovalException extends AbstractPlatformDomainRuleException { + + public SavingsAccountEditPendingApprovalException(final Long savingsId) { + super("error.msg.savingsaccount.edit.pending.approval", + "Savings account with identifier " + savingsId + " already has a Nominee/Digital-Feature edit pending approval", + savingsId); + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountEditSelfApprovalNotAllowedException.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountEditSelfApprovalNotAllowedException.java new file mode 100644 index 000000000..ff68905d8 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountEditSelfApprovalNotAllowedException.java @@ -0,0 +1,31 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class SavingsAccountEditSelfApprovalNotAllowedException extends AbstractPlatformDomainRuleException { + + public SavingsAccountEditSelfApprovalNotAllowedException(final Long savingsId) { + super("error.msg.savingsaccount.edit.self.approval.not.allowed", + "The maker of the pending Nominee/Digital-Feature edit for savings account " + savingsId + + " cannot also approve or reject it", + savingsId); + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountNoPendingEditException.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountNoPendingEditException.java new file mode 100644 index 000000000..7350eb382 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountNoPendingEditException.java @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class SavingsAccountNoPendingEditException extends AbstractPlatformDomainRuleException { + + public SavingsAccountNoPendingEditException(final Long savingsId) { + super("error.msg.savingsaccount.no.pending.edit", + "Savings account with identifier " + savingsId + " has no Nominee/Digital-Feature edit awaiting approval", savingsId); + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeAutoApplyService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeAutoApplyService.java index 4488167b9..efb371213 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeAutoApplyService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeAutoApplyService.java @@ -1,243 +1,291 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.fineract.portfolio.savings.service; - -import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.MonthDay; -import java.util.ArrayList; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.fineract.organisation.monetary.domain.Money; -import org.apache.fineract.portfolio.charge.domain.Charge; -import org.apache.fineract.portfolio.savings.domain.FacilityChargeMapping; -import org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository; -import org.apache.fineract.portfolio.savings.domain.SavingsAccount; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountCharge; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountChargeRepository; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountFacilityConfig; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository; -import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityConfig; -import org.springframework.stereotype.Service; - -/** - * Reads account's facility config (Yes/No per facility) and auto-applies - * the corresponding charge from m_facility_charge_mapping. - * No manual charge selection needed — fully automatic. - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class FacilityChargeAutoApplyService { - - private final FacilityChargeMappingRepository mappingRepository; - private final SavingsAccountChargeRepository savingsAccountChargeRepository; - private final SavingsAccountTransactionRepository savingsAccountTransactionRepository; - - public void applyFacilityChargesOnActivation(final SavingsAccount account) { - // Use account-level facility config — this reflects what was selected at account opening time - final SavingsAccountFacilityConfig config = account.getFacilityConfig(); - - if (config == null) { - // Fallback: use product-level config if account config not set - applyFromProductConfig(account); - return; - } - - final List chargesToApply = new ArrayList<>(); - - applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmFacility()), "ATM_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmWithdrawalFacility()), "ATM_WITHDRAWAL", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getUpiFacility()), "UPI_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getPosTransactionsFacility()), "POS_TRANSACTIONS", chargesToApply); - // Cheque book: no longer charged at activation — the per-leaf issuance charge is now deducted - // when a cheque book is actually issued (ChequeBookIssuanceChargeService), not just for having - // the facility enabled. - applyIfEnabled(account, Boolean.TRUE.equals(config.getImpsFacility()), "IMPS_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getSmsAlerts()), "SMS_ALERTS", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getBbpsFacility()), "BBPS_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getInsuranceCover()), "INSURANCE_COVER", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getBepgFacility()), "BEPG_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getWithdrawalFacility()), "WITHDRAWAL_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getDepositFacilityKyc()), "DEPOSIT_FACILITY_KYC",chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getVirtualDebitCard()), "VIRTUAL_DEBIT_CARD", chargesToApply); - - if (!chargesToApply.isEmpty()) { - savingsAccountChargeRepository.saveAll(chargesToApply); - log.info("Auto-applied {} facility charge(s) to savings account id={}", - chargesToApply.size(), account.getId()); - } - } - - /** - * Collects all unpaid SavingsActivation charges on the account. - * Called after facility charges are saved and flushed, so amounts are properly initialized. - */ - public List collectActivationCharges(final SavingsAccount account) { - final List transactions = new ArrayList<>(); - final LocalDate activationDate = account.getActivationDate(); - - for (final SavingsAccountCharge charge : account.charges()) { - if (!charge.isSavingsActivation()) continue; - if (charge.isPaid() || charge.isWaived()) continue; - - final BigDecimal amount = charge.getAmount(account.getCurrency()).getAmount(); - if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) continue; - - try { - final org.apache.fineract.organisation.monetary.domain.Money amountMoney = - org.apache.fineract.organisation.monetary.domain.Money.of(account.getCurrency(), amount); - final SavingsAccountTransaction txn = account.payCharge(charge, amountMoney, activationDate, false, null); - if (txn != null) { - transactions.add(txn); - log.info("Collected activation charge '{}' ₹{} from savings account id={}", - charge.getCharge().getName(), amount, account.getId()); - } - } catch (Exception e) { - log.warn("Could not collect activation charge '{}' from savings account id={}: {}", - charge.getCharge() != null ? charge.getCharge().getName() : "?", account.getId(), e.getMessage()); - } - } - - if (!transactions.isEmpty()) { - savingsAccountTransactionRepository.saveAll(transactions); - account.recalculateBalancesAfterFacilityCharges(activationDate); - } - return transactions; - } - - private void autoCollectSavingsActivationCharges(final SavingsAccount account, - final List charges) { - final LocalDate activationDate = account.getActivationDate(); - final List transactions = new ArrayList<>(); - - for (final SavingsAccountCharge charge : charges) { - if (!charge.isSavingsActivation()) continue; - final BigDecimal amount = charge.getAmountOutstanding(account.getCurrency()).getAmount(); - if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) continue; - - try { - final Money amountMoney = Money.of(account.getCurrency(), amount); - final SavingsAccountTransaction txn = account.payCharge(charge, amountMoney, activationDate, false, null); - if (txn != null) transactions.add(txn); - log.info("Auto-collected facility charge '{}' ₹{} from account id={}", - charge.getCharge().getName(), amount, account.getId()); - } catch (Exception e) { - log.warn("Could not auto-collect facility charge '{}' from account id={}: {}", - charge.getCharge().getName(), account.getId(), e.getMessage()); - } - } - - if (!transactions.isEmpty()) { - savingsAccountTransactionRepository.saveAll(transactions); - account.recalculateBalancesAfterFacilityCharges(activationDate); - } - } - - private void applyIfEnabled(final SavingsAccount account, final boolean facilityEnabled, - final String facilityType, final List collector) { - - if (!facilityEnabled) return; - - // Skip if this charge is already applied to the account - boolean alreadyApplied = account.charges().stream() - .anyMatch(c -> c.getCharge() != null - && c.getCharge().getName() != null - && c.getCharge().getName().equals(getChargeName(facilityType))); - if (alreadyApplied) return; - - mappingRepository.findByFacilityType(facilityType).ifPresent(mapping -> { - final Charge charge = mapping.getCharge(); - if (charge == null) return; - - final MonthDay feeOnMonthDay = resolveFeeOnMonthDay(charge); - final SavingsAccountCharge accountCharge = SavingsAccountCharge.createNewWithoutSavingsAccount( - charge, - charge.getAmount(), - null, // chargeTime derived from Charge definition - null, // chargeCalculation derived from Charge definition - resolveDueDate(charge), - true, - feeOnMonthDay, - charge.getFeeInterval()); - - accountCharge.update(account); - account.charges().add(accountCharge); - collector.add(accountCharge); - }); - } - - private MonthDay resolveFeeOnMonthDay(final Charge charge) { - if (charge.getFeeOnMonthDay() != null) return charge.getFeeOnMonthDay(); - if (charge.isMonthlyFee() || charge.isAnnualFee()) { - // Default: 1st of the month - return MonthDay.of(LocalDate.now().plusMonths(1).getMonth(), 1); - } - return null; - } - - private LocalDate resolveDueDate(final Charge charge) { - if (charge.isMonthlyFee()) { - return LocalDate.now().plusMonths(1).withDayOfMonth(1); - } else if (charge.isAnnualFee()) { - return LocalDate.now().plusYears(1); - } - return null; - } - - private String getChargeName(final String facilityType) { - return mappingRepository.findByFacilityType(facilityType) - .map(FacilityChargeMapping::getChargeName) - .orElse(""); - } - - private void applyFromProductConfig(final SavingsAccount account) { - final SavingsProductFacilityConfig config = account.savingsProduct() != null - ? account.savingsProduct().getFacilityConfig() : null; - if (config == null) return; - - final List chargesToApply = new ArrayList<>(); - applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmFacility()), "ATM_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmWithdrawalFacility()), "ATM_WITHDRAWAL", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getUpiFacility()), "UPI_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getPosTransactionsFacility()), "POS_TRANSACTIONS", chargesToApply); - // Cheque book: no longer charged at activation — the per-leaf issuance charge is now deducted - // when a cheque book is actually issued (ChequeBookIssuanceChargeService), not just for having - // the facility enabled. - applyIfEnabled(account, Boolean.TRUE.equals(config.getImpsFacility()), "IMPS_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getSmsAlerts()), "SMS_ALERTS", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getBbpsFacility()), "BBPS_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getInsuranceCover()), "INSURANCE_COVER", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getBepgFacility()), "BEPG_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getWithdrawalFacility()), "WITHDRAWAL_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getDepositFacilityKyc()), "DEPOSIT_FACILITY_KYC",chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getVirtualDebitCard()), "VIRTUAL_DEBIT_CARD", chargesToApply); - if (!chargesToApply.isEmpty()) { - savingsAccountChargeRepository.saveAll(chargesToApply); - } - } -} +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.MonthDay; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.organisation.monetary.domain.Money; +import org.apache.fineract.portfolio.charge.domain.Charge; +import org.apache.fineract.portfolio.savings.domain.FacilityChargeMapping; +import org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository; +import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountCharge; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountChargeRepository; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountFacilityConfig; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository; +import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityConfig; +import org.springframework.stereotype.Service; + +/** + * Reads account's facility config (Yes/No per facility) and auto-applies + * the corresponding charge from m_facility_charge_mapping. + * No manual charge selection needed — fully automatic. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class FacilityChargeAutoApplyService { + + private final FacilityChargeMappingRepository mappingRepository; + private final SavingsAccountChargeRepository savingsAccountChargeRepository; + private final SavingsAccountTransactionRepository savingsAccountTransactionRepository; + + public void applyFacilityChargesOnActivation(final SavingsAccount account) { + // Use account-level facility config — this reflects what was selected at account opening time + final SavingsAccountFacilityConfig config = account.getFacilityConfig(); + + if (config == null) { + // Fallback: use product-level config if account config not set + applyFromProductConfig(account); + return; + } + + final List chargesToApply = new ArrayList<>(); + + applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmFacility()), "ATM_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmWithdrawalFacility()), "ATM_WITHDRAWAL", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getUpiFacility()), "UPI_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getPosTransactionsFacility()), "POS_TRANSACTIONS", chargesToApply); + // Cheque book: no longer charged at activation — the per-leaf issuance charge is now deducted + // when a cheque book is actually issued (ChequeBookIssuanceChargeService), not just for having + // the facility enabled. + applyIfEnabled(account, Boolean.TRUE.equals(config.getImpsFacility()), "IMPS_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getSmsAlerts()), "SMS_ALERTS", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getBbpsFacility()), "BBPS_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getInsuranceCover()), "INSURANCE_COVER", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getBepgFacility()), "BEPG_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getWithdrawalFacility()), "WITHDRAWAL_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getDepositFacilityKyc()), "DEPOSIT_FACILITY_KYC",chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getVirtualDebitCard()), "VIRTUAL_DEBIT_CARD", chargesToApply); + + if (!chargesToApply.isEmpty()) { + savingsAccountChargeRepository.saveAll(chargesToApply); + log.info("Auto-applied {} facility charge(s) to savings account id={}", + chargesToApply.size(), account.getId()); + } + } + + /** + * Sum of the mapped charge amounts for the given facility types - used to size the hold placed at + * edit-submission time (see SavingsAccountEditWritePlatformServiceImpl) before any charge is actually applied. + */ + public BigDecimal totalChargeAmountFor(final java.util.Set facilityTypes) { + BigDecimal total = BigDecimal.ZERO; + for (final String facilityType : facilityTypes) { + final java.util.Optional mapping = mappingRepository.findByFacilityType(facilityType); + if (mapping.isPresent() && mapping.get().getCharge() != null) { + total = total.add(mapping.get().getCharge().getAmount()); + } + } + return total; + } + + /** + * Adds a fresh {@link SavingsAccountCharge} for each of the given (newly re-enabled) facility types, so approving + * a Nominee/Digital-Feature edit always charges again for the transition - unlike + * {@link #applyFacilityChargesOnActivation(SavingsAccount)}'s {@code applyIfEnabled}, this deliberately has NO + * "already applied" dedup-by-name guard: a facility that was enabled at activation, disabled, then re-enabled via + * an approved edit must be charged again, even though a (now-paid, now-irrelevant) charge object with the same + * name already sits in {@code account.charges()} from the earlier cycle. + */ + public List applyChargesForFacilities(final SavingsAccount account, final java.util.Set facilityTypes) { + final List chargesToApply = new ArrayList<>(); + for (final String facilityType : facilityTypes) { + mappingRepository.findByFacilityType(facilityType).ifPresent(mapping -> { + final Charge charge = mapping.getCharge(); + if (charge == null) { + return; + } + final MonthDay feeOnMonthDay = resolveFeeOnMonthDay(charge); + final SavingsAccountCharge accountCharge = SavingsAccountCharge.createNewWithoutSavingsAccount(charge, charge.getAmount(), + null, null, resolveDueDate(charge), true, feeOnMonthDay, charge.getFeeInterval()); + accountCharge.update(account); + account.charges().add(accountCharge); + chargesToApply.add(accountCharge); + }); + } + if (!chargesToApply.isEmpty()) { + savingsAccountChargeRepository.saveAll(chargesToApply); + } + return chargesToApply; + } + + /** + * Pays exactly the given charges (typically the just-added result of {@link #applyChargesForFacilities}) as of + * {@code transactionDate}. Unlike {@link #collectActivationCharges(SavingsAccount)} - which posts at the + * account's activation date and sweeps every unpaid activation-type charge - this is scoped to a specific set of + * charges and a caller-supplied date, for posting a Nominee/Digital-Feature edit's charges on the day it's + * approved rather than backdating them to activation. + */ + public List collectCharges(final SavingsAccount account, final List charges, + final LocalDate transactionDate) { + final List transactions = new ArrayList<>(); + for (final SavingsAccountCharge charge : charges) { + if (charge.isPaid() || charge.isWaived()) continue; + final BigDecimal amount = charge.getAmountOutstanding(account.getCurrency()).getAmount(); + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) continue; + + final Money amountMoney = Money.of(account.getCurrency(), amount); + final SavingsAccountTransaction txn = account.payCharge(charge, amountMoney, transactionDate, false, null); + if (txn != null) { + transactions.add(txn); + log.info("Collected facility charge '{}' ₹{} from savings account id={}", charge.getCharge().getName(), amount, + account.getId()); + } + } + if (!transactions.isEmpty()) { + savingsAccountTransactionRepository.saveAll(transactions); + account.recalculateBalancesAfterFacilityCharges(transactionDate); + } + return transactions; + } + + /** + * Collects all unpaid SavingsActivation charges on the account. + * Called after facility charges are saved and flushed, so amounts are properly initialized. + */ + public List collectActivationCharges(final SavingsAccount account) { + final List transactions = new ArrayList<>(); + final LocalDate activationDate = account.getActivationDate(); + + for (final SavingsAccountCharge charge : account.charges()) { + if (!charge.isSavingsActivation()) continue; + if (charge.isPaid() || charge.isWaived()) continue; + + final BigDecimal amount = charge.getAmount(account.getCurrency()).getAmount(); + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) continue; + + try { + final org.apache.fineract.organisation.monetary.domain.Money amountMoney = + org.apache.fineract.organisation.monetary.domain.Money.of(account.getCurrency(), amount); + final SavingsAccountTransaction txn = account.payCharge(charge, amountMoney, activationDate, false, null); + if (txn != null) { + transactions.add(txn); + log.info("Collected activation charge '{}' ₹{} from savings account id={}", + charge.getCharge().getName(), amount, account.getId()); + } + } catch (Exception e) { + log.warn("Could not collect activation charge '{}' from savings account id={}: {}", + charge.getCharge() != null ? charge.getCharge().getName() : "?", account.getId(), e.getMessage()); + } + } + + if (!transactions.isEmpty()) { + savingsAccountTransactionRepository.saveAll(transactions); + account.recalculateBalancesAfterFacilityCharges(activationDate); + } + return transactions; + } + + private void applyIfEnabled(final SavingsAccount account, final boolean facilityEnabled, + final String facilityType, final List collector) { + + if (!facilityEnabled) return; + + // Skip if this charge is already applied to the account + boolean alreadyApplied = account.charges().stream() + .anyMatch(c -> c.getCharge() != null + && c.getCharge().getName() != null + && c.getCharge().getName().equals(getChargeName(facilityType))); + if (alreadyApplied) return; + + mappingRepository.findByFacilityType(facilityType).ifPresent(mapping -> { + final Charge charge = mapping.getCharge(); + if (charge == null) return; + + final MonthDay feeOnMonthDay = resolveFeeOnMonthDay(charge); + final SavingsAccountCharge accountCharge = SavingsAccountCharge.createNewWithoutSavingsAccount( + charge, + charge.getAmount(), + null, // chargeTime derived from Charge definition + null, // chargeCalculation derived from Charge definition + resolveDueDate(charge), + true, + feeOnMonthDay, + charge.getFeeInterval()); + + accountCharge.update(account); + account.charges().add(accountCharge); + collector.add(accountCharge); + }); + } + + private MonthDay resolveFeeOnMonthDay(final Charge charge) { + if (charge.getFeeOnMonthDay() != null) return charge.getFeeOnMonthDay(); + if (charge.isMonthlyFee() || charge.isAnnualFee()) { + // Default: 1st of the month + return MonthDay.of(DateUtils.getBusinessLocalDate().plusMonths(1).getMonth(), 1); + } + return null; + } + + private LocalDate resolveDueDate(final Charge charge) { + if (charge.isMonthlyFee()) { + return DateUtils.getBusinessLocalDate().plusMonths(1).withDayOfMonth(1); + } else if (charge.isAnnualFee()) { + return DateUtils.getBusinessLocalDate().plusYears(1); + } + return null; + } + + private String getChargeName(final String facilityType) { + return mappingRepository.findByFacilityType(facilityType) + .map(FacilityChargeMapping::getChargeName) + .orElse(""); + } + + private void applyFromProductConfig(final SavingsAccount account) { + final SavingsProductFacilityConfig config = account.savingsProduct() != null + ? account.savingsProduct().getFacilityConfig() : null; + if (config == null) return; + + final List chargesToApply = new ArrayList<>(); + applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmFacility()), "ATM_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmWithdrawalFacility()), "ATM_WITHDRAWAL", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getUpiFacility()), "UPI_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getPosTransactionsFacility()), "POS_TRANSACTIONS", chargesToApply); + // Cheque book: no longer charged at activation — the per-leaf issuance charge is now deducted + // when a cheque book is actually issued (ChequeBookIssuanceChargeService), not just for having + // the facility enabled. + applyIfEnabled(account, Boolean.TRUE.equals(config.getImpsFacility()), "IMPS_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getSmsAlerts()), "SMS_ALERTS", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getBbpsFacility()), "BBPS_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getInsuranceCover()), "INSURANCE_COVER", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getBepgFacility()), "BEPG_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getWithdrawalFacility()), "WITHDRAWAL_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getDepositFacilityKyc()), "DEPOSIT_FACILITY_KYC",chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getVirtualDebitCard()), "VIRTUAL_DEBIT_CARD", chargesToApply); + if (!chargesToApply.isEmpty()) { + savingsAccountChargeRepository.saveAll(chargesToApply); + } + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeSeederService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeSeederService.java index f7fbbdb66..92c8c30e6 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeSeederService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeSeederService.java @@ -26,7 +26,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; /** diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformService.java new file mode 100644 index 000000000..e92d97ae2 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformService.java @@ -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.savings.service; + +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.savings.data.SavingsAccountPendingEditData; + +public interface SavingsAccountEditWritePlatformService { + + CommandProcessingResult submitEditForApproval(Long savingsId, JsonCommand command); + + CommandProcessingResult approveEdit(Long savingsId); + + CommandProcessingResult rejectEdit(Long savingsId, String rejectReason); + + SavingsAccountPendingEditData retrievePendingEdit(Long savingsId); + + java.util.Collection retrieveAllPendingEdits(); +} diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml index c717feeaa..ec16ad31d 100644 --- a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml @@ -51,4 +51,6 @@ + + diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2030_add_savings_account_pending_edit_table.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2030_add_savings_account_pending_edit_table.xml new file mode 100644 index 000000000..4d29a1778 --- /dev/null +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2030_add_savings_account_pending_edit_table.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2031_add_savings_account_edit_approval_permissions.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2031_add_savings_account_edit_approval_permissions.xml new file mode 100644 index 000000000..b867caad7 --- /dev/null +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2031_add_savings_account_edit_approval_permissions.xml @@ -0,0 +1,84 @@ + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'SUBMITEDIT_SAVINGSACCOUNT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVEEDIT_SAVINGSACCOUNT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'REJECTEDIT_SAVINGSACCOUNT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVEEDIT_SAVINGSACCOUNT_CHECKER' + + + + + + + + + + diff --git a/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/otp/util/OtpDestinationMasker.java b/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/otp/util/OtpDestinationMasker.java new file mode 100644 index 000000000..42b6eb800 --- /dev/null +++ b/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/otp/util/OtpDestinationMasker.java @@ -0,0 +1,53 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.security.otp.util; + +/** + * Shared masking logic for OTP delivery destinations (mobile number / email address), so the destination can be + * shown to a user for confirmation ("OTP sent to ******1234") without exposing the full value. + * + *

+ * Extracted, verbatim, from the private {@code mask()} methods that previously lived independently in + * {@code LoginOtpServiceImpl} (SMS) and {@code EmailLoginOtpServiceImpl} (Email) — this is a pure refactor with no + * behaviour change; both services now delegate here instead of maintaining their own copy. + */ +public final class OtpDestinationMasker { + + private OtpDestinationMasker() {} + + public static String maskMobile(final String mobile) { + if (mobile == null || mobile.length() < 4) { + return "****"; + } + return "******" + mobile.substring(mobile.length() - 4); + } + + public static String maskEmail(final String email) { + if (email == null || !email.contains("@")) { + return "****"; + } + final int at = email.indexOf('@'); + final String local = email.substring(0, at); + final String domain = email.substring(at); + if (local.length() <= 2) { + return "*" + local.substring(local.length() - 1) + domain; + } + return local.charAt(0) + "***" + local.charAt(local.length() - 1) + domain; + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..c757327a7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "cbsnexora-backend", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}