diff --git a/buildSrc/src/main/groovy/org.apache.fineract.dependencies.gradle b/buildSrc/src/main/groovy/org.apache.fineract.dependencies.gradle index 918a84e32f4..69e31442ef4 100644 --- a/buildSrc/src/main/groovy/org.apache.fineract.dependencies.gradle +++ b/buildSrc/src/main/groovy/org.apache.fineract.dependencies.gradle @@ -30,7 +30,7 @@ ext['httpclient5.version'] = '5.6.4' // https://github.com/spring-gradle-plugins/dependency-management-plugin dependencyManagement { imports { - mavenBom 'com.squareup.okhttp3:okhttp-bom:4.12.0' + mavenBom 'com.squareup.okhttp3:okhttp-bom:5.5.0' mavenBom 'org.slf4j:slf4j-bom:2.0.17' mavenBom 'io.micrometer:micrometer-bom:1.16.5' mavenBom 'org.springframework.boot:spring-boot-dependencies:4.1.0' diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java index 72f20f257b9..03bbf3bb4b5 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java @@ -439,6 +439,7 @@ public static class FineractJobProperties { private boolean loanCobEnabled; private FineractJournalEntryAggregationProperties journalEntryAggregation; private int retainedEarningChunkSize; + private boolean executeMissedRuns; } @Getter diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/DataValidatorBuilder.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/DataValidatorBuilder.java index e647f1b63d8..ddbbb8c3021 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/DataValidatorBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/DataValidatorBuilder.java @@ -131,6 +131,13 @@ public void failWithCode(final String errorCode, final Object... defaultUserMess this.dataValidationErrors.add(error); } + public void failWithCodeAndMessage(final String errorCode, final String defaultEnglishMessage, final Object... defaultUserMessageArgs) { + String validationErrorCode = "validation.msg." + this.resource + "." + this.parameter + "." + errorCode; + final ApiParameterError error = ApiParameterError.parameterError(validationErrorCode, defaultEnglishMessage, this.parameter, + this.value, defaultUserMessageArgs); + this.dataValidationErrors.add(error); + } + public void failWithCodeNoParameterAddedToErrorCode(final String errorCode, final Object... defaultUserMessageArgs) { String validationErrorCode = "validation.msg." + this.resource + "." + errorCode; String defaultEnglishMessage = "Failed data validation due to: " + errorCode + "."; diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/serialization/DatatableCommandFromApiJsonDeserializer.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/serialization/DatatableCommandFromApiJsonDeserializer.java index 51e74d6e2c8..ff26fb449a6 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/serialization/DatatableCommandFromApiJsonDeserializer.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/serialization/DatatableCommandFromApiJsonDeserializer.java @@ -315,7 +315,9 @@ private void validateType(DataValidatorBuilder validator, final JsonElement colu validator = validator.reset().parameter(API_FIELD_LENGTH).value(length); if (jdbcType.hasPrecision(databaseTypeResolver.databaseType())) { if (jdbcType.isStringType() && length == null) { - validator.failWithCode("must.be.provided.when.type.is.String"); + final String columnName = this.fromApiJsonHelper.extractStringNamed(API_FIELD_NAME, column); + validator.failWithCodeAndMessage("must.be.provided.when.type.is.String", + "Column length is required for String columns (" + columnName + ").", columnName); } validator.ignoreIfNull().positiveAmount(); } // else, the precision is ignored diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/dataqueries/data/EntityTables.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/dataqueries/data/EntityTables.java index 87219bf2237..e465a5d90ed 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/dataqueries/data/EntityTables.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/dataqueries/data/EntityTables.java @@ -56,6 +56,9 @@ public enum EntityTables { private static final List ENTITY_NAMES = Arrays.stream(ENTITY_VALUES).map(EntityTables::getName).toList(); + private static final List CHECKABLE_ENTITY_NAMES = Arrays.stream(ENTITY_VALUES).filter(EntityTables::hasCheck) + .map(EntityTables::getName).toList(); + private static final Map BY_ENTITY_NAME = Arrays.stream(ENTITY_VALUES) .collect(Collectors.toMap(EntityTables::getName, e -> e)); @@ -125,6 +128,10 @@ public static List getEntityNames() { return ENTITY_NAMES; } + public static List getCheckableEntityNames() { + return CHECKABLE_ENTITY_NAMES; + } + public static EntityTables fromEntityName(String name) { return name == null ? null : BY_ENTITY_NAME.get(name.toLowerCase()); } diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/dataqueries/exception/DatatableEntryRequiredException.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/dataqueries/exception/DatatableEntryRequiredException.java index 5f78ead74d4..d9710ac177c 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/dataqueries/exception/DatatableEntryRequiredException.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/dataqueries/exception/DatatableEntryRequiredException.java @@ -18,7 +18,11 @@ */ package org.apache.fineract.infrastructure.dataqueries.exception; +import java.util.List; +import java.util.Locale; import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; +import org.apache.fineract.infrastructure.dataqueries.data.EntityTables; +import org.apache.fineract.infrastructure.dataqueries.data.StatusEnum; /** * A {@link AbstractPlatformDomainRuleException} thrown when datatable resources are not found. @@ -30,6 +34,36 @@ public DatatableEntryRequiredException(String datatableName) { "The datatable " + datatableName + " needs to be filled in before the current action can be proceeded", datatableName); } + public DatatableEntryRequiredException(List datatableNames, String entityName, Integer status) { + super("error.msg.entry.required.in.datatable." + datatableNames, + buildRequiredEntryMessage(datatableNames, EntityTables.fromEntityName(entityName), StatusEnum.fromInt(status)), + datatableNames.toString()); + } + + private static String buildRequiredEntryMessage(List datatableNames, EntityTables entity, StatusEnum status) { + final String tables = (datatableNames.size() == 1 ? "data table " : "data tables ") + datatableNames; + if (status == null || entity == null) { + return "Please fill in the " + tables + " before continuing."; + } + return "Please fill in the " + tables + " before " + actionOf(status) + " this " + + entity.getHumanReadableName().toLowerCase(Locale.ROOT) + "."; + } + + private static String actionOf(StatusEnum status) { + return switch (status) { + case CREATE -> "creating"; + case APPROVE -> "approving"; + case ACTIVATE -> "activating"; + case WITHDRAWN -> "withdrawing"; + case REJECTED -> "rejecting"; + case CLOSE -> "closing"; + case WRITE_OFF -> "writing off"; + case RESCHEDULE -> "rescheduling"; + case OVERPAY -> "overpaying"; + case DISBURSE -> "disbursing"; + }; + } + public DatatableEntryRequiredException(String datatableName, Long appTableId) { super("error.msg.entry.cannot.be.deleted.datatable." + datatableName + ".attached.to.entity.datatable.check", "The entry cannot be deleted, due to datatable " + datatableName + " is attached to an Entity-Datatable check", diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/tax/data/TaxGroupData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/tax/data/TaxGroupData.java index d99926f644c..067862216a2 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/tax/data/TaxGroupData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/tax/data/TaxGroupData.java @@ -39,25 +39,37 @@ public final class TaxGroupData implements Serializable { // System business date, exposed so clients can validate a new tax component's start date client-side private final LocalDate businessDate; + // Edit control flag: false if the tax group is linked to a charge, savings product or savings account + @SuppressWarnings("unused") + private final Boolean groupEditable; + public static TaxGroupData lookup(final Long id, final String name) { final Collection taxComponents = null; final Collection taxAssociations = null; - return new TaxGroupData(id, name, taxAssociations, taxComponents, null); + return new TaxGroupData(id, name, taxAssociations, taxComponents, null, null); } public static TaxGroupData template(final Collection taxComponents) { final Long id = null; final String name = null; final Collection taxAssociations = null; - return new TaxGroupData(id, name, taxAssociations, taxComponents, null); + return new TaxGroupData(id, name, taxAssociations, taxComponents, null, null); } public static TaxGroupData template(final TaxGroupData taxGroupData, final Collection taxComponents) { - return new TaxGroupData(taxGroupData.id, taxGroupData.name, taxGroupData.taxAssociations, taxComponents, taxGroupData.businessDate); + return new TaxGroupData(taxGroupData.id, taxGroupData.name, taxGroupData.taxAssociations, taxComponents, taxGroupData.businessDate, + taxGroupData.groupEditable); + } + + public static TaxGroupData template(final TaxGroupData taxGroupData, final Collection taxComponents, + final Boolean groupEditable) { + return new TaxGroupData(taxGroupData.id, taxGroupData.name, taxGroupData.taxAssociations, taxComponents, taxGroupData.businessDate, + groupEditable); } public static TaxGroupData withBusinessDate(final TaxGroupData taxGroupData, final LocalDate businessDate) { - return new TaxGroupData(taxGroupData.id, taxGroupData.name, taxGroupData.taxAssociations, taxGroupData.taxComponents, businessDate); + return new TaxGroupData(taxGroupData.id, taxGroupData.name, taxGroupData.taxAssociations, taxGroupData.taxComponents, businessDate, + taxGroupData.groupEditable); } } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/tax/data/TaxGroupMappingsData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/tax/data/TaxGroupMappingsData.java index 643a89eebbf..51ea55697ed 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/tax/data/TaxGroupMappingsData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/tax/data/TaxGroupMappingsData.java @@ -38,6 +38,9 @@ public class TaxGroupMappingsData implements Serializable { private final LocalDate startDate; @SuppressWarnings("unused") private final LocalDate endDate; + // Edit control flag: true if the group is not linked and this component's start date is still in the future. + @SuppressWarnings("unused") + private Boolean componentEditable; public boolean occursOnDayFromAndUpToAndIncluding(final LocalDate target) { return DateUtils.isAfter(target, startDate()) && (endDate == null || !DateUtils.isAfter(target, endDate())); diff --git a/fineract-core/src/test/java/org/apache/fineract/infrastructure/core/serialization/DatatableCommandFromApiJsonDeserializerTest.java b/fineract-core/src/test/java/org/apache/fineract/infrastructure/core/serialization/DatatableCommandFromApiJsonDeserializerTest.java new file mode 100644 index 00000000000..164f497b46a --- /dev/null +++ b/fineract-core/src/test/java/org/apache/fineract/infrastructure/core/serialization/DatatableCommandFromApiJsonDeserializerTest.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.infrastructure.core.serialization; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.service.database.DatabaseType; +import org.apache.fineract.infrastructure.core.service.database.DatabaseTypeResolver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class DatatableCommandFromApiJsonDeserializerTest { + + private DatatableCommandFromApiJsonDeserializer underTest; + + @BeforeEach + void setUp() { + DatabaseTypeResolver databaseTypeResolver = mock(DatabaseTypeResolver.class); + when(databaseTypeResolver.databaseType()).thenReturn(DatabaseType.POSTGRESQL); + underTest = new DatatableCommandFromApiJsonDeserializer(new FromJsonHelper(), databaseTypeResolver); + } + + @Test + void stringColumnWithoutLengthNamesTheColumnInAReadableMessage() { + String json = "{\"datatableName\":\"dt_loan_note\",\"apptableName\":\"m_loan\",\"multiRow\":false," + + "\"columns\":[{\"name\":\"note\",\"type\":\"String\",\"mandatory\":true}]}"; + + PlatformApiDataValidationException exception = assertThrows(PlatformApiDataValidationException.class, + () -> underTest.validateForCreate(json)); + + List errors = exception.getErrors(); + assertEquals(1, errors.size()); + ApiParameterError error = errors.get(0); + assertEquals("length", error.getParameterName()); + assertEquals("Column length is required for String columns (note).", error.getDefaultUserMessage()); + assertEquals("validation.msg.datatable.length.must.be.provided.when.type.is.String", error.getUserMessageGlobalisationCode()); + } +} diff --git a/fineract-core/src/test/java/org/apache/fineract/infrastructure/dataqueries/exception/DatatableEntryRequiredExceptionTest.java b/fineract-core/src/test/java/org/apache/fineract/infrastructure/dataqueries/exception/DatatableEntryRequiredExceptionTest.java new file mode 100644 index 00000000000..bf3676f0515 --- /dev/null +++ b/fineract-core/src/test/java/org/apache/fineract/infrastructure/dataqueries/exception/DatatableEntryRequiredExceptionTest.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.infrastructure.dataqueries.exception; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class DatatableEntryRequiredExceptionTest { + + @Test + void namesTheDatatableTheActionAndTheEntity() { + DatatableEntryRequiredException exception = new DatatableEntryRequiredException(List.of("dt_loan_details"), "m_loan", 200); + + assertEquals("Please fill in the data table [dt_loan_details] before approving this loan.", exception.getDefaultUserMessage()); + assertEquals("error.msg.entry.required.in.datatable.[dt_loan_details]", exception.getGlobalisationMessageCode()); + } + + @Test + void usesThePluralForSeveralDatatables() { + DatatableEntryRequiredException exception = new DatatableEntryRequiredException(List.of("dt_first", "dt_second"), "m_client", 100); + + assertEquals("Please fill in the data tables [dt_first, dt_second] before creating this client.", + exception.getDefaultUserMessage()); + assertEquals("error.msg.entry.required.in.datatable.[dt_first, dt_second]", exception.getGlobalisationMessageCode()); + } + + @Test + void describesSavingsAndWriteOffActions() { + assertEquals("Please fill in the data table [dt_savings_details] before activating this savings account.", + new DatatableEntryRequiredException(List.of("dt_savings_details"), "m_savings_account", 300).getDefaultUserMessage()); + assertEquals("Please fill in the data table [dt_loan_details] before writing off this loan.", + new DatatableEntryRequiredException(List.of("dt_loan_details"), "m_loan", 601).getDefaultUserMessage()); + } + + @Test + void fallsBackToAGenericActionWhenEntityOrStatusIsUnknown() { + assertEquals("Please fill in the data table [dt_extra] before continuing.", + new DatatableEntryRequiredException(List.of("dt_extra"), "m_unknown", 100).getDefaultUserMessage()); + assertEquals("Please fill in the data table [dt_extra] before continuing.", + new DatatableEntryRequiredException(List.of("dt_extra"), "m_loan", 999).getDefaultUserMessage()); + } +} diff --git a/fineract-doc/src/docs/en/chapters/architecture/batch-jobs.adoc b/fineract-doc/src/docs/en/chapters/architecture/batch-jobs.adoc index 9b920edf3ce..b7297f33410 100644 --- a/fineract-doc/src/docs/en/chapters/architecture/batch-jobs.adoc +++ b/fineract-doc/src/docs/en/chapters/architecture/batch-jobs.adoc @@ -286,6 +286,31 @@ POST /fineract-provider/api/v1/jobs/LOAN_COB/inline?tenantIdentifier={tenantId} ---- In this case the Loan COB job will work only with the given loan IDs. +== Executing missed runs + +The scheduler runs in memory and has no persistent job store. A cron fire that happens while Fineract is down never happens, and a fire that happens while the scheduler is suspended is vetoed. Both are lost by default. + +When `fineract.job.execute-missed-runs` (environment variable `FINERACT_JOB_EXECUTE_MISSED_RUNS`, default `false`) is `true`, the scheduler replays what was missed. The `next_run_time` of a job records the first run that never happened, because a vetoed fire never advances it, and this is used as the start of the job's trigger, so Quartz applies its own misfire handling: + +* By default a job fires once, as soon as the scheduler is up, however many runs it missed. +* `Increase Business Date by 1 day` fires once per missed run, one after another, so the business date moves one day per missed run. `Increase COB Date by 1 day` fires once. +* A job never runs concurrently with itself. Fires while the scheduler is suspended are still vetoed and do not advance `next_run_time`. + +The replay happens in two places: + +* On boot, for every active job whose `next_run_time` is in the past. A tenant with `reset_scheduler_on_bootup` is un-suspended before its jobs are registered, so the replayed fires are not vetoed. +* On `POST /scheduler?command=start`, which registers each active job with a past `next_run_time` again in the same way and clears `is_misfired`. With the property off the existing `execute_misfired_jobs` replay is unchanged. + +`next_run_time` is the instant of a Quartz fire time and is compared as an instant, so neither the tenant's nor the JVM's time zone changes what counts as missed. The cron expression itself is evaluated in the tenant's time zone. The column has no zone, a `Date` is mapped with the JVM's default zone, so keep the same default zone (the shipped images use `-Duser.timezone=UTC`) across restarts. + +Jobs run in the order Quartz fires them, there is no dependency between jobs. A replayed run executes on the day of the catch-up, not on the day it was missed, so a job that depends on the real clock or on a business date that has not caught up yet sees the current state. Known limits: + +* Periodic standing instructions lose the skipped dates, because they match the exact business date. +* Retained Earning is wrong if the business date jumps over a year end in one step. +* Jobs outside COB date their postings on the catch-up day. +* Report mailing sends duplicates after a long gap. +* Loans that are more than one day behind are not caught up by a single replayed COB run. Run the COB catch-up (`/loans/catch-up`) after the business date has caught up. + == Global Configuration for enabling/disabling jobs Some jobs can be enabled/disabled with global configuration. diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/populator/loan/LoanWorkbookPopulator.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/populator/loan/LoanWorkbookPopulator.java index 02c9924d163..64d489980b8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/populator/loan/LoanWorkbookPopulator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/populator/loan/LoanWorkbookPopulator.java @@ -475,28 +475,41 @@ private void setNames(Sheet worksheet) { officeGroup.setRefersToFormula(TemplatePopulateImportConstants.OFFICE_SHEET_NAME + "!$B$2:$B$" + (officeNames.size() + 1)); // Client and Loan Officer Names for each office + // Guard against duplicate office-keyed defined names (Staff_/Client_/Group_) — POI rejects a + // duplicate and 500s the whole template whenever two office names match case-insensitively after sanitising + // (e.g. "Head Office" and "head office"). Same guard as the charge/product loops below; each Name is created + // only once its name is known to be new, so a skipped entry leaves no orphan Name in the workbook. + Set seenOfficeScopedNames = new HashSet<>(); for (Integer i = 0; i < officeNames.size(); i++) { Integer[] officeNameToBeginEndIndexesOfClients = clientSheetPopulator.getOfficeNameToBeginEndIndexesOfClients().get(i); Integer[] officeNameToBeginEndIndexesOfStaff = personnelSheetPopulator.getOfficeNameToBeginEndIndexesOfStaff().get(i); Integer[] officeNameToBeginEndIndexesOfGroups = groupSheetPopulator.getOfficeNameToBeginEndIndexesOfGroups().get(i); - Name clientName = loanWorkbook.createName(); - Name loanOfficerName = loanWorkbook.createName(); - Name groupName = loanWorkbook.createName(); - if (officeNameToBeginEndIndexesOfStaff != null) { - setSanitized(loanOfficerName, "Staff_" + officeNames.get(i)); - loanOfficerName.setRefersToFormula(TemplatePopulateImportConstants.STAFF_SHEET_NAME + "!$B$" - + officeNameToBeginEndIndexesOfStaff[0] + ":$B$" + officeNameToBeginEndIndexesOfStaff[1]); + String staffName = "Staff_" + officeNames.get(i); + if (seenOfficeScopedNames.add(sanitizeName(staffName).toUpperCase(Locale.ROOT))) { + Name loanOfficerName = loanWorkbook.createName(); + setSanitized(loanOfficerName, staffName); + loanOfficerName.setRefersToFormula(TemplatePopulateImportConstants.STAFF_SHEET_NAME + "!$B$" + + officeNameToBeginEndIndexesOfStaff[0] + ":$B$" + officeNameToBeginEndIndexesOfStaff[1]); + } } if (officeNameToBeginEndIndexesOfClients != null) { - setSanitized(clientName, "Client_" + officeNames.get(i)); - clientName.setRefersToFormula(TemplatePopulateImportConstants.CLIENT_SHEET_NAME + "!$B$" - + officeNameToBeginEndIndexesOfClients[0] + ":$B$" + officeNameToBeginEndIndexesOfClients[1]); + String clientNameStr = "Client_" + officeNames.get(i); + if (seenOfficeScopedNames.add(sanitizeName(clientNameStr).toUpperCase(Locale.ROOT))) { + Name clientName = loanWorkbook.createName(); + setSanitized(clientName, clientNameStr); + clientName.setRefersToFormula(TemplatePopulateImportConstants.CLIENT_SHEET_NAME + "!$B$" + + officeNameToBeginEndIndexesOfClients[0] + ":$B$" + officeNameToBeginEndIndexesOfClients[1]); + } } if (officeNameToBeginEndIndexesOfGroups != null) { - setSanitized(groupName, "Group_" + officeNames.get(i)); - groupName.setRefersToFormula(TemplatePopulateImportConstants.GROUP_SHEET_NAME + "!$B$" - + officeNameToBeginEndIndexesOfGroups[0] + ":$B$" + officeNameToBeginEndIndexesOfGroups[1]); + String groupNameStr = "Group_" + officeNames.get(i); + if (seenOfficeScopedNames.add(sanitizeName(groupNameStr).toUpperCase(Locale.ROOT))) { + Name groupName = loanWorkbook.createName(); + setSanitized(groupName, groupNameStr); + groupName.setRefersToFormula(TemplatePopulateImportConstants.GROUP_SHEET_NAME + "!$B$" + + officeNameToBeginEndIndexesOfGroups[0] + ":$B$" + officeNameToBeginEndIndexesOfGroups[1]); + } } } @@ -521,7 +534,11 @@ private void setNames(Sheet worksheet) { // Default Charge Name, Charge Amount, Charge Amount Type, Charge Due Date Set seenChargeNames = new HashSet<>(); for (Integer i = 0; i < charges.size(); i++) { - String chargeName = charges.get(i).getName().trim().replaceAll("[ )(]", "_"); + // sanitizeName() strips every character Excel disallows in a defined name (POI only permits + // letter/digit/period/underscore) - the previous "[ )(]" regex left characters like '%' or '-' + // in place, which crashed the whole template download the moment a charge name contained one + // (e.g. "Service Fee 5%-10% (VAT)"). + String chargeName = sanitizeName(charges.get(i).getName()); // Guard against duplicate charge-keyed defined names — POI rejects a duplicate and 500s the whole // template. Excel defined names are case-insensitive, so upper-case the key. The names are resolved // before any createName() call so a skipped charge leaves no orphan Name in the workbook. diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/service/BulkImportWorkbookPopulatorServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/service/BulkImportWorkbookPopulatorServiceImpl.java index 14351c97b5b..ab62e0f3e6b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/service/BulkImportWorkbookPopulatorServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/service/BulkImportWorkbookPopulatorServiceImpl.java @@ -23,7 +23,10 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.ArrayList; +import java.util.EnumSet; import java.util.List; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; import org.apache.fineract.accounting.glaccount.data.GLAccountData; import org.apache.fineract.accounting.glaccount.service.GLAccountReadPlatformService; import org.apache.fineract.infrastructure.bulkimport.constants.TemplatePopulateImportConstants; @@ -66,7 +69,9 @@ import org.apache.fineract.infrastructure.bulkimport.populator.users.UserWorkbookPopulator; import org.apache.fineract.infrastructure.codes.data.CodeValueData; import org.apache.fineract.infrastructure.codes.service.CodeValueReadPlatformService; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.Page; import org.apache.fineract.infrastructure.core.service.SearchParameters; @@ -118,6 +123,11 @@ public class BulkImportWorkbookPopulatorServiceImpl implements BulkImportWorkbookPopulatorService { private static final Logger LOG = LoggerFactory.getLogger(BulkImportWorkbookPopulatorServiceImpl.class); + // Templates whose populators never format a date: they ignore dateFormat or only hand it to POI's + // createDateConstraint, which accepts null. Every other template needs it and fails without it. + private static final Set TEMPLATES_WITHOUT_DATE_FORMAT = EnumSet.of(GlobalEntityType.OFFICES, GlobalEntityType.STAFF, + GlobalEntityType.USERS, GlobalEntityType.CHART_OF_ACCOUNTS, GlobalEntityType.GL_JOURNAL_ENTRIES, + GlobalEntityType.SHARE_ACCOUNTS); private final PlatformSecurityContext context; private final OfficeReadPlatformService officeReadPlatformService; private final StaffReadService staffReadPlatformService; @@ -177,6 +187,10 @@ public BulkImportWorkbookPopulatorServiceImpl(final PlatformSecurityContext cont @Override public Response getTemplate(String entityType, Long officeId, Long staffId, final String dateFormat, final LookupMode lookupMode) { + if (StringUtils.isBlank(dateFormat) && requiresDateFormat(entityType)) { + throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError("error.msg.dateformat.mandatory", + "The parameter dateFormat is mandatory", "dateFormat"))); + } WorkbookPopulator populator = null; final Workbook workbook = new HSSFWorkbook(); if (entityType != null) { @@ -227,6 +241,11 @@ public Response getTemplate(String entityType, Long officeId, Long staffId, fina } } + private static boolean requiresDateFormat(final String entityType) { + return entityType != null + && TEMPLATES_WITHOUT_DATE_FORMAT.stream().noneMatch(type -> type.toString().equalsIgnoreCase(entityType.trim())); + } + private WorkbookPopulator populateClientWorkbook(final String entityType, final Long officeId, final Long staffId) { this.context.authenticatedUser().validateHasReadPermission(TemplatePopulateImportConstants.OFFICE_ENTITY_TYPE); this.context.authenticatedUser().validateHasReadPermission(TemplatePopulateImportConstants.STAFF_ENTITY_TYPE); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/domain/EntityDatatableChecksRepository.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/domain/EntityDatatableChecksRepository.java index 28b4b4c8729..f957138d252 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/domain/EntityDatatableChecksRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/domain/EntityDatatableChecksRepository.java @@ -35,7 +35,7 @@ public interface EntityDatatableChecksRepository INNER JOIN RegisteredDatatable rdt ON rdt.datatableName = dt.datatableName WHERE dt.entity = :entity AND dt.status = :status - AND rdt.subtype = :subtype + AND UPPER(rdt.subtype) = UPPER(:subtype) """) List findByEntityAndStatusAndSubtype(@Param("entity") String entity, @Param("status") Integer status, @Param("subtype") String subtype); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/EntityDatatableChecksDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/EntityDatatableChecksDataValidator.java index abed6d81715..c8716351e33 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/EntityDatatableChecksDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/EntityDatatableChecksDataValidator.java @@ -75,12 +75,15 @@ public void validateForCreate(final String json) { final JsonElement element = this.fromApiJsonHelper.parse(json); final String entity = this.fromApiJsonHelper.extractStringNamed(ENTITY, element); - baseDataValidator.reset().parameter(ENTITY).value(entity).notBlank().isOneOfTheseStringValues(EntityTables.getEntityNames()); + baseDataValidator.reset().parameter(ENTITY).value(entity).notBlank() + .isOneOfTheseStringValues(EntityTables.getCheckableEntityNames()); final Integer status = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(STATUS, element); - final Object[] entityTablesStatuses = EntityTables.getCheckStatusCodes(entity).toArray(); - - baseDataValidator.reset().parameter(STATUS).value(status).isOneOfTheseValues(entityTablesStatuses); + final EntityTables entityTable = EntityTables.fromEntityName(entity); + if (entityTable != null && entityTable.hasCheck()) { + final Object[] entityTablesStatuses = EntityTables.getCheckStatusCodes(entity).toArray(); + baseDataValidator.reset().parameter(STATUS).value(status).isOneOfTheseValues(entityTablesStatuses); + } final String datatableName = this.fromApiJsonHelper.extractStringNamed(API_PARAM_DATATABLE_NAME, element); baseDataValidator.reset().parameter(API_PARAM_DATATABLE_NAME).value(datatableName).notBlank(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/EntityDatatableChecksReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/EntityDatatableChecksReadPlatformServiceImpl.java index e4b3f3411cb..db0c9d5cc45 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/EntityDatatableChecksReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/EntityDatatableChecksReadPlatformServiceImpl.java @@ -138,7 +138,7 @@ public List retrieveTemplates(final Integer status, final String @Override public EntityDataTableChecksTemplateData retrieveTemplate() { List dataTables = getDataTables(); - List entities = EntityTables.getEntityNames(); + List entities = EntityTables.getCheckableEntityNames(); List clientStatuses = getStatusList(EntityTables.CLIENT.getCheckStatuses()); List loanStatuses = getStatusList(EntityTables.LOAN.getCheckStatuses()); List groupstatuses = getStatusList(EntityTables.GROUP.getCheckStatuses()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/EntityDatatableChecksWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/EntityDatatableChecksWritePlatformServiceImpl.java index 6f01b3954b9..c6ea1038ac0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/EntityDatatableChecksWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/EntityDatatableChecksWritePlatformServiceImpl.java @@ -164,7 +164,7 @@ public void runTheCheck(final Long entityId, final String entityName, final Inte } } if (!reqDatatables.isEmpty()) { - throw new DatatableEntryRequiredException(reqDatatables.toString()); + throw new DatatableEntryRequiredException(reqDatatables, entityName, status); } } } @@ -192,7 +192,7 @@ public void runTheCheckForProduct(final Long entityId, final String entityName, } } if (!reqDatatables.isEmpty()) { - throw new DatatableEntryRequiredException(reqDatatables.toString()); + throw new DatatableEntryRequiredException(reqDatatables, entityName, status); } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/entityaccess/api/FineractEntityApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/entityaccess/api/FineractEntityApiResource.java index 051a7a2c9fe..b4a1007c95d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/entityaccess/api/FineractEntityApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/entityaccess/api/FineractEntityApiResource.java @@ -67,7 +67,7 @@ public class FineractEntityApiResource { @AlternativeOperationId("retrieveAll_7") public String retrieveAll(@Context final UriInfo uriInfo) { - this.context.authenticatedUser().validateHasReadPermission(FineractEntityApiResourceConstants.FINERACT_ENTITY_RESOURCE_NAME); + this.context.authenticatedUser().validateHasReadPermission(FineractEntityApiResourceConstants.ENTITY_MAPPING_RESOURCE_NAME); final Collection entityMappings = this.readPlatformService.retrieveAllSupportedMappingTypes(); final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters()); @@ -81,7 +81,7 @@ public String retrieveAll(@Context final UriInfo uriInfo) { @AlternativeOperationId("retrieveOne_4") public String retrieveOne(@PathParam("mapId") final Long mapId, @Context final UriInfo uriInfo) { - this.context.authenticatedUser().validateHasReadPermission(FineractEntityApiResourceConstants.FINERACT_ENTITY_RESOURCE_NAME); + this.context.authenticatedUser().validateHasReadPermission(FineractEntityApiResourceConstants.ENTITY_MAPPING_RESOURCE_NAME); final Collection entityToEntityMappings = this.readPlatformService.retrieveOneMapping(mapId); final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters()); @@ -95,7 +95,7 @@ public String retrieveOne(@PathParam("mapId") final Long mapId, @Context final U public String getEntityToEntityMappings(@PathParam("mapId") final Long mapId, @PathParam("fromId") final Long fromId, @PathParam("toId") final Long toId, @Context final UriInfo uriInfo) { - this.context.authenticatedUser().validateHasReadPermission(FineractEntityApiResourceConstants.FINERACT_ENTITY_RESOURCE_NAME); + this.context.authenticatedUser().validateHasReadPermission(FineractEntityApiResourceConstants.ENTITY_MAPPING_RESOURCE_NAME); final Collection entityToEntityMappings = this.readPlatformService .retrieveEntityToEntityMappings(mapId, fromId, toId); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/entityaccess/api/FineractEntityApiResourceConstants.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/entityaccess/api/FineractEntityApiResourceConstants.java index 7e7df1a8266..02d30e4afd4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/entityaccess/api/FineractEntityApiResourceConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/entityaccess/api/FineractEntityApiResourceConstants.java @@ -29,6 +29,7 @@ private FineractEntityApiResourceConstants() { } public static final String FINERACT_ENTITY_RESOURCE_NAME = "FineractEntity"; + public static final String ENTITY_MAPPING_RESOURCE_NAME = "ENTITYMAPPING"; public static final String mappingTypes = "mappingTypes"; public static final String mapId = "mapId"; public static final String relId = "relId"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/processor/ElasticSearchHookProcessor.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/processor/ElasticSearchHookProcessor.java index 548340b345f..33d0c80116c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/processor/ElasticSearchHookProcessor.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/processor/ElasticSearchHookProcessor.java @@ -74,12 +74,12 @@ private void sendRequest(final String url, final String contentType, final Strin if (contentType.equalsIgnoreCase("json") || contentType.contains("json")) { final JsonObject json = new Gson().fromJson(payload, JsonObject.class); - service.sendJsonRequest(entityName, actionName, context.getTenantContext().getTenantIdentifier(), fineractEndpointUrl, json) - .enqueue(callback); + service.sendJsonRequest(url, entityName, actionName, context.getTenantContext().getTenantIdentifier(), fineractEndpointUrl, + json).enqueue(callback); } else { Map map = new HashMap<>(); map = new Gson().fromJson(payload, map.getClass()); - service.sendFormRequest(entityName, actionName, context.getTenantContext().getTenantIdentifier(), fineractEndpointUrl, map) + service.sendFormRequest(url, entityName, actionName, context.getTenantContext().getTenantIdentifier(), fineractEndpointUrl, map) .enqueue(callback); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/processor/ProcessorHelper.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/processor/ProcessorHelper.java index b5bf200b9c0..552c35b6bca 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/processor/ProcessorHelper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/processor/ProcessorHelper.java @@ -27,6 +27,7 @@ import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; +import okhttp3.HttpUrl; import okhttp3.OkHttpClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -112,10 +113,20 @@ public void onFailure(@SuppressWarnings("unused") Call call, Throwable t) { }; } + /** + * Creates the client for a hook URL. Requests pass the hook URL itself with {@code @Url}, so the base URL is only + * the scheme, host and port of the hook URL (Retrofit requires a base URL that ends with "/"). + * + * @param url + * the hook URL + * @return the client + * @throws IllegalArgumentException + * if the URL is not a valid http or https URL + */ public WebHookService createWebHookService(final String url) { final OkHttpClient client = createClient(); final Retrofit.Builder retrofitBuilder = new Retrofit.Builder(); - retrofitBuilder.baseUrl(url); + retrofitBuilder.baseUrl(HttpUrl.get(url).resolve("/")); retrofitBuilder.client(client); retrofitBuilder.addConverterFactory(GsonConverterFactory.create()); final Retrofit retrofit = retrofitBuilder.build(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/processor/TwilioHookProcessor.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/processor/TwilioHookProcessor.java index 7154ec2b2db..6f3bc8d0c51 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/processor/TwilioHookProcessor.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/processor/TwilioHookProcessor.java @@ -65,7 +65,8 @@ public void process(final Hook hook, final String payload, final String entityNa private void sendRequest(final HookSmsProviderData smsProviderData, final String payload, String entityName, String actionName, final Hook hook, final FineractContext context) throws IOException { - final WebHookService service = processorHelper.createWebHookService(smsProviderData.getUrl()); + final String url = smsProviderData.getUrl(); + final WebHookService service = processorHelper.createWebHookService(url); @SuppressWarnings("rawtypes") final Callback callback = processorHelper.createCallback(smsProviderData.getUrl()); @@ -93,7 +94,7 @@ private void sendRequest(final HookSmsProviderData smsProviderData, final String } else { json = JsonParser.parseString(payload).getAsJsonObject(); } - service.sendSmsBridgeRequest(entityName, actionName, context.getTenantContext().getTenantIdentifier(), apiKey, json) + service.sendSmsBridgeRequest(url, entityName, actionName, context.getTenantContext().getTenantIdentifier(), apiKey, json) .enqueue(callback); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/processor/WebHookProcessor.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/processor/WebHookProcessor.java index 1c3808216e5..abbf9bc96fe 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/processor/WebHookProcessor.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/processor/WebHookProcessor.java @@ -74,12 +74,12 @@ private void sendRequest(final String url, final String contentType, final Strin if (contentType.equalsIgnoreCase("json") || contentType.contains("json")) { final JsonObject json = JsonParser.parseString(payload).getAsJsonObject(); - service.sendJsonRequest(entityName, actionName, context.getTenantContext().getTenantIdentifier(), fineractEndpointUrl, json) - .enqueue(callback); + service.sendJsonRequest(url, entityName, actionName, context.getTenantContext().getTenantIdentifier(), fineractEndpointUrl, + json).enqueue(callback); } else { Map map = new HashMap<>(); map = new Gson().fromJson(payload, map.getClass()); - service.sendFormRequest(entityName, actionName, context.getTenantContext().getTenantIdentifier(), fineractEndpointUrl, map) + service.sendFormRequest(url, entityName, actionName, context.getTenantContext().getTenantIdentifier(), fineractEndpointUrl, map) .enqueue(callback); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/processor/WebHookService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/processor/WebHookService.java index 40938c6d8f8..7af99bcb69b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/processor/WebHookService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/processor/WebHookService.java @@ -28,6 +28,7 @@ import retrofit2.http.GET; import retrofit2.http.Header; import retrofit2.http.POST; +import retrofit2.http.Url; public interface WebHookService { @@ -37,24 +38,26 @@ public interface WebHookService { String ENDPOINT_HEADER = "X-Fineract-Endpoint"; String API_KEY_HEADER = "X-Fineract-API-Key"; + // The Payload URL is passed as the full request URL (@Url), so it doesn't have to end with "/". + // Ping - @GET(".") - Call sendEmptyRequest(); + @GET + Call sendEmptyRequest(@Url String url); // Template - Web - @POST(".") - Call sendJsonRequest(@Header(ENTITY_HEADER) String entityHeader, @Header(ACTION_HEADER) String actionHeader, + @POST + Call sendJsonRequest(@Url String url, @Header(ENTITY_HEADER) String entityHeader, @Header(ACTION_HEADER) String actionHeader, @Header(TENANT_HEADER) String tenantHeader, @Header(ENDPOINT_HEADER) String endpointHeader, @Body JsonObject result); @FormUrlEncoded - @POST(".") - Call sendFormRequest(@Header(ENTITY_HEADER) String entityHeader, @Header(ACTION_HEADER) String actionHeader, + @POST + Call sendFormRequest(@Url String url, @Header(ENTITY_HEADER) String entityHeader, @Header(ACTION_HEADER) String actionHeader, @Header(TENANT_HEADER) String tenantHeader, @Header(ENDPOINT_HEADER) String endpointHeader, @FieldMap Map params); // Template - SMS Bridge - @POST(".") - Call sendSmsBridgeRequest(@Header(ENTITY_HEADER) String entityHeader, @Header(ACTION_HEADER) String actionHeader, + @POST + Call sendSmsBridgeRequest(@Url String url, @Header(ENTITY_HEADER) String entityHeader, @Header(ACTION_HEADER) String actionHeader, @Header(TENANT_HEADER) String tenantHeader, @Header(API_KEY_HEADER) String apiKeyHeader, @Body JsonObject result); @POST("/configuration") diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/service/HookWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/service/HookWritePlatformServiceImpl.java index 77564581f31..16e356ceca4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/service/HookWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/hooks/service/HookWritePlatformServiceImpl.java @@ -240,8 +240,8 @@ private void validateHookRules(final HookTemplate template, final Set scheduledJobDetails = this.schedularWritePlatformService .retrieveAllJobs(fineractProperties.getNodeId()); for (final ScheduledJobDetail jobDetail : scheduledJobDetails) { @@ -199,6 +206,36 @@ public void startScheduler() { } } + /** + * A fire that was vetoed because the scheduler was suspended never advanced next_run_time, so it still names the + * first run that did not happen. Registering the job again with that time as the trigger's start makes Quartz + * replay what was missed, in the same way as on boot. + */ + private void replayMissedRuns() { + for (final ScheduledJobDetail jobDetail : this.schedularWritePlatformService.retrieveAllJobs(fineractProperties.getNodeId())) { + if (jobDetail.isActiveSchedular() && isInThePast(jobDetail.getNextRunTime())) { + final boolean currentlyRunning = jobDetail.isCurrentlyRunning(); + deleteFromScheduler(jobDetail); + scheduleJob(jobDetail, true); + // a manually started run may be in progress: keep the veto that stops a second one + jobDetail.setCurrentlyRunning(currentlyRunning); + } + jobDetail.setTriggerMisfired(false); + this.schedularWritePlatformService.saveOrUpdate(jobDetail); + } + } + + private void deleteFromScheduler(final ScheduledJobDetail scheduledJobDetail) { + final Scheduler scheduler = SCHEDULERS.get(getSchedulerName(scheduledJobDetail)); + if (scheduler != null && scheduledJobDetail.getJobKey() != null) { + try { + scheduler.deleteJob(constructJobKey(scheduledJobDetail.getJobKey())); + } catch (final SchedulerException e) { + log.error("Error occurred.", e); + } + } + } + @Override public void rescheduleJob(final Long jobId) { final ScheduledJobDetail scheduledJobDetail = this.schedularWritePlatformService.findByJobId(jobId); @@ -247,6 +284,10 @@ public void onApplicationEvent(@SuppressWarnings("unused") ContextClosedEvent ev @Override public void scheduleJob(final ScheduledJobDetail scheduledJobDetails) { + scheduleJob(scheduledJobDetails, fineractProperties.getJob().isExecuteMissedRuns()); + } + + private void scheduleJob(final ScheduledJobDetail scheduledJobDetails, final boolean replayMissedRuns) { try { final JobDetail jobDetail = createJobDetail(scheduledJobDetails, Collections.emptySet()); scheduledJobDetails.setJobKey(getJobKeyAsString(jobDetail.getKey())); @@ -256,7 +297,7 @@ public void scheduleJob(final ScheduledJobDetail scheduledJobDetails) { return; } - final Trigger trigger = createTrigger(scheduledJobDetails, jobDetail); + final Trigger trigger = createTrigger(scheduledJobDetails, jobDetail, replayMissedRuns); final Scheduler scheduler = getScheduler(scheduledJobDetails); scheduler.scheduleJob(jobDetail, trigger); scheduledJobDetails.setNextRunTime(trigger.getNextFireTime()); @@ -351,7 +392,8 @@ private JobDetail createJobDetail(final ScheduledJobDetail scheduledJobDetail, S return jobDetailFactoryBean.getObject(); } - private Trigger createTrigger(final ScheduledJobDetail scheduledJobDetails, final JobDetail jobDetail) throws ParseException { + private Trigger createTrigger(final ScheduledJobDetail scheduledJobDetails, final JobDetail jobDetail, final boolean replayMissedRuns) + throws ParseException { final FineractPlatformTenant tenant = ThreadLocalContextUtil.getTenant(); final CronTriggerFactoryBean cronTriggerFactoryBean = new CronTriggerFactoryBean(); cronTriggerFactoryBean.setName(scheduledJobDetails.getJobName() + "Trigger" + tenant.getId()); @@ -364,10 +406,29 @@ private Trigger createTrigger(final ScheduledJobDetail scheduledJobDetails, fina cronTriggerFactoryBean.setGroup(scheduledJobDetails.getGroupName()); cronTriggerFactoryBean.setCronExpression(scheduledJobDetails.getCronExpression()); cronTriggerFactoryBean.setPriority(scheduledJobDetails.getTaskPriority()); + // Other jobs keep Quartz's default (fire once, now). The business date has to move one day per missed run. + if (fineractProperties.getJob().isExecuteMissedRuns() && JobName.INCREASE_BUSINESS_DATE_BY_1_DAY.name() + .equals(jobNameService.getJobByHumanReadableName(scheduledJobDetails.getJobName()).getEnumStyleName())) { + cronTriggerFactoryBean.setMisfireInstruction(CronTrigger.MISFIRE_INSTRUCTION_IGNORE_MISFIRE_POLICY); + } + if (replayMissedRuns && isInThePast(scheduledJobDetails.getNextRunTime())) { + // The first run that never happened: Quartz applies the misfire instruction to it and what follows. + // The cron itself is still evaluated in the tenant's time zone. + cronTriggerFactoryBean.setStartTime(Date.from(scheduledJobDetails.getNextRunTime().toInstant())); + } cronTriggerFactoryBean.afterPropertiesSet(); return cronTriggerFactoryBean.getObject(); } + /** + * next_run_time is the instant of a Quartz fire time (a Date, mapped by the JVM's default zone to a zone-less + * column), so it is compared as an instant. Neither the tenant's nor the system zone takes part in it. + */ + private static boolean isInThePast(final Date nextRunTime) { + return nextRunTime != null && DateUtils.isBefore(OffsetDateTime.ofInstant(nextRunTime.toInstant(), ZoneOffset.UTC), + DateUtils.getAuditOffsetDateTime()); + } + private String getStackTraceAsString(final Throwable throwable) { final StackTraceElement[] stackTraceElements = throwable.getStackTrace(); final StringBuilder sb = new StringBuilder(throwable.toString()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobSchedulerServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobSchedulerServiceImpl.java index adb90ec41dd..d9934d997e3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobSchedulerServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobSchedulerServiceImpl.java @@ -63,6 +63,11 @@ public void onApplicationEvent(ContextRefreshedEvent event) { HashMap businessDates = businessDateReadPlatformService.getBusinessDates(); ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT); ThreadLocalContextUtil.setBusinessDates(businessDates); + final SchedulerDetail schedulerDetail = schedularWritePlatformService.retriveSchedulerDetail(); + if (schedulerDetail.isResetSchedulerOnBootup()) { + schedulerDetail.setSuspended(false); + schedularWritePlatformService.updateSchedulerDetail(schedulerDetail); + } final List scheduledJobDetails = schedularWritePlatformService .retrieveAllJobs(fineractProperties.getNodeId()); for (final ScheduledJobDetail jobDetails : scheduledJobDetails) { @@ -74,11 +79,6 @@ public void onApplicationEvent(ContextRefreshedEvent event) { jobDetails.setTriggerMisfired(false); schedularWritePlatformService.saveOrUpdate(jobDetails); } - final SchedulerDetail schedulerDetail = schedularWritePlatformService.retriveSchedulerDetail(); - if (schedulerDetail.isResetSchedulerOnBootup()) { - schedulerDetail.setSuspended(false); - schedularWritePlatformService.updateSchedulerDetail(schedulerDetail); - } ThreadLocalContextUtil.reset(); } log.info("Scheduling batch jobs has finished"); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/SchedulerTriggerListener.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/SchedulerTriggerListener.java index c889bbd9490..692515d9cb7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/SchedulerTriggerListener.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/SchedulerTriggerListener.java @@ -71,7 +71,7 @@ public boolean vetoJobExecution(final Trigger trigger, final JobExecutionContext @Override public void triggerMisfired(final Trigger trigger) { - log.error("triggerMisfired() trigger={}", trigger); + log.info("triggerMisfired() trigger={}", trigger); } @Override diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/collateralmanagement/domain/ClientCollateralManagementRepositoryWrapper.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/collateralmanagement/domain/ClientCollateralManagementRepositoryWrapper.java index 1eeef86592d..8fee53cf825 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/collateralmanagement/domain/ClientCollateralManagementRepositoryWrapper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/collateralmanagement/domain/ClientCollateralManagementRepositoryWrapper.java @@ -65,7 +65,8 @@ public List getClientCollateralData(final Long c BigDecimal quantity = clientCollateralManagement.getQuantity(); BigDecimal total = clientCollateralManagement.getTotal(); BigDecimal totalCollateralValue = clientCollateralManagement.getTotalCollateral(total); - if (prodId != null && clientCollateralManagement.getCollaterals().getCurrency().getCode().equals(currency)) { + // Without a loan product every collateral is listed; with one, only those in its currency. + if (prodId == null || clientCollateralManagement.getCollaterals().getCurrency().getCode().equals(currency)) { clientCollateralManagementDataSet .add(ClientCollateralManagementData.instance(clientCollateralManagement.getCollaterals().getName(), quantity, total, totalCollateralValue, clientId, null, clientCollateralManagement.getId())); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/meeting/data/MeetingAttendanceUpdateRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/meeting/data/MeetingAttendanceUpdateRequest.java index a4affffea50..4d26659c5dd 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/meeting/data/MeetingAttendanceUpdateRequest.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/meeting/data/MeetingAttendanceUpdateRequest.java @@ -44,7 +44,6 @@ public class MeetingAttendanceUpdateRequest implements Serializable { private Long entityId; @Hidden private CalendarEntityType entityType; - @Hidden // @NotNull(message = "{org.apache.fineract.portfolio.meeting.attendance.id.not-null}") @Valid @JsonAlias("clientsAttendance") diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tax/service/TaxAssembler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tax/service/TaxAssembler.java index 8b558e80722..acbfdea77d6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tax/service/TaxAssembler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tax/service/TaxAssembler.java @@ -131,12 +131,13 @@ public Set assembleTaxGroupMappingsFrom(final JsonCommand comm dateFormat, locale); final LocalDate endDate = this.fromApiJsonHelper.extractLocalDateNamed(TaxApiConstants.endDateParamName, taxComponent, dateFormat, locale); - if (endDate == null && startDate == null) { + final boolean isExistingMapping = isUpdate && mappingId != null; + if (endDate == null && startDate == null && !isExistingMapping) { startDate = DateUtils.getBusinessLocalDate(); } TaxGroupMappings mappings = null; - if (isUpdate && mappingId != null) { - mappings = TaxGroupMappings.createTaxGroupMappings(mappingId, component, endDate); + if (isExistingMapping) { + mappings = TaxGroupMappings.createTaxGroupMappings(mappingId, component, startDate, endDate); } else { mappings = TaxGroupMappings.createTaxGroupMappings(component, startDate); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tax/service/TaxReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tax/service/TaxReadPlatformServiceImpl.java index c93ac8e5200..7cce4606a97 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tax/service/TaxReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tax/service/TaxReadPlatformServiceImpl.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.portfolio.tax.service; +import java.time.LocalDate; import java.util.Collection; import java.util.List; import lombok.RequiredArgsConstructor; @@ -25,6 +26,7 @@ import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.portfolio.tax.data.TaxComponentData; import org.apache.fineract.portfolio.tax.data.TaxGroupData; +import org.apache.fineract.portfolio.tax.data.TaxGroupMappingsData; import org.apache.fineract.portfolio.tax.domain.TaxComponentRepository; import org.apache.fineract.portfolio.tax.domain.TaxComponentRepositoryWrapper; import org.apache.fineract.portfolio.tax.domain.TaxGroupRepository; @@ -72,9 +74,15 @@ public TaxGroupData retrieveTaxGroupData(final Long id) { @Override public TaxGroupData retrieveTaxGroupWithTemplate(final Long id) { - TaxGroupData taxGroupData = retrieveTaxGroupData(id); - taxGroupData = TaxGroupData.template(taxGroupData, retrieveTaxComponentsForLookUp()); - return taxGroupData; + final TaxGroupData taxGroupData = retrieveTaxGroupData(id); + final boolean groupEditable = !taxGroupRepository.isLinked(id); + final LocalDate today = DateUtils.getBusinessLocalDate(); + if (taxGroupData.getTaxAssociations() != null) { + for (TaxGroupMappingsData mapping : taxGroupData.getTaxAssociations()) { + mapping.setComponentEditable(groupEditable && DateUtils.isAfter(mapping.getStartDate(), today)); + } + } + return TaxGroupData.template(taxGroupData, retrieveTaxComponentsForLookUp(), groupEditable); } @Override diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tax/service/TaxWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tax/service/TaxWritePlatformServiceImpl.java index a606846d322..ede43d2e010 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tax/service/TaxWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tax/service/TaxWritePlatformServiceImpl.java @@ -86,7 +86,7 @@ public CommandProcessingResult updateTaxGroup(final Long id, final JsonCommand c final TaxGroup taxGroup = this.taxGroupRepositoryWrapper.findOneWithNotFoundDetection(id); final boolean isUpdate = true; Set groupMappings = this.taxAssembler.assembleTaxGroupMappingsFrom(command, isUpdate); - this.validator.validateTaxGroupEndDateAndTaxComponent(taxGroup, groupMappings); + this.validator.validateTaxGroupEndDateAndTaxComponent(taxGroup, groupMappings, this.taxGroupRepository.isLinked(id)); Map changes = taxGroup.update(command, groupMappings); this.validator.validateTaxGroup(taxGroup); this.taxGroupRepository.saveAndFlush(taxGroup); diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index d314b7a451b..b67c2898a4e 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -101,6 +101,8 @@ fineract.job.journal-entry-aggregation.enabled=${FINERACT_JOB_JOURNAL_ENTRY_AGGR #this property if enabled, will create aggregated entry for all data on first run, instead of one entry per submitted_on_date fineract.job.journal-entry-aggregation.chunk-size=${FINERACT_JOB_JOURNAL_ENTRY_AGGREGATION_CHUNK_SIZE:2000} fineract.job.retainedEarning-chunk-size=${FINERACT_RETAINED_EARNING_CHUNK_SIZE:100} +# Replay scheduler runs that were missed while Fineract was down or the scheduler was suspended +fineract.job.execute-missed-runs=${FINERACT_JOB_EXECUTE_MISSED_RUNS:false} fineract.partitioned-job.partitioned-job-properties[0].job-name=LOAN_COB fineract.partitioned-job.partitioned-job-properties[0].chunk-size=${LOAN_COB_CHUNK_SIZE:100} 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 5743806c3c7..a258a63e7c8 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 @@ -276,4 +276,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0258_add_read_entitymapping_permission.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0258_add_read_entitymapping_permission.xml new file mode 100644 index 00000000000..c59f17a4a72 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0258_add_read_entitymapping_permission.xml @@ -0,0 +1,35 @@ + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'READ_ENTITYMAPPING' + + + + + + + + + + diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/bulkimport/populator/loan/LoanWorkbookPopulatorTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/bulkimport/populator/loan/LoanWorkbookPopulatorTest.java index 9fe6ac10cc4..e4000ad0102 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/bulkimport/populator/loan/LoanWorkbookPopulatorTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/bulkimport/populator/loan/LoanWorkbookPopulatorTest.java @@ -20,12 +20,14 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; import java.util.List; +import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.bulkimport.populator.ChargeSheetPopulator; import org.apache.fineract.infrastructure.bulkimport.populator.ClientSheetPopulator; import org.apache.fineract.infrastructure.bulkimport.populator.ExtrasSheetPopulator; @@ -33,6 +35,7 @@ import org.apache.fineract.infrastructure.bulkimport.populator.LoanProductSheetPopulator; import org.apache.fineract.infrastructure.bulkimport.populator.OfficeSheetPopulator; import org.apache.fineract.infrastructure.bulkimport.populator.PersonnelSheetPopulator; +import org.apache.fineract.organisation.office.data.OfficeData; import org.apache.fineract.portfolio.charge.data.ChargeData; import org.apache.fineract.portfolio.loanproduct.data.LoanProductData; import org.apache.poi.hssf.usermodel.HSSFWorkbook; @@ -98,4 +101,49 @@ void productNamesCollidingOnlyByCaseEmitOneDefinedNameSet() throws Exception { "colliding product names must yield a single defined-name set"); } } + + // Office names are unique, but Excel defined names are case-insensitive, so "Head Office" and "head office" both + // resolve to the defined name "Group_Head_Office". GroupSheetPopulator records a begin/end index for every office, + // even one with zero groups, so this collision fires regardless of whether either office has any groups. The + // second createName call threw "The workbook already contains this name: Group_head_office" and 500'd the whole + // loan bulk-import template download. + @Test + void officesCollidingOnNameEmitOneDefinedNameSetPerScope() throws Exception { + List offices = List.of(OfficeData.testInstance(1L, "Head Office"), OfficeData.testInstance(2L, "head office")); + OfficeSheetPopulator officePopulator = new OfficeSheetPopulator(offices); + GroupSheetPopulator groupPopulator = new GroupSheetPopulator(List.of(), offices); + ClientSheetPopulator clientPopulator = new ClientSheetPopulator(List.of(), offices); + PersonnelSheetPopulator personnelPopulator = new PersonnelSheetPopulator(List.of(), offices); + + try (Workbook workbook = new HSSFWorkbook()) { + LoanWorkbookPopulator populator = new LoanWorkbookPopulator(officePopulator, clientPopulator, groupPopulator, + personnelPopulator, new LoanProductSheetPopulator(List.of()), new ChargeSheetPopulator(List.of()), + new ExtrasSheetPopulator(List.of(), List.of(), List.of())); + + assertDoesNotThrow(() -> populator.populate(workbook, DATE_FORMAT)); + + assertEquals(1, definedNamesStartingWith(workbook, "Group_"), "colliding office names must yield a single Group_ defined name"); + assertTrue(workbook.getAllNames().stream().allMatch(name -> StringUtils.isNotEmpty(name.getNameName())), + "a skipped office must not leave an unnamed Name in the workbook"); + } + } + + // A charge name containing characters Excel forbids in a defined name (e.g. '%') survived the old "[ )(]"-only + // sanitisation, so POI's HSSFName.setNameName rejected it with "Invalid name: ... name must be letter, digit, + // period, or underscore" and 500'd the whole template download. + @Test + void chargeNameWithExcelIllegalCharactersDoesNotCrashTemplateDownload() throws Exception { + ChargeData charge = mock(ChargeData.class); + when(charge.getName()).thenReturn("Service Fee 5%-10% (VAT)"); + ChargeSheetPopulator charges = spy(new ChargeSheetPopulator(List.of())); + doReturn(List.of(charge)).when(charges).getCharges(); + + try (Workbook workbook = new HSSFWorkbook()) { + LoanWorkbookPopulator populator = populator(new LoanProductSheetPopulator(List.of()), charges); + + assertDoesNotThrow(() -> populator.populate(workbook, DATE_FORMAT)); + + assertEquals(1, definedNamesStartingWith(workbook, "CHARGE_NAME_")); + } + } } diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/bulkimport/service/BulkImportWorkbookPopulatorServiceImplTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/bulkimport/service/BulkImportWorkbookPopulatorServiceImplTest.java new file mode 100644 index 00000000000..a5e10c89f02 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/bulkimport/service/BulkImportWorkbookPopulatorServiceImplTest.java @@ -0,0 +1,130 @@ +/** + * 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.bulkimport.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.RETURNS_DEFAULTS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import jakarta.ws.rs.core.Response; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.fineract.accounting.glaccount.service.GLAccountReadPlatformService; +import org.apache.fineract.infrastructure.bulkimport.data.GlobalEntityType; +import org.apache.fineract.infrastructure.bulkimport.data.LookupMode; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.codes.service.CodeValueReadPlatformService; +import org.apache.fineract.infrastructure.core.domain.ActionContext; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.office.service.OfficeReadPlatformService; +import org.apache.fineract.organisation.staff.service.StaffReadService; +import org.apache.fineract.portfolio.charge.service.ChargeReadPlatformService; +import org.apache.fineract.portfolio.client.service.ClientReadPlatformService; +import org.apache.fineract.portfolio.fund.service.FundReadPlatformService; +import org.apache.fineract.portfolio.group.service.CenterReadPlatformService; +import org.apache.fineract.portfolio.group.service.GroupReadPlatformService; +import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService; +import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService; +import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService; +import org.apache.fineract.portfolio.products.service.ShareProductReadPlatformService; +import org.apache.fineract.portfolio.savings.service.DepositProductReadPlatformService; +import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService; +import org.apache.fineract.portfolio.savings.service.SavingsProductReadPlatformService; +import org.apache.fineract.useradministration.domain.AppUser; +import org.apache.fineract.useradministration.service.RoleReadPlatformService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +class BulkImportWorkbookPopulatorServiceImplTest { + + private final PlatformSecurityContext context = mock(PlatformSecurityContext.class); + private final BulkImportWorkbookPopulatorServiceImpl service = new BulkImportWorkbookPopulatorServiceImpl(context, + readService(OfficeReadPlatformService.class), readService(StaffReadService.class), readService(ClientReadPlatformService.class), + readService(CenterReadPlatformService.class), readService(GroupReadPlatformService.class), + readService(FundReadPlatformService.class), readService(PaymentTypeReadService.class), + readService(LoanProductReadPlatformService.class), + readService(org.apache.fineract.organisation.monetary.service.CurrencyReadPlatformService.class), + readService(LoanReadPlatformService.class), readService(GLAccountReadPlatformService.class), + readService(SavingsAccountReadPlatformService.class), readService(CodeValueReadPlatformService.class), + readService(SavingsProductReadPlatformService.class), readService(ShareProductReadPlatformService.class), + readService(ChargeReadPlatformService.class), readService(DepositProductReadPlatformService.class), + readService(RoleReadPlatformService.class)); + + // Every read service answers with no data, and an empty Page where it returns one. + private static T readService(final Class type) { + return mock(type, invocation -> invocation.getMethod().getReturnType() == Page.class ? new Page<>(List.of(), 0) + : RETURNS_DEFAULTS.answer(invocation)); + } + + @BeforeEach + void setUp() { + ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Asia/Kolkata", null)); + ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT); + ThreadLocalContextUtil + .setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, LocalDate.now(ZoneId.systemDefault())))); + when(context.authenticatedUser()).thenReturn(mock(AppUser.class)); + } + + @AfterEach + void tearDown() { + ThreadLocalContextUtil.reset(); + } + + // The template download failed with an NPE deep inside the workbook populators' date formatting whenever dateFormat + // was omitted, giving the user no feedback ("nothing happens" on click). Reject the request up front with a clear + // validation error instead. + @ParameterizedTest + @EnumSource(value = GlobalEntityType.class, names = { "LOANS", "CLIENTS_PERSON", "CENTERS", "SAVINGS_ACCOUNT", "LOAN_TRANSACTIONS" }) + void missingDateFormatIsRejectedWithAClearValidationErrorForTemplatesThatFormatDates(GlobalEntityType entityType) { + PlatformApiDataValidationException exception = assertThrows(PlatformApiDataValidationException.class, + () -> service.getTemplate(entityType.toString(), null, null, null, LookupMode.INCLUDE)); + + assertEquals(1, exception.getErrors().size()); + assertEquals("dateFormat", exception.getErrors().get(0).getParameterName()); + } + + @Test + void blankDateFormatIsRejectedWithAClearValidationError() { + assertThrows(PlatformApiDataValidationException.class, + () -> service.getTemplate(GlobalEntityType.LOANS.toString(), null, null, " ", LookupMode.INCLUDE)); + } + + // These templates never format a date (they ignore dateFormat or only pass it to POI's createDateConstraint, which + // accepts null), so they keep downloading without it. + @ParameterizedTest + @EnumSource(value = GlobalEntityType.class, names = { "OFFICES", "STAFF", "USERS", "CHART_OF_ACCOUNTS", "GL_JOURNAL_ENTRIES", + "SHARE_ACCOUNTS" }) + void templatesThatDoNotFormatDatesDownloadWithoutDateFormat(GlobalEntityType entityType) { + Response response = service.getTemplate(entityType.toString(), null, null, null, LookupMode.INCLUDE); + + assertEquals(200, response.getStatus()); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/dataqueries/service/EntityDatatableChecksDataValidatorTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/dataqueries/service/EntityDatatableChecksDataValidatorTest.java new file mode 100644 index 00000000000..ba4e2f8e58d --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/dataqueries/service/EntityDatatableChecksDataValidatorTest.java @@ -0,0 +1,59 @@ +/** + * 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.dataqueries.service; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class EntityDatatableChecksDataValidatorTest { + + private final EntityDatatableChecksDataValidator underTest = new EntityDatatableChecksDataValidator(new FromJsonHelper()); + + @ParameterizedTest + @ValueSource(strings = { "m_center", "m_office", "m_savings_product", "m_savings_account_transaction", "m_share_product", + "m_wc_loan_product" }) + void rejectsEntityWithoutCheckStatusesOnTheEntityParameter(String entity) { + String json = "{\"entity\":\"" + entity + "\",\"status\":100,\"datatableName\":\"dt_check\"}"; + + PlatformApiDataValidationException exception = assertThrows(PlatformApiDataValidationException.class, + () -> underTest.validateForCreate(json)); + + List errors = exception.getErrors(); + assertEquals(1, errors.size()); + assertEquals("entity", errors.get(0).getParameterName()); + assertEquals("validation.msg.entityDatatableChecks.entity.is.not.one.of.expected.enumerations", + errors.get(0).getUserMessageGlobalisationCode()); + } + + @ParameterizedTest + @ValueSource(strings = { "m_client", "m_group", "m_product_loan", "m_loan", "m_savings_account", "m_wc_loan" }) + void acceptsCreateStatusForEntityWithChecks(String entity) { + String json = "{\"entity\":\"" + entity + "\",\"status\":100,\"datatableName\":\"dt_check\"}"; + + assertDoesNotThrow(() -> underTest.validateForCreate(json)); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/dataqueries/service/EntityDatatableChecksReadPlatformServiceImplTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/dataqueries/service/EntityDatatableChecksReadPlatformServiceImplTest.java index 7ea3f359d6a..11a6e1f36b4 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/dataqueries/service/EntityDatatableChecksReadPlatformServiceImplTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/dataqueries/service/EntityDatatableChecksReadPlatformServiceImplTest.java @@ -28,6 +28,7 @@ import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonObject; +import java.util.List; import org.apache.fineract.infrastructure.core.service.PaginationHelper; import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; import org.apache.fineract.infrastructure.dataqueries.data.EntityDataTableChecksTemplateData; @@ -43,6 +44,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; +import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) class EntityDatatableChecksReadPlatformServiceImplTest { @@ -93,4 +95,13 @@ void templateExposesLoanProductCheckStatuses() { assertEquals(StatusEnum.CREATE.name(), statusLoanProduct.get(0).getAsJsonObject().get("name").getAsString()); assertEquals(StatusEnum.CREATE.getValue(), statusLoanProduct.get(0).getAsJsonObject().get("code").getAsInt()); } + + @Test + void templateOffersOnlyEntitiesThatSupportChecks() { + EntityDataTableChecksTemplateData template = underTest.retrieveTemplate(); + + @SuppressWarnings("unchecked") + List entities = (List) ReflectionTestUtils.getField(template, "entities"); + assertEquals(List.of("m_client", "m_group", "m_product_loan", "m_loan", "m_savings_account", "m_wc_loan"), entities); + } } diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/entityaccess/api/FineractEntityApiResourceTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/entityaccess/api/FineractEntityApiResourceTest.java new file mode 100644 index 00000000000..00338908ffe --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/entityaccess/api/FineractEntityApiResourceTest.java @@ -0,0 +1,91 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.entityaccess.api; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import jakarta.ws.rs.core.UriInfo; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; +import org.apache.fineract.infrastructure.entityaccess.data.FineractEntityRelationData; +import org.apache.fineract.infrastructure.entityaccess.data.FineractEntityToEntityMappingData; +import org.apache.fineract.infrastructure.entityaccess.service.FineractEntityAccessReadService; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class FineractEntityApiResourceTest { + + private static final String READ_PERMISSION_RESOURCE = "ENTITYMAPPING"; + private static final Long MAP_ID = 1L; + + @Mock + private PlatformSecurityContext context; + @Mock + private FineractEntityAccessReadService readPlatformService; + @Mock + private DefaultToApiJsonSerializer toApiJsonSerializer; + @Mock + private DefaultToApiJsonSerializer toApiJsonSerializerOfficeToLoanProducts; + @Mock + private ApiRequestParameterHelper apiRequestParameterHelper; + @Mock + private PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + @Mock + private AppUser appUser; + @Mock + private UriInfo uriInfo; + + private FineractEntityApiResource underTest; + + @BeforeEach + void setUp() { + when(context.authenticatedUser()).thenReturn(appUser); + underTest = new FineractEntityApiResource(context, readPlatformService, toApiJsonSerializer, toApiJsonSerializerOfficeToLoanProducts, + apiRequestParameterHelper, commandsSourceWritePlatformService); + } + + @Test + void retrieveAllChecksEntityMappingReadPermission() { + underTest.retrieveAll(uriInfo); + + verify(appUser).validateHasReadPermission(READ_PERMISSION_RESOURCE); + } + + @Test + void retrieveOneChecksEntityMappingReadPermission() { + underTest.retrieveOne(MAP_ID, uriInfo); + + verify(appUser).validateHasReadPermission(READ_PERMISSION_RESOURCE); + } + + @Test + void getEntityToEntityMappingsChecksEntityMappingReadPermission() { + underTest.getEntityToEntityMappings(MAP_ID, 2L, 3L, uriInfo); + + verify(appUser).validateHasReadPermission(READ_PERMISSION_RESOURCE); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/hooks/processor/ProcessorHelperTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/hooks/processor/ProcessorHelperTest.java new file mode 100644 index 00000000000..6e1f7262df5 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/hooks/processor/ProcessorHelperTest.java @@ -0,0 +1,72 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.hooks.processor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.gson.JsonObject; +import java.util.Map; +import java.util.Set; +import org.apache.fineract.infrastructure.hooks.data.HookSmsProviderData; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ProcessorHelperTest { + + private ProcessorHelper processorHelper; + + @BeforeEach + void setUp() throws Exception { + processorHelper = new ProcessorHelper(); + } + + @Test + void sendsToAPayloadUrlWithoutTrailingSlash() { + final String url = "https://example.com/hook"; + + final WebHookService service = processorHelper.createWebHookService(url); + + assertEquals(url, service.sendJsonRequest(url, "CLIENT", "CREATE", "default", null, new JsonObject()).request().url().toString()); + assertEquals(url, service.sendFormRequest(url, "CLIENT", "CREATE", "default", null, Map.of()).request().url().toString()); + assertEquals(url, service.sendEmptyRequest(url).request().url().toString()); + } + + @Test + void keepsAPayloadUrlWithTrailingSlashAndQueryUnchanged() { + final String withSlash = "https://example.com/hook/"; + final String withQuery = "https://example.com/hooks/fineract?token=abc"; + + assertEquals(withSlash, processorHelper.createWebHookService(withSlash).sendEmptyRequest(withSlash).request().url().toString()); + assertEquals(withQuery, processorHelper.createWebHookService(withQuery).sendEmptyRequest(withQuery).request().url().toString()); + } + + @Test + void sendsTheSmsBridgeConfigurationToTheHostRoot() { + final WebHookService service = processorHelper.createWebHookService("https://bridge.example.com/sms"); + + assertEquals("https://bridge.example.com/configuration", + service.sendSmsBridgeConfigRequest(new HookSmsProviderData(Set.of())).request().url().toString()); + } + + @Test + void rejectsAnInvalidPayloadUrl() { + assertThrows(IllegalArgumentException.class, () -> processorHelper.createWebHookService("not a url")); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/JobRegisterServiceImplTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/JobRegisterServiceImplTest.java new file mode 100644 index 00000000000..8a643116a52 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/JobRegisterServiceImplTest.java @@ -0,0 +1,121 @@ +/** + * 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.jobs.service; + +import static org.apache.fineract.infrastructure.jobs.service.MissedRunsFixture.DAILY_AT_MIDNIGHT; +import static org.apache.fineract.infrastructure.jobs.service.MissedRunsFixture.midnightDaysAgo; +import static org.apache.fineract.infrastructure.jobs.service.MissedRunsFixture.missedDailyRuns; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.time.Duration; +import java.util.Date; +import org.apache.fineract.infrastructure.jobs.domain.ScheduledJobDetail; +import org.junit.jupiter.api.Test; + +class JobRegisterServiceImplTest { + + private static final int DAYS_DOWN = 3; + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + @Test + void resumeReplaysTheRunsSkippedWhileSuspendedWhenEnabled() throws Exception { + try (MissedRunsFixture fixture = new MissedRunsFixture(true)) { + final ScheduledJobDetail job = fixture.addJob(JobName.INCREASE_BUSINESS_DATE_BY_1_DAY, DAILY_AT_MIDNIGHT, + new Date(System.currentTimeMillis() + 3_600_000)); + fixture.boot(); + fixture.asRequest(fixture.jobRegisterService::pauseScheduler); + // what the fires vetoed during the suspension leave behind (see the restart test for the veto itself) + job.setNextRunTime(midnightDaysAgo(DAYS_DOWN)); + job.setTriggerMisfired(true); + + fixture.asRequest(fixture.jobRegisterService::startScheduler); + + await().atMost(TIMEOUT).untilAsserted(() -> assertThat(fixture.executionCount(JobName.INCREASE_BUSINESS_DATE_BY_1_DAY)) + .isEqualTo(missedDailyRuns(DAYS_DOWN))); + assertThat(job.isTriggerMisfired()).isFalse(); + await().atMost(TIMEOUT).untilAsserted(() -> assertThat(job.getNextRunTime()).isAfter(new Date())); + } + } + + @Test + void restartWhileSuspendedKeepsTheMissedRunsPendingUntilResume() throws Exception { + try (MissedRunsFixture fixture = new MissedRunsFixture(true)) { + fixture.schedulerDetail.setSuspended(true); + fixture.schedulerDetail.setResetSchedulerOnBootup(false); + final Date missedSince = midnightDaysAgo(DAYS_DOWN); + final ScheduledJobDetail job = fixture.addJob(JobName.INCREASE_BUSINESS_DATE_BY_1_DAY, DAILY_AT_MIDNIGHT, missedSince); + fixture.addJob(JobName.UPDATE_LOAN_ARREARS_AGEING, DAILY_AT_MIDNIGHT, missedSince); + + fixture.boot(); + + await().atMost(TIMEOUT).until(job::isTriggerMisfired); + await().during(Duration.ofSeconds(1)).atMost(TIMEOUT) + .untilAsserted(() -> assertThat(fixture.executions).as("everything is vetoed while suspended").isEmpty()); + assertThat(job.getNextRunTime()).as("a vetoed fire does not advance next_run_time").isEqualTo(missedSince); + + fixture.asRequest(fixture.jobRegisterService::startScheduler); + + await().atMost(TIMEOUT).untilAsserted(() -> { + assertThat(fixture.executionCount(JobName.INCREASE_BUSINESS_DATE_BY_1_DAY)).isEqualTo(missedDailyRuns(DAYS_DOWN)); + assertThat(fixture.executionCount(JobName.UPDATE_LOAN_ARREARS_AGEING)).isEqualTo(1); + }); + await().atMost(TIMEOUT).untilAsserted(() -> assertThat(job.getNextRunTime()).isAfter(new Date())); + assertThat(job.isTriggerMisfired()).isFalse(); + } + } + + @Test + void resumeDoesNotRegisterAJobAgainThatHasNothingMissed() throws Exception { + try (MissedRunsFixture fixture = new MissedRunsFixture(true)) { + fixture.schedulerDetail.setSuspended(true); + final ScheduledJobDetail job = fixture.addJob(JobName.UPDATE_LOAN_ARREARS_AGEING, DAILY_AT_MIDNIGHT, + new Date(System.currentTimeMillis() + 3_600_000)); + fixture.boot(); + final Date nextRunTime = job.getNextRunTime(); + + fixture.asRequest(fixture.jobRegisterService::startScheduler); + + await().during(Duration.ofSeconds(1)).atMost(TIMEOUT) + .untilAsserted(() -> assertThat(fixture.executionCount(JobName.UPDATE_LOAN_ARREARS_AGEING)).isZero()); + assertThat(job.getNextRunTime()).isEqualTo(nextRunTime); + } + } + + @Test + void resumeKeepsTheLegacyMisfiredReplayWhenDisabled() throws Exception { + try (MissedRunsFixture fixture = new MissedRunsFixture(false)) { + fixture.schedulerDetail.setExecuteInstructionForMisfiredJobs(true); + final ScheduledJobDetail job = fixture.addJob(JobName.UPDATE_LOAN_ARREARS_AGEING, DAILY_AT_MIDNIGHT, + midnightDaysAgo(DAYS_DOWN)); + fixture.boot(); + fixture.schedulerDetail.setSuspended(true); + job.setNextRunTime(midnightDaysAgo(DAYS_DOWN)); + job.setTriggerMisfired(true); + + fixture.asRequest(fixture.jobRegisterService::startScheduler); + + // The legacy replay is asynchronous and moves next_run_time before the replayed fire is vetoed or not + // (FINERACT-2918), so only its synchronous effects are asserted. + assertThat(fixture.schedulerDetail.isSuspended()).isFalse(); + assertThat(job.isTriggerMisfired()).isFalse(); + assertThat(job.getNextRunTime()).as("legacy resume takes the trigger's next fire time").isAfter(new Date()); + } + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/JobSchedulerServiceImplTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/JobSchedulerServiceImplTest.java new file mode 100644 index 00000000000..ea0b824ebce --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/JobSchedulerServiceImplTest.java @@ -0,0 +1,125 @@ +/** + * 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.jobs.service; + +import static org.apache.fineract.infrastructure.jobs.service.MissedRunsFixture.DAILY_AT_MIDNIGHT; +import static org.apache.fineract.infrastructure.jobs.service.MissedRunsFixture.midnightDaysAgo; +import static org.apache.fineract.infrastructure.jobs.service.MissedRunsFixture.missedDailyRuns; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.time.Duration; +import java.time.LocalDate; +import java.util.Date; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.fineract.infrastructure.jobs.domain.ScheduledJobDetail; +import org.junit.jupiter.api.Test; + +class JobSchedulerServiceImplTest { + + private static final int DAYS_DOWN = 3; + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + @Test + void bootReplaysEveryMissedBusinessDateRunWhenEnabled() throws Exception { + try (MissedRunsFixture fixture = new MissedRunsFixture(true)) { + final LocalDate start = LocalDate.of(2025, 1, 1); + final AtomicReference businessDate = new AtomicReference<>(start); + fixture.jobBody = job -> businessDate.updateAndGet(date -> date.plusDays(1)); + final ScheduledJobDetail job = fixture.addJob(JobName.INCREASE_BUSINESS_DATE_BY_1_DAY, DAILY_AT_MIDNIGHT, + midnightDaysAgo(DAYS_DOWN)); + + fixture.boot(); + + await().atMost(TIMEOUT).untilAsserted(() -> assertThat(fixture.executionCount(JobName.INCREASE_BUSINESS_DATE_BY_1_DAY)) + .isEqualTo(missedDailyRuns(DAYS_DOWN))); + assertThat(businessDate.get()).isEqualTo(start.plusDays(missedDailyRuns(DAYS_DOWN))); + await().atMost(TIMEOUT).untilAsserted(() -> assertThat(job.getNextRunTime()).isAfter(new Date())); + assertThat(job.isCurrentlyRunning()).isFalse(); + } + } + + @Test + void bootFiresOnlyOnceForJobsWithTheDefaultMisfirePolicy() throws Exception { + try (MissedRunsFixture fixture = new MissedRunsFixture(true)) { + final ScheduledJobDetail ageing = fixture.addJob(JobName.UPDATE_LOAN_ARREARS_AGEING, DAILY_AT_MIDNIGHT, + midnightDaysAgo(DAYS_DOWN)); + fixture.addJob(JobName.INCREASE_COB_DATE_BY_1_DAY, DAILY_AT_MIDNIGHT, midnightDaysAgo(DAYS_DOWN)); + + fixture.boot(); + + await().atMost(TIMEOUT).untilAsserted(() -> assertThat(ageing.getNextRunTime()).isAfter(new Date())); + // give a (wrong) second replay time to show up + await().during(Duration.ofSeconds(1)).atMost(TIMEOUT) + .untilAsserted(() -> assertThat(fixture.executionCount(JobName.UPDATE_LOAN_ARREARS_AGEING)).isEqualTo(1)); + assertThat(fixture.executionCount(JobName.INCREASE_COB_DATE_BY_1_DAY)).isEqualTo(1); + } + } + + @Test + void bootDoesNotReplayAnythingWhenDisabled() throws Exception { + try (MissedRunsFixture fixture = new MissedRunsFixture(false)) { + final ScheduledJobDetail job = fixture.addJob(JobName.INCREASE_BUSINESS_DATE_BY_1_DAY, DAILY_AT_MIDNIGHT, + midnightDaysAgo(DAYS_DOWN)); + + fixture.boot(); + + await().during(Duration.ofSeconds(2)).atMost(TIMEOUT) + .untilAsserted(() -> assertThat(fixture.executionCount(JobName.INCREASE_BUSINESS_DATE_BY_1_DAY)).isZero()); + assertThat(job.getNextRunTime()).isAfter(new Date()); + } + } + + @Test + void bootUnsuspendsTheTenantBeforeRegisteringSoReplayedRunsAreNotVetoed() throws Exception { + try (MissedRunsFixture fixture = new MissedRunsFixture(true)) { + fixture.schedulerDetail.setSuspended(true); + fixture.schedulerDetail.setResetSchedulerOnBootup(true); + fixture.addJob(JobName.INCREASE_BUSINESS_DATE_BY_1_DAY, DAILY_AT_MIDNIGHT, midnightDaysAgo(DAYS_DOWN)); + + fixture.boot(); + + await().atMost(TIMEOUT).untilAsserted(() -> assertThat(fixture.executionCount(JobName.INCREASE_BUSINESS_DATE_BY_1_DAY)) + .isEqualTo(missedDailyRuns(DAYS_DOWN))); + assertThat(fixture.schedulerDetail.isSuspended()).isFalse(); + } + } + + @Test + void aReplayedJobNeverRunsConcurrentlyWithItself() throws Exception { + try (MissedRunsFixture fixture = new MissedRunsFixture(true)) { + fixture.jobBody = job -> sleep(150); + fixture.addJob(JobName.INCREASE_BUSINESS_DATE_BY_1_DAY, DAILY_AT_MIDNIGHT, midnightDaysAgo(DAYS_DOWN)); + + fixture.boot(); + + await().atMost(TIMEOUT).untilAsserted(() -> assertThat(fixture.executionCount(JobName.INCREASE_BUSINESS_DATE_BY_1_DAY)) + .isEqualTo(missedDailyRuns(DAYS_DOWN))); + assertThat(fixture.maxConcurrentExecutions.get()).isEqualTo(1); + } + } + + private static void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/MissedRunsFixture.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/MissedRunsFixture.java new file mode 100644 index 00000000000..bb476e3e550 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/MissedRunsFixture.java @@ -0,0 +1,217 @@ +/** + * 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.jobs.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.businessdate.service.BusinessDateReadPlatformService; +import org.apache.fineract.infrastructure.core.config.FineractProperties; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.infrastructure.core.service.tenant.TenantDetailsService; +import org.apache.fineract.infrastructure.jobs.domain.ScheduledJobDetail; +import org.apache.fineract.infrastructure.jobs.domain.ScheduledJobDetailRepository; +import org.apache.fineract.infrastructure.jobs.domain.ScheduledJobRunHistoryRepository; +import org.apache.fineract.infrastructure.jobs.domain.SchedulerDetail; +import org.apache.fineract.infrastructure.jobs.domain.SchedulerDetailRepository; +import org.apache.fineract.infrastructure.jobs.service.jobname.JobNameData; +import org.apache.fineract.infrastructure.jobs.service.jobname.JobNameService; +import org.springframework.batch.core.configuration.JobRegistry; +import org.springframework.batch.core.job.Job; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * Wires the real {@link JobRegisterServiceImpl}, {@link JobSchedulerServiceImpl}, {@link SchedulerVetoer}, + * {@link SchedulerTriggerListener}, {@link SchedulerJobListener} and a real in-memory Quartz scheduler. Only the job + * table, the scheduler_detail row and the Spring Batch launch are replaced by in-memory stand-ins, so the tests observe + * what Quartz really fires and what the veto and the listener really do to {@code next_run_time}. + */ +final class MissedRunsFixture implements AutoCloseable { + + private static final AtomicLong TENANT_IDS = new AtomicLong(7000); + private static final AtomicLong JOB_IDS = new AtomicLong(1); + + final FineractPlatformTenant tenant; + final FineractProperties properties = new FineractProperties(); + final SchedulerDetail schedulerDetail = new SchedulerDetail(); + final List jobs = new CopyOnWriteArrayList<>(); + final JobRegisterServiceImpl jobRegisterService = new JobRegisterServiceImpl(); + final JobSchedulerServiceImpl jobSchedulerService; + final Map> executions = new ConcurrentHashMap<>(); + final AtomicInteger concurrentExecutions = new AtomicInteger(); + final AtomicInteger maxConcurrentExecutions = new AtomicInteger(); + volatile Consumer jobBody = job -> { // by default the job does nothing + }; + + private final java.util.TimeZone systemZone = java.util.TimeZone.getDefault(); + + MissedRunsFixture(boolean executeMissedRuns) throws Exception { + this(executeMissedRuns, "UTC"); + } + + MissedRunsFixture(boolean executeMissedRuns, String tenantZone) throws Exception { + final long tenantId = TENANT_IDS.incrementAndGet(); + tenant = new FineractPlatformTenant(tenantId, "tenant" + tenantId, "Tenant " + tenantId, tenantZone, null); + ThreadLocalContextUtil.setTenant(tenant); + + properties.setNodeId("1"); + final FineractProperties.FineractModeProperties mode = new FineractProperties.FineractModeProperties(); + mode.setBatchManagerEnabled(true); + properties.setMode(mode); + final FineractProperties.FineractJobProperties jobProperties = new FineractProperties.FineractJobProperties(); + jobProperties.setExecuteMissedRuns(executeMissedRuns); + properties.setJob(jobProperties); + + final ScheduledJobDetailRepository jobRepository = mock(ScheduledJobDetailRepository.class); + when(jobRepository.findAllJobs(anyInt())).thenAnswer(inv -> List.copyOf(jobs)); + when(jobRepository.findByJobKey(anyString())).thenAnswer(inv -> byJobKey(inv.getArgument(0))); + when(jobRepository.findByJobKeyWithLock(anyString())).thenAnswer(inv -> byJobKey(inv.getArgument(0))); + when(jobRepository.save(any(ScheduledJobDetail.class))).thenAnswer(inv -> inv.getArgument(0)); + final ScheduledJobRunHistoryRepository historyRepository = mock(ScheduledJobRunHistoryRepository.class); + final SchedulerDetailRepository schedulerDetailRepository = mock(SchedulerDetailRepository.class); + when(schedulerDetailRepository.findAllSchedulerDetails()).thenAnswer(inv -> List.of(schedulerDetail)); + final SchedularWritePlatformService writeService = new SchedularWritePlatformServiceJpaRepositoryImpl(jobRepository, + historyRepository, null, schedulerDetailRepository); + + final BusinessDateReadPlatformService businessDateService = mock(BusinessDateReadPlatformService.class); + when(businessDateService.getBusinessDates()).thenAnswer(inv -> new HashMap()); + final TenantDetailsService tenantDetailsService = mock(TenantDetailsService.class); + when(tenantDetailsService.loadTenantById(tenant.getTenantIdentifier())).thenReturn(tenant); + when(tenantDetailsService.findAllTenants()).thenReturn(List.of(tenant)); + + final JobNameService jobNameService = mock(JobNameService.class); + when(jobNameService.getJobByHumanReadableName(anyString())).thenAnswer(inv -> { + final String name = inv.getArgument(0); + final JobName jobName = Arrays.stream(JobName.values()).filter(candidate -> candidate.toString().equals(name)).findFirst() + .orElseThrow(); + return new JobNameData(jobName.name(), jobName.toString()); + }); + final JobRegistry jobRegistry = mock(JobRegistry.class); + when(jobRegistry.getJob(anyString())).thenReturn(mock(Job.class)); + + final JobStarter jobStarter = mock(JobStarter.class); + doAnswer(inv -> { + final ScheduledJobDetail job = inv.getArgument(1); + final int running = concurrentExecutions.incrementAndGet(); + maxConcurrentExecutions.accumulateAndGet(running, Math::max); + try { + executions.computeIfAbsent(job.getJobName(), name -> new CopyOnWriteArrayList<>()).add(Instant.now()); + jobBody.accept(job); + } finally { + concurrentExecutions.decrementAndGet(); + } + return null; + }).when(jobStarter).run(any(), any(), any(), anyString()); + + final SchedulerVetoer vetoer = new SchedulerVetoer(writeService, businessDateService); + ReflectionTestUtils.setField(jobRegisterService, "schedularWritePlatformService", writeService); + ReflectionTestUtils.setField(jobRegisterService, "schedulerJobListener", + new SchedulerJobListener(writeService, tenantDetailsService)); + ReflectionTestUtils.setField(jobRegisterService, "globalSchedulerTriggerListener", + new SchedulerTriggerListener(tenantDetailsService, vetoer)); + ReflectionTestUtils.setField(jobRegisterService, "fineractProperties", properties); + ReflectionTestUtils.setField(jobRegisterService, "jobRegistry", jobRegistry); + ReflectionTestUtils.setField(jobRegisterService, "jobStarter", jobStarter); + ReflectionTestUtils.setField(jobRegisterService, "jobNameService", jobNameService); + + jobSchedulerService = new JobSchedulerServiceImpl(properties, writeService, tenantDetailsService, jobRegisterService, + businessDateService); + } + + ScheduledJobDetail addJob(JobName jobName, String cronExpression, Date nextRunTime) { + final ScheduledJobDetail job = new ScheduledJobDetail(); + job.setId(JOB_IDS.getAndIncrement()); + job.setJobName(jobName.toString()); + job.setShortName(jobName.name()); + job.setGroupName("group"); + job.setCronExpression(cronExpression); + job.setTaskPriority((short) 5); + job.setSchedulerGroup((short) 0); + job.setNodeId(0); + job.setActiveSchedular(true); + job.setNextRunTime(nextRunTime); + jobs.add(job); + return job; + } + + /** A cron expression that fires every day at midnight, and the midnight {@code daysAgo} days back (UTC). */ + static final String DAILY_AT_MIDNIGHT = "0 0 0 * * ?"; + + static Date midnightDaysAgo(int daysAgo) { + return Date.from(Instant.now().truncatedTo(ChronoUnit.DAYS).minus(daysAgo, ChronoUnit.DAYS)); + } + + /** Number of midnights from {@code daysAgo} days back up to and including today's: what a daily job missed. */ + static int missedDailyRuns(int daysAgo) { + return daysAgo + 1; + } + + void boot() { + jobSchedulerService.onApplicationEvent(null); + } + + /** Runs {@code action} the way a REST call would: with the tenant bound to the thread. */ + void asRequest(Runnable action) { + ThreadLocalContextUtil.setTenant(tenant); + try { + action.run(); + } finally { + ThreadLocalContextUtil.reset(); + } + } + + int executionCount(JobName jobName) { + return executions.getOrDefault(jobName.toString(), List.of()).size(); + } + + /** The JVM default zone: what the column mapping of a Date uses. Restored on close. */ + void setSystemZone(String zoneId) { + java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone(zoneId)); + } + + @Override + public void close() { + java.util.TimeZone.setDefault(systemZone); + jobRegisterService.stopAllSchedulers(); + ThreadLocalContextUtil.reset(); + } + + private ScheduledJobDetail byJobKey(String jobKey) { + return jobs.stream().filter(job -> jobKey.equals(job.getJobKey())).findFirst().orElseThrow(); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/MissedRunsTimeZoneTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/MissedRunsTimeZoneTest.java new file mode 100644 index 00000000000..e2bb96cbcc3 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/service/MissedRunsTimeZoneTest.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.infrastructure.jobs.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.time.Duration; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import org.apache.fineract.infrastructure.jobs.domain.ScheduledJobDetail; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +/** + * next_run_time is an instant: whether it is "in the past" and where the trigger starts do not depend on the tenant's + * or the JVM's zone, while the cron runs in the tenant's zone. + */ +class MissedRunsTimeZoneTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + @ParameterizedTest + @CsvSource({ "America/New_York,Pacific/Auckland", "Pacific/Auckland,America/Los_Angeles", "Asia/Kolkata,UTC" }) + void aDailyCronOfTheTenantIsReplayedPerTenantMidnightWhateverTheSystemZone(String tenantZone, String systemZone) throws Exception { + try (MissedRunsFixture fixture = new MissedRunsFixture(true, tenantZone)) { + fixture.setSystemZone(systemZone); + final ZoneId tenant = ZoneId.of(tenantZone); + final ZonedDateTime firstMissed = ZonedDateTime.now(tenant).truncatedTo(ChronoUnit.DAYS).minusDays(3); + final ScheduledJobDetail job = fixture.addJob(JobName.INCREASE_BUSINESS_DATE_BY_1_DAY, "0 0 0 * * ?", + Date.from(firstMissed.toInstant())); + + fixture.boot(); + + await().atMost(TIMEOUT) + .untilAsserted(() -> assertThat(fixture.executionCount(JobName.INCREASE_BUSINESS_DATE_BY_1_DAY)).isEqualTo(4)); + await().atMost(TIMEOUT).untilAsserted(() -> assertThat(job.getNextRunTime()).isAfter(new Date())); + assertThat(job.getNextRunTime().toInstant().atZone(tenant).toLocalTime()).as("next run is the tenant's midnight") + .isEqualTo(java.time.LocalTime.MIDNIGHT); + } + } + + @Test + void theRepeatedHourOfADaylightSavingEndFiresOnce() throws Exception { + final ZoneId newYork = ZoneId.of("America/New_York"); + try (MissedRunsFixture fixture = new MissedRunsFixture(true, "America/New_York")) { + fixture.setSystemZone("Pacific/Auckland"); + // 2025-11-02 01:30 happens twice in New York + fixture.addJob(JobName.INCREASE_BUSINESS_DATE_BY_1_DAY, "0 30 1 1,2 11 ? 2025", + Date.from(ZonedDateTime.of(2025, 11, 1, 1, 30, 0, 0, newYork).toInstant())); + + fixture.boot(); + + await().during(Duration.ofSeconds(1)).atMost(TIMEOUT) + .untilAsserted(() -> assertThat(fixture.executionCount(JobName.INCREASE_BUSINESS_DATE_BY_1_DAY)).isEqualTo(2)); + } + } + + @Test + void aLocalTimeThatDoesNotExistOnTheDaylightSavingStartDayWasNeverScheduledSoItIsNotReplayed() throws Exception { + final ZoneId newYork = ZoneId.of("America/New_York"); + try (MissedRunsFixture fixture = new MissedRunsFixture(true, "America/New_York")) { + fixture.setSystemZone("Pacific/Auckland"); + // 2026-03-08 02:30 does not exist in New York: Quartz never fires the cron that day, so only 03-09 is + // missed + fixture.addJob(JobName.INCREASE_BUSINESS_DATE_BY_1_DAY, "0 30 2 8,9 3 ? 2026", + Date.from(ZonedDateTime.of(2026, 3, 8, 0, 0, 0, 0, newYork).toInstant())); + + fixture.boot(); + + await().during(Duration.ofSeconds(1)).atMost(TIMEOUT) + .untilAsserted(() -> assertThat(fixture.executionCount(JobName.INCREASE_BUSINESS_DATE_BY_1_DAY)).isEqualTo(1)); + } + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/collateralmanagement/domain/ClientCollateralManagementRepositoryWrapperTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/collateralmanagement/domain/ClientCollateralManagementRepositoryWrapperTest.java new file mode 100644 index 00000000000..612b12a0adf --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/collateralmanagement/domain/ClientCollateralManagementRepositoryWrapperTest.java @@ -0,0 +1,99 @@ +/** + * 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.collateralmanagement.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; +import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.apache.fineract.portfolio.collateralmanagement.data.ClientCollateralManagementData; +import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct; +import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ClientCollateralManagementRepositoryWrapperTest { + + private static final Long CLIENT_ID = 2L; + private static final Long PRODUCT_ID = 1L; + + @Mock + private ClientCollateralManagementRepository clientCollateralManagementRepository; + + @Mock + private ClientRepositoryWrapper clientRepositoryWrapper; + + @Mock + private LoanProductRepository loanProductRepository; + + @Mock + private Client client; + + private ClientCollateralManagementRepositoryWrapper wrapper; + + @BeforeEach + void setUp() { + wrapper = new ClientCollateralManagementRepositoryWrapper(clientCollateralManagementRepository, clientRepositoryWrapper, + loanProductRepository); + when(clientRepositoryWrapper.findOneWithNotFoundDetection(CLIENT_ID)).thenReturn(client); + final List collaterals = List.of(collateral(10L, "Gold", "USD"), collateral(11L, "Car", "EUR")); + when(clientCollateralManagementRepository.findByClientId(client)).thenReturn(collaterals); + } + + @Test + void listsEveryCollateralWhenNoLoanProductIsGiven() { + final List collaterals = wrapper.getClientCollateralData(CLIENT_ID, null); + + assertEquals(List.of(10L, 11L), collaterals.stream().map(ClientCollateralManagementData::getId).toList()); + } + + @Test + void listsOnlyCollateralsInTheLoanProductCurrency() { + final LoanProduct loanProduct = mock(LoanProduct.class, RETURNS_DEEP_STUBS); + when(loanProduct.getCurrency().getCode()).thenReturn("USD"); + when(loanProductRepository.findById(PRODUCT_ID)).thenReturn(Optional.of(loanProduct)); + + final List collaterals = wrapper.getClientCollateralData(CLIENT_ID, PRODUCT_ID); + + assertEquals(List.of(10L), collaterals.stream().map(ClientCollateralManagementData::getId).toList()); + } + + private static ClientCollateralManagement collateral(final Long id, final String name, final String currencyCode) { + final ClientCollateralManagement collateral = mock(ClientCollateralManagement.class, RETURNS_DEEP_STUBS); + when(collateral.getQuantity()).thenReturn(BigDecimal.ONE); + when(collateral.getTotal()).thenReturn(BigDecimal.TEN); + // Lenient: the currency is only read when a loan product is given, and the id and name only for listed + // collaterals. + lenient().when(collateral.getId()).thenReturn(id); + lenient().when(collateral.getCollaterals().getName()).thenReturn(name); + lenient().when(collateral.getCollaterals().getCurrency().getCode()).thenReturn(currencyCode); + return collateral; + } +} diff --git a/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/api/TaxGroupApiResourceSwagger.java b/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/api/TaxGroupApiResourceSwagger.java index 9051366cb42..6c6926b689d 100644 --- a/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/api/TaxGroupApiResourceSwagger.java +++ b/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/api/TaxGroupApiResourceSwagger.java @@ -56,6 +56,8 @@ private GetTaxesGroupTaxComponent() {} public LocalDate startDate; @Schema(example = "[2016, 4, 11]") public LocalDate endDate; + @Schema(example = "true", description = "Only present on the template response. True if the group is editable and this component's start date is still in the future, so it can be moved.") + public Boolean componentEditable; } @Schema(example = "7") @@ -65,6 +67,8 @@ private GetTaxesGroupTaxComponent() {} public Set taxAssociations; @Schema(example = "[2016, 4, 11]") public LocalDate businessDate; + @Schema(example = "true", description = "Only present on the template response. False if the group is linked to a charge, savings product or savings account.") + public Boolean groupEditable; } @Schema(description = "PostTaxesGroupRequest") @@ -113,6 +117,8 @@ private PutTaxesGroupTaxComponents() {} public Long id; @Schema(example = "7") public Long taxComponentId; + @Schema(example = "22 April 2016", description = "Only accepted when the group is not linked and this component's current start date is still in the future. Must be today or later and before the component's end date.") + public String startDate; @Schema(example = "22 April 2016") public String endDate; } @@ -139,6 +145,8 @@ static final class PutTaxesGroupModifiedComponents { private PutTaxesGroupModifiedComponents() {} + @Schema(example = "[2016, 4, 22]") + public LocalDate startDate; @Schema(example = "[2016, 4, 22]") public LocalDate endDate; @Schema(example = "7") diff --git a/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/domain/TaxGroup.java b/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/domain/TaxGroup.java index 0e75a75171b..098ee7190e0 100644 --- a/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/domain/TaxGroup.java +++ b/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/domain/TaxGroup.java @@ -80,7 +80,7 @@ public Map update(final JsonCommand command, final Set> changes) { + public void update(final LocalDate startDate, final LocalDate endDate, final List> changes) { + if (startDate != null && !DateUtils.isEqual(startDate, this.startDate)) { + this.startDate = startDate; + Map map = new HashMap<>(2); + map.put(TaxApiConstants.startDateParamName, startDate); + map.put(TaxApiConstants.taxComponentIdParamName, this.getTaxComponent().getId()); + changes.add(map); + } if (endDate != null && !DateUtils.isEqual(endDate, this.endDate)) { this.endDate = endDate; Map map = new HashMap<>(2); diff --git a/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/domain/TaxGroupRepository.java b/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/domain/TaxGroupRepository.java index 99345412485..30d92202f08 100644 --- a/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/domain/TaxGroupRepository.java +++ b/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/domain/TaxGroupRepository.java @@ -20,7 +20,16 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; public interface TaxGroupRepository extends JpaRepository, JpaSpecificationExecutor { + @Query(value = "select count(*) from (select tax_group_id from m_charge union all select tax_group_id from m_savings_product" + + " union all select tax_group_id from m_savings_account) links where links.tax_group_id = ?1", nativeQuery = true) + long countLinks(Long taxGroupId); + + default boolean isLinked(final Long taxGroupId) { + return countLinks(taxGroupId) > 0; + } + } diff --git a/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/mapper/TaxGroupMapper.java b/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/mapper/TaxGroupMapper.java index 3ed8965f242..10952d65084 100644 --- a/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/mapper/TaxGroupMapper.java +++ b/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/mapper/TaxGroupMapper.java @@ -31,6 +31,7 @@ public interface TaxGroupMapper { @Mapping(target = "taxAssociations", source = "taxGroup.taxGroupMappings") @Mapping(target = "taxComponents", ignore = true) @Mapping(target = "businessDate", ignore = true) + @Mapping(target = "groupEditable", ignore = true) TaxGroupData map(TaxGroup taxGroup); List map(List taxGroups); diff --git a/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/mapper/TaxGroupMappingsMapper.java b/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/mapper/TaxGroupMappingsMapper.java index 4afc643fe3a..4ff78a70c92 100644 --- a/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/mapper/TaxGroupMappingsMapper.java +++ b/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/mapper/TaxGroupMappingsMapper.java @@ -23,10 +23,12 @@ import org.apache.fineract.portfolio.tax.data.TaxGroupMappingsData; import org.apache.fineract.portfolio.tax.domain.TaxGroupMappings; import org.mapstruct.Mapper; +import org.mapstruct.Mapping; @Mapper(config = MapstructMapperConfig.class, uses = { TaxComponentMapper.class }) public interface TaxGroupMappingsMapper { + @Mapping(target = "componentEditable", ignore = true) TaxGroupMappingsData map(TaxGroupMappings taxGroupMapping); List map(List taxGroupMappings); diff --git a/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/serialization/TaxValidator.java b/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/serialization/TaxValidator.java index b561ae620b8..252d9ab95a6 100644 --- a/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/serialization/TaxValidator.java +++ b/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/serialization/TaxValidator.java @@ -283,15 +283,37 @@ public void validateForTaxGroupUpdate(final String json) { throwExceptionIfValidationWarningsExist(dataValidationErrors); } - public void validateTaxGroupEndDateAndTaxComponent(final TaxGroup taxGroup, final Set groupMappings) { + public void validateTaxGroupEndDateAndTaxComponent(final TaxGroup taxGroup, final Set groupMappings, + final boolean linked) { final List dataValidationErrors = new ArrayList<>(); final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource(TAX_GROUP); + final LocalDate today = DateUtils.getBusinessLocalDate(); for (TaxGroupMappings mapping : groupMappings) { if (mapping.getId() != null) { TaxGroupMappings existing = taxGroup.findOneBy(mapping); - baseDataValidator.reset().parameter(TaxApiConstants.endDateParamName).value(mapping.endDate()).ignoreIfNull() - .validateDateAfter(existing.startDate()); + LocalDate effectiveStartDate = existing.startDate(); + if (mapping.startDate() != null && !DateUtils.isEqual(mapping.startDate(), existing.startDate())) { + if (linked) { + baseDataValidator.reset().parameter(TaxApiConstants.startDateParamName) + .failWithCode("cannot.be.modified.for.linked.tax.group"); + } else if (!DateUtils.isAfter(existing.startDate(), today)) { + baseDataValidator.reset().parameter(TaxApiConstants.startDateParamName) + .failWithCode("cannot.be.modified.after.activation"); + } else { + baseDataValidator.reset().parameter(TaxApiConstants.startDateParamName).value(mapping.startDate()) + .validateDateAfterOrEqual(today); + effectiveStartDate = mapping.startDate(); + } + } + final LocalDate effectiveEndDate = mapping.endDate() != null ? mapping.endDate() : existing.endDate(); + if (mapping.endDate() == null && effectiveEndDate != null && !DateUtils.isBefore(effectiveStartDate, effectiveEndDate)) { + baseDataValidator.reset().parameter(TaxApiConstants.startDateParamName).value(effectiveStartDate) + .failWithCode("must.be.before.end.date", effectiveEndDate); + } else { + baseDataValidator.reset().parameter(TaxApiConstants.endDateParamName).value(mapping.endDate()).ignoreIfNull() + .validateDateAfter(effectiveStartDate); + } if (mapping.getTaxComponent() != null && !existing.getTaxComponent().getId().equals(mapping.getTaxComponent().getId())) { baseDataValidator.reset().parameter(TaxApiConstants.taxComponentIdParamName).failWithCode("update.not.supported"); } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/MakercheckerTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/MakercheckerTest.java index 817c6e4bde9..c03a68f231c 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/MakercheckerTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/MakercheckerTest.java @@ -428,6 +428,48 @@ public void testDuplicatePendingSubmissionAndCheckerOnlyInitiationAreRefusedWith } } + @Test + public void testCheckerOnlyUserApprovesAnIdenticalPendingSubmissionThroughTheActionEndpoint() { + globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.MAKER_CHECKER, + new PutGlobalConfigurationsRequest().enabled(true)); + try { + Integer clientId = Utils.performServerPost(requestSpec, responseSpec, CLIENTS_URL + "?" + Utils.TENANT_IDENTIFIER, + ClientHelper.getTestClientAsJSONPending(START_DATE_STRING, "1"), "clientId"); + rolesHelper.updatePermissions(new PutPermissionsRequest().putPermissionsItem("ACTIVATE_CLIENT", true)); + + Integer makerRoleId = RolesHelper.createRole(requestSpec, responseSpec); + RolesHelper.addPermissionsToRole(requestSpec, responseSpec, makerRoleId, Map.of("ACTIVATE_CLIENT", true)); + Integer checkerOnlyRoleId = RolesHelper.createRole(requestSpec, responseSpec); + RolesHelper.addPermissionsToRole(requestSpec, responseSpec, checkerOnlyRoleId, Map.of("ACTIVATE_CLIENT_CHECKER", true)); + Integer staffId = StaffHelper.createStaff(requestSpec, responseSpec); + String password = "A1b2c3d4e5f$"; + String maker = Utils.uniqueRandomStringGenerator("user", 8); + Integer makerUserId = (Integer) UserHelper.createUser(requestSpec, responseSpec, makerRoleId, staffId, maker, password, + "resourceId"); + String checkerOnly = Utils.uniqueRandomStringGenerator("user", 8); + UserHelper.createUser(requestSpec, responseSpec, checkerOnlyRoleId, staffId, checkerOnly, password, "resourceId"); + RequestSpecification checkerOnlySpec = userRequestSpec(checkerOnly, password); + ResponseSpecification forbidden = new ResponseSpecBuilder().expectStatusCode(403).build(); + + assertNull(activateClient(userRequestSpec(maker, password), responseSpec, clientId, TRANSACTION_DATE_STRING, "clientId"), + "The maker's activation should be queued for approval"); + + activateClient(checkerOnlySpec, forbidden, clientId, "06 June 2023", ""); + + assertEquals(clientId, activateClient(checkerOnlySpec, responseSpec, clientId, TRANSACTION_DATE_STRING, "clientId")); + assertEquals(Boolean.TRUE, Utils.performServerGet(requestSpec, responseSpec, + CLIENTS_URL + "/" + clientId + "?" + Utils.TENANT_IDENTIFIER, "active")); + assertEquals(0, + makercheckersHelper.getMakerCheckerList( + Map.of("actionName", "ACTIVATE", "entityName", "CLIENT", "makerId", makerUserId.toString())).size(), + "The pending activation should have been approved"); + } finally { + globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.MAKER_CHECKER, + new PutGlobalConfigurationsRequest().enabled(false)); + rolesHelper.updatePermissions(new PutPermissionsRequest().putPermissionsItem("ACTIVATE_CLIENT", false)); + } + } + private static T activateClient(RequestSpecification requestSpec, ResponseSpecification responseSpec, Integer clientId, String activationDate, String jsonAttributeToGetBack) { String body = "{\"activationDate\":\"" + activationDate + "\",\"dateFormat\":\"dd MMMM yyyy\",\"locale\":\"en\"}"; diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/TaxGroupComponentStartDateTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/TaxGroupComponentStartDateTest.java new file mode 100644 index 00000000000..819cae8d4b8 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/TaxGroupComponentStartDateTest.java @@ -0,0 +1,215 @@ +/** + * 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.integrationtests; + +import static org.apache.fineract.client.feign.util.FeignCalls.fail; +import static org.apache.fineract.client.feign.util.FeignCalls.ok; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import org.apache.fineract.client.feign.FineractFeignClient; +import org.apache.fineract.client.feign.util.CallFailedRuntimeException; +import org.apache.fineract.client.models.ChargeRequest; +import org.apache.fineract.client.models.GetTaxesGroupResponse; +import org.apache.fineract.client.models.GetTaxesGroupTaxAssociations; +import org.apache.fineract.client.models.PostTaxesComponentsRequest; +import org.apache.fineract.client.models.PostTaxesGroupRequest; +import org.apache.fineract.client.models.PostTaxesGroupTaxComponents; +import org.apache.fineract.client.models.PutTaxesGroupTaxComponents; +import org.apache.fineract.client.models.PutTaxesGroupTaxGroupIdRequest; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignChargesHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.TaxGroupTemplateApi; +import org.apache.fineract.integrationtests.common.BusinessDateHelper; +import org.apache.fineract.integrationtests.common.FineractFeignClientHelper; +import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.integrationtests.common.accounting.Account; +import org.apache.fineract.integrationtests.common.accounting.AccountHelper; +import org.apache.fineract.portfolio.charge.domain.ChargeCalculationType; +import org.apache.fineract.portfolio.charge.domain.ChargePaymentMode; +import org.apache.fineract.portfolio.charge.domain.ChargeTimeType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class TaxGroupComponentStartDateTest { + + private FineractFeignClient fineractClient; + private TaxGroupTemplateApi taxGroupTemplateApi; + + @BeforeEach + public void setup() { + fineractClient = FineractFeignClientHelper.getFineractFeignClient(); + taxGroupTemplateApi = fineractClient.create(TaxGroupTemplateApi.class); + } + + @Test + void updateTaxGroup_changesStartDateOfComponentNotYetEffective() { + BusinessDateHelper.runAt("01 January 2024", () -> { + final Long taxComponentId = createTaxComponent(); + final Long taxGroupId = createTaxGroup(taxComponentId, "01 March 2024"); + final Long mappingId = association(taxGroupId, taxComponentId).getId(); + + ok(() -> fineractClient.taxGroup().updateTaxGroup(taxGroupId, updateRequest( + new PutTaxesGroupTaxComponents().id(mappingId).taxComponentId(taxComponentId).startDate("15 March 2024")))); + + final GetTaxesGroupTaxAssociations association = association(taxGroupId, taxComponentId); + assertEquals(mappingId, association.getId()); + assertEquals(LocalDate.of(2024, 3, 15), association.getStartDate()); + }); + } + + @Test + void updateTaxGroup_rejectsStartDateChangeOfEffectiveComponent() { + BusinessDateHelper.runAt("01 January 2024", () -> { + final Long taxComponentId = createTaxComponent(); + final Long taxGroupId = createTaxGroup(taxComponentId, "01 January 2023"); + final Long mappingId = association(taxGroupId, taxComponentId).getId(); + + final CallFailedRuntimeException exception = fail(() -> fineractClient.taxGroup().updateTaxGroup(taxGroupId, updateRequest( + new PutTaxesGroupTaxComponents().id(mappingId).taxComponentId(taxComponentId).startDate("15 March 2024")))); + + assertEquals(400, exception.getStatus()); + assertTrue(exception.getMessage().contains("cannot.be.modified.after.activation")); + assertEquals(LocalDate.of(2023, 1, 1), association(taxGroupId, taxComponentId).getStartDate()); + }); + } + + @Test + void updateTaxGroup_rejectsStartDateOnOrAfterEndDate() { + BusinessDateHelper.runAt("01 January 2024", () -> { + final Long taxComponentId = createTaxComponent(); + final Long taxGroupId = createTaxGroup(taxComponentId, "01 March 2024"); + final Long mappingId = association(taxGroupId, taxComponentId).getId(); + ok(() -> fineractClient.taxGroup().updateTaxGroup(taxGroupId, + updateRequest(new PutTaxesGroupTaxComponents().id(mappingId).taxComponentId(taxComponentId).endDate("01 June 2024")))); + + final CallFailedRuntimeException exception = fail(() -> fineractClient.taxGroup().updateTaxGroup(taxGroupId, updateRequest( + new PutTaxesGroupTaxComponents().id(mappingId).taxComponentId(taxComponentId).startDate("15 June 2024")))); + + assertEquals(400, exception.getStatus()); + assertTrue(exception.getMessage().contains("must.be.before.end.date")); + final GetTaxesGroupTaxAssociations association = association(taxGroupId, taxComponentId); + assertEquals(LocalDate.of(2024, 3, 1), association.getStartDate()); + assertEquals(LocalDate.of(2024, 6, 1), association.getEndDate()); + }); + } + + @Test + void updateTaxGroup_rejectsStartDateChangeOfLinkedGroup() { + BusinessDateHelper.runAt("01 January 2024", () -> { + final Long taxComponentId = createTaxComponent(); + final Long taxGroupId = createTaxGroup(taxComponentId, "01 March 2024"); + final Long mappingId = association(taxGroupId, taxComponentId).getId(); + linkToCharge(taxGroupId); + + final CallFailedRuntimeException exception = fail(() -> fineractClient.taxGroup().updateTaxGroup(taxGroupId, updateRequest( + new PutTaxesGroupTaxComponents().id(mappingId).taxComponentId(taxComponentId).startDate("15 March 2024")))); + + assertEquals(400, exception.getStatus()); + assertTrue(exception.getMessage().contains("cannot.be.modified.for.linked.tax.group")); + assertEquals(LocalDate.of(2024, 3, 1), association(taxGroupId, taxComponentId).getStartDate()); + }); + } + + @Test + void updateTaxGroup_addsNewComponentWithPastStartDate() { + BusinessDateHelper.runAt("01 January 2024", () -> { + final Long taxComponentId = createTaxComponent(); + final Long taxGroupId = createTaxGroup(taxComponentId, "01 March 2024"); + final Long newTaxComponentId = createTaxComponent(); + + ok(() -> fineractClient.taxGroup().updateTaxGroup(taxGroupId, + updateRequest(new PutTaxesGroupTaxComponents().taxComponentId(newTaxComponentId).startDate("01 June 2023")))); + + assertEquals(LocalDate.of(2023, 6, 1), association(taxGroupId, newTaxComponentId).getStartDate()); + }); + } + + @Test + void retrieveTaxGroupTemplate_unlinkedGroupFlagsComponentsByStartDate() { + BusinessDateHelper.runAt("01 January 2024", () -> { + final Long futureComponentId = createTaxComponent(); + final Long taxGroupId = createTaxGroup(futureComponentId, "01 March 2024"); + final Long effectiveComponentId = createTaxComponent(); + ok(() -> fineractClient.taxGroup().updateTaxGroup(taxGroupId, + updateRequest(new PutTaxesGroupTaxComponents().taxComponentId(effectiveComponentId).startDate("01 June 2023")))); + + final GetTaxesGroupResponse template = ok(() -> taxGroupTemplateApi.retrieveTaxGroupWithTemplate(taxGroupId)); + + assertTrue(template.getGroupEditable()); + assertTrue(association(template, futureComponentId).getComponentEditable()); + assertFalse(association(template, effectiveComponentId).getComponentEditable()); + }); + } + + @Test + void retrieveTaxGroupTemplate_linkedGroupIsNotEditable() { + BusinessDateHelper.runAt("01 January 2024", () -> { + final Long taxComponentId = createTaxComponent(); + final Long taxGroupId = createTaxGroup(taxComponentId, "01 March 2024"); + linkToCharge(taxGroupId); + + final GetTaxesGroupResponse template = ok(() -> taxGroupTemplateApi.retrieveTaxGroupWithTemplate(taxGroupId)); + + assertFalse(template.getGroupEditable()); + assertFalse(association(template, taxComponentId).getComponentEditable()); + }); + } + + private Long createTaxComponent() { + final Account glAccount = AccountHelper.createLiabilityGlAccount("taxComponent"); + final PostTaxesComponentsRequest request = new PostTaxesComponentsRequest().name(Utils.randomStringGenerator("TAX_CMP_", 6)) + .percentage(5.0f).startDate("01 January 2023").creditAccountType(Integer.valueOf(glAccount.getAccountType().toString())) + .creditAccountId(glAccount.getAccountID().longValue()).dateFormat(Utils.DATE_FORMAT).locale(Utils.LOCALE); + return ok(() -> fineractClient.taxComponents().createTaxComponent(request)).getResourceId(); + } + + private Long createTaxGroup(final Long taxComponentId, final String startDate) { + final PostTaxesGroupRequest request = new PostTaxesGroupRequest().name(Utils.randomStringGenerator("TAX_GRP_", 6)) + .taxComponents(Set.of(new PostTaxesGroupTaxComponents().taxComponentId(taxComponentId).startDate(startDate))) + .dateFormat(Utils.DATE_FORMAT).locale(Utils.LOCALE); + return ok(() -> fineractClient.taxGroup().createTaxGroup(request)).getResourceId(); + } + + private void linkToCharge(final Long taxGroupId) { + new FeignChargesHelper(fineractClient).createCharge(new ChargeRequest().penalty(false).amount(10.0) + .chargeCalculationType(ChargeCalculationType.FLAT.getValue()).chargeTimeType(ChargeTimeType.SPECIFIED_DUE_DATE.getValue()) + .chargePaymentMode(ChargePaymentMode.REGULAR.getValue()).currencyCode("USD") + .name(Utils.uniqueRandomStringGenerator("CHARGE_", 6)).chargeAppliesTo(1).locale(Utils.LOCALE).active(true) + .taxGroupId(taxGroupId)); + } + + private GetTaxesGroupTaxAssociations association(final Long taxGroupId, final Long taxComponentId) { + return association(ok(() -> fineractClient.taxGroup().retrieveOneTaxGroup(taxGroupId)), taxComponentId); + } + + private GetTaxesGroupTaxAssociations association(final GetTaxesGroupResponse taxGroup, final Long taxComponentId) { + return taxGroup.getTaxAssociations().stream().filter(a -> taxComponentId.equals(a.getTaxComponent().getId())).findFirst() + .orElseThrow(); + } + + private PutTaxesGroupTaxGroupIdRequest updateRequest(final PutTaxesGroupTaxComponents... taxComponents) { + return new PutTaxesGroupTaxGroupIdRequest().taxComponents(new HashSet<>(Arrays.asList(taxComponents))).dateFormat(Utils.DATE_FORMAT) + .locale(Utils.LOCALE); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/TaxGroupTemplateApi.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/TaxGroupTemplateApi.java new file mode 100644 index 00000000000..833e96d9656 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/TaxGroupTemplateApi.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.integrationtests.client.feign.helpers; + +import feign.Headers; +import feign.Param; +import feign.RequestLine; +import org.apache.fineract.client.models.GetTaxesGroupResponse; + +@Headers({ "Accept: application/json", "Content-Type: application/json" }) +public interface TaxGroupTemplateApi { + + @RequestLine("GET /v1/taxes/group/{taxGroupId}?template=true") + GetTaxesGroupResponse retrieveTaxGroupWithTemplate(@Param("taxGroupId") Long taxGroupId); +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignClientEntityDatatableCheckSubtypeTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignClientEntityDatatableCheckSubtypeTest.java new file mode 100644 index 00000000000..16ece0de46d --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignClientEntityDatatableCheckSubtypeTest.java @@ -0,0 +1,106 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.integrationtests.client.feign.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.apache.fineract.client.feign.FineractFeignClient; +import org.apache.fineract.client.feign.util.CallFailedRuntimeException; +import org.apache.fineract.client.models.PostClientsResponse; +import org.apache.fineract.client.models.PostColumnHeaderData; +import org.apache.fineract.client.models.PostDataTablesRequest; +import org.apache.fineract.integrationtests.client.FeignIntegrationTest; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignClientHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignDatatableHelper; +import org.apache.fineract.integrationtests.client.feign.modules.ClientRequestBuilders; +import org.apache.fineract.integrationtests.common.FineractFeignClientHelper; +import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.integrationtests.common.organisation.EntityDatatableChecksHelper; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Entity Data Table Checks on {@code m_client} must be enforced whatever case the datatable's entity subtype was + * registered with ({@code "Person"} is the documented value). + */ +public class FeignClientEntityDatatableCheckSubtypeTest extends FeignIntegrationTest { + + private static final String CLIENT_APP_TABLE_NAME = "m_client"; + private static final long STATUS_CREATE = 100L; + private static final long STATUS_ACTIVATE = 300L; + + private static FeignClientHelper clientHelper; + private static FeignDatatableHelper datatableHelper; + + @BeforeAll + public static void setup() { + FineractFeignClient fineractClient = FineractFeignClientHelper.getFineractFeignClient(); + clientHelper = new FeignClientHelper(fineractClient); + datatableHelper = new FeignDatatableHelper(fineractClient); + } + + @Test + void createPersonClientIsRejectedWhenCheckDatatableHasMixedCaseSubtype() { + String datatableName = createClientDatatable("Person"); + Long checkId = EntityDatatableChecksHelper.createEntityDatatableCheck(CLIENT_APP_TABLE_NAME, datatableName, STATUS_CREATE, null) + .getResourceId(); + try { + String today = Utils.dateFormatter.format(Utils.getLocalDateOfTenant()); + CallFailedRuntimeException failure = clientHelper.createClientExpectingError(ClientRequestBuilders.createPendingClient(today)); + + assertEquals(403, failure.getStatus()); + assertTrue(failure.getResponseBody().contains("error.msg.entry.required.in.datatable"), failure.getResponseBody()); + } finally { + EntityDatatableChecksHelper.deleteEntityDatatableCheck(checkId); + datatableHelper.deleteDatatable(datatableName); + } + } + + @Test + void activatePersonClientIsRejectedWhenCheckDatatableHasMixedCaseSubtype() { + String today = Utils.dateFormatter.format(Utils.getLocalDateOfTenant()); + PostClientsResponse pending = clientHelper.createClientPending(today); + + String datatableName = createClientDatatable("Person"); + Long checkId = EntityDatatableChecksHelper.createEntityDatatableCheck(CLIENT_APP_TABLE_NAME, datatableName, STATUS_ACTIVATE, null) + .getResourceId(); + try { + CallFailedRuntimeException failure = clientHelper.activateClientExpectingError(pending.getClientId(), + ClientRequestBuilders.activateClient(today)); + + assertEquals(403, failure.getStatus()); + assertTrue(failure.getResponseBody().contains("error.msg.entry.required.in.datatable"), failure.getResponseBody()); + } finally { + EntityDatatableChecksHelper.deleteEntityDatatableCheck(checkId); + datatableHelper.deleteDatatable(datatableName); + } + } + + private String createClientDatatable(String entitySubType) { + PostDataTablesRequest request = new PostDataTablesRequest()// + .datatableName(Utils.uniqueRandomStringGenerator("dt_client_subtype_", 5).toLowerCase())// + .apptableName(CLIENT_APP_TABLE_NAME)// + .entitySubType(entitySubType)// + .multiRow(false)// + .columns(List.of(new PostColumnHeaderData().name("note").type("String").length(50L).mandatory(true))); + return datatableHelper.createDatatable(request).getResourceIdentifier(); + } +}