Copilot commented on code in PR #5424: URL: https://github.com/apache/fineract/pull/5424#discussion_r2747331257
########## fineract-accounting/src/main/java/org/apache/fineract/accounting/reconciliation/serialization/ReconciliationDataValidator.java: ########## @@ -0,0 +1,286 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.reconciliation.serialization; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.reflect.TypeToken; +import java.lang.reflect.Type; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; +import org.apache.fineract.infrastructure.core.exception.InvalidJsonException; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ReconciliationDataValidator { + + private final FromJsonHelper fromApiJsonHelper; + + private static final String GL_ACCOUNT_ID = "glAccountId"; + private static final String OFFICE_ID = "officeId"; + private static final String STATEMENT_DATE = "statementDate"; + private static final String OPENING_BALANCE = "openingBalance"; + private static final String CLOSING_BALANCE = "closingBalance"; + private static final String FILE_NAME = "fileName"; + private static final String FILE_TYPE = "fileType"; + private static final String NOTES = "notes"; + private static final String LOCALE = "locale"; + private static final String DATE_FORMAT = "dateFormat"; + private static final String TRANSACTIONS = "transactions"; + private static final String TRANSACTION_DATE = "transactionDate"; + private static final String VALUE_DATE = "valueDate"; + private static final String DESCRIPTION = "description"; + private static final String REFERENCE_NUMBER = "referenceNumber"; + private static final String CHECK_NUMBER = "checkNumber"; + private static final String DEBIT_AMOUNT = "debitAmount"; + private static final String CREDIT_AMOUNT = "creditAmount"; + private static final String BALANCE = "balance"; + private static final String TRANSACTION_TYPE = "transactionType"; + private static final String BANK_TRANSACTION_ID = "bankTransactionId"; + private static final String GL_JOURNAL_ENTRY_ID = "glJournalEntryId"; + private static final String MATCH_TYPE = "matchType"; + private static final String ADJUSTMENT_TYPE = "adjustmentType"; + private static final String AMOUNT = "amount"; + private static final String GL_ACCOUNT_DEBIT = "glAccountDebit"; + private static final String GL_ACCOUNT_CREDIT = "glAccountCredit"; + private static final String NAME = "name"; + private static final String MATCH_CONDITION = "matchCondition"; + private static final String CONDITION_VALUE = "conditionValue"; + private static final String DATE_TOLERANCE_DAYS = "dateToleranceDays"; + private static final String AMOUNT_TOLERANCE = "amountTolerance"; + private static final String PRIORITY = "priority"; + private static final String IS_ACTIVE = "isActive"; + + private static final Set<String> CREATE_IMPORT_PARAMETERS = new HashSet<>( + Arrays.asList(GL_ACCOUNT_ID, OFFICE_ID, STATEMENT_DATE, OPENING_BALANCE, CLOSING_BALANCE, FILE_NAME, FILE_TYPE, NOTES, LOCALE, + DATE_FORMAT)); + + private static final Set<String> IMPORT_TRANSACTIONS_PARAMETERS = new HashSet<>(Arrays.asList(TRANSACTIONS, LOCALE, DATE_FORMAT)); + + private static final Set<String> CREATE_MATCH_PARAMETERS = new HashSet<>( + Arrays.asList(BANK_TRANSACTION_ID, GL_JOURNAL_ENTRY_ID, MATCH_TYPE, NOTES)); + + private static final Set<String> CREATE_ADJUSTMENT_PARAMETERS = new HashSet<>( + Arrays.asList(ADJUSTMENT_TYPE, DESCRIPTION, AMOUNT, GL_ACCOUNT_DEBIT, GL_ACCOUNT_CREDIT, LOCALE)); + + private static final Set<String> CREATE_RULE_PARAMETERS = new HashSet<>(Arrays.asList(NAME, DESCRIPTION, GL_ACCOUNT_ID, + MATCH_CONDITION, CONDITION_VALUE, DATE_TOLERANCE_DAYS, AMOUNT_TOLERANCE, PRIORITY, IS_ACTIVE, LOCALE)); + + private static final Set<String> UPDATE_RULE_PARAMETERS = new HashSet<>( + Arrays.asList(NAME, DESCRIPTION, MATCH_CONDITION, CONDITION_VALUE, DATE_TOLERANCE_DAYS, AMOUNT_TOLERANCE, PRIORITY, IS_ACTIVE, + LOCALE)); + + public void validateForCreateImport(final String json) { + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + + final Type typeOfMap = new TypeToken<Object>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, CREATE_IMPORT_PARAMETERS); + + final List<ApiParameterError> dataValidationErrors = new ArrayList<>(); Review Comment: `validateForCreateImport()` only allows parameters like `officeId` and `statementDate`, and it rejects unknown parameters via `checkForUnsupportedParameters(...)`. However, the write service in this PR expects `fromDate`/`toDate` (and `ReconciliationJsonInputParams` doesn’t even define `officeId`/`statementDate`). As-is, valid requests for the service will be rejected as unsupported/missing fields. Please align the validator’s allowed/required parameters with the actual API contract used by `ReconciliationWritePlatformServiceImpl` and `ReconciliationApiResource`. ########## fineract-accounting/src/main/java/org/apache/fineract/accounting/reconciliation/service/ReconciliationWritePlatformServiceImpl.java: ########## @@ -0,0 +1,512 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.reconciliation.service; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import jakarta.persistence.PersistenceException; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.apache.fineract.accounting.glaccount.domain.GLAccount; +import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper; +import org.apache.fineract.accounting.journalentry.domain.JournalEntry; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryType; +import org.apache.fineract.accounting.journalentry.exception.JournalEntryNotFoundException; +import org.apache.fineract.accounting.reconciliation.api.ReconciliationJsonInputParams; +import org.apache.fineract.accounting.reconciliation.domain.AdjustmentType; +import org.apache.fineract.accounting.reconciliation.domain.BankStatementImport; +import org.apache.fineract.accounting.reconciliation.domain.BankStatementTransaction; +import org.apache.fineract.accounting.reconciliation.domain.MatchType; +import org.apache.fineract.accounting.reconciliation.domain.ReconciliationAdjustment; +import org.apache.fineract.accounting.reconciliation.domain.ReconciliationAuditLog; +import org.apache.fineract.accounting.reconciliation.domain.ReconciliationMatch; +import org.apache.fineract.accounting.reconciliation.domain.ReconciliationRule; +import org.apache.fineract.accounting.reconciliation.domain.repository.BankStatementImportRepository; +import org.apache.fineract.accounting.reconciliation.domain.repository.BankStatementTransactionRepository; +import org.apache.fineract.accounting.reconciliation.domain.repository.ReconciliationAdjustmentRepository; +import org.apache.fineract.accounting.reconciliation.domain.repository.ReconciliationAuditLogRepository; +import org.apache.fineract.accounting.reconciliation.domain.repository.ReconciliationMatchRepository; +import org.apache.fineract.accounting.reconciliation.domain.repository.ReconciliationRuleRepository; +import org.apache.fineract.accounting.reconciliation.exception.ReconciliationNotFoundException; +import org.apache.fineract.accounting.reconciliation.exception.ReconciliationMatchNotFoundException; +import org.apache.fineract.accounting.reconciliation.exception.ReconciliationRuleNotFoundException; +import org.apache.fineract.accounting.reconciliation.serialization.ReconciliationDataValidator; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.exception.ErrorHandler; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.organisation.office.domain.OfficeRepository; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.jpa.JpaSystemException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ReconciliationWritePlatformServiceImpl implements ReconciliationWritePlatformService { + + private final PlatformSecurityContext context; + private final FromJsonHelper fromApiJsonHelper; + private final ReconciliationDataValidator dataValidator; + private final BankStatementImportRepository bankStatementImportRepository; + private final BankStatementTransactionRepository bankStatementTransactionRepository; + private final ReconciliationMatchRepository reconciliationMatchRepository; + private final ReconciliationAdjustmentRepository reconciliationAdjustmentRepository; + private final ReconciliationRuleRepository reconciliationRuleRepository; + private final ReconciliationAuditLogRepository reconciliationAuditLogRepository; + private final GLAccountRepositoryWrapper glAccountRepository; + private final JournalEntryRepository journalEntryRepository; + private final OfficeRepository officeRepository; + private final ReconciliationMatchingService matchingService; + + @Override + @Transactional + public CommandProcessingResult createImport(JsonCommand command) { + try { + this.dataValidator.validateForCreateImport(command.json()); + + final AppUser currentUser = this.context.authenticatedUser(); + final Long glAccountId = command.longValueOfParameterNamed(ReconciliationJsonInputParams.GL_ACCOUNT_ID.getValue()); + final GLAccount glAccount = this.glAccountRepository.findOneWithNotFoundDetection(glAccountId); + + final String fileName = command.stringValueOfParameterNamed(ReconciliationJsonInputParams.FILE_NAME.getValue()); + final LocalDate fromDate = command.localDateValueOfParameterNamed(ReconciliationJsonInputParams.FROM_DATE.getValue()); + final LocalDate toDate = command.localDateValueOfParameterNamed(ReconciliationJsonInputParams.TO_DATE.getValue()); + final BigDecimal openingBalance = command + .bigDecimalValueOfParameterNamed(ReconciliationJsonInputParams.OPENING_BALANCE.getValue()); + final BigDecimal closingBalance = command + .bigDecimalValueOfParameterNamed(ReconciliationJsonInputParams.CLOSING_BALANCE.getValue()); + + final Office office = this.officeRepository.findById(currentUser.getOffice().getId()).orElseThrow(); + + final BankStatementImport importRecord = BankStatementImport.create(glAccount, office, fromDate, openingBalance, + closingBalance, fileName, "CSV", currentUser.getId(), DateUtils.getAuditOffsetDateTime(), null); + + this.bankStatementImportRepository.saveAndFlush(importRecord); + + createAuditLog(importRecord.getId(), "CREATED", "Bank statement import created", currentUser.getId()); + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(importRecord.getId()).build(); + + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(command, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } catch (final PersistenceException ee) { + final Throwable throwable = ExceptionUtils.getRootCause(ee.getCause()); + handleDataIntegrityIssues(command, throwable, ee); + return CommandProcessingResult.empty(); + } + } + + @Override + @Transactional + public CommandProcessingResult importTransactions(Long importId, JsonCommand command) { + try { + this.dataValidator.validateForImportTransactions(command.json()); + + final AppUser currentUser = this.context.authenticatedUser(); + final BankStatementImport importRecord = this.bankStatementImportRepository.findById(importId) + .orElseThrow(() -> new ReconciliationNotFoundException(importId)); + + final JsonArray transactions = command.arrayOfParameterNamed(ReconciliationJsonInputParams.TRANSACTIONS.getValue()); + + int totalTransactions = 0; + BigDecimal totalDebits = BigDecimal.ZERO; + BigDecimal totalCredits = BigDecimal.ZERO; + + final List<BankStatementTransaction> transactionList = new ArrayList<>(); + + for (JsonElement element : transactions) { + final LocalDate transactionDate = this.fromApiJsonHelper.extractLocalDateNamed("transactionDate", element.getAsJsonObject()); + final LocalDate valueDate = this.fromApiJsonHelper.extractLocalDateNamed("valueDate", element.getAsJsonObject()); + final String description = this.fromApiJsonHelper.extractStringNamed("description", element.getAsJsonObject()); + final String referenceNumber = this.fromApiJsonHelper.extractStringNamed("referenceNumber", element.getAsJsonObject()); + final BigDecimal debitAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("debitAmount", + element.getAsJsonObject()); + final BigDecimal creditAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("creditAmount", + element.getAsJsonObject()); + final BigDecimal balance = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("balance", element.getAsJsonObject()); + + final BankStatementTransaction transaction = BankStatementTransaction.create(importRecord, transactionDate, valueDate, + description, referenceNumber, null, debitAmount, creditAmount, balance, null, null); + + transactionList.add(transaction); + + totalTransactions++; + if (debitAmount != null) { + totalDebits = totalDebits.add(debitAmount); + } + if (creditAmount != null) { + totalCredits = totalCredits.add(creditAmount); + } + } + + this.bankStatementTransactionRepository.saveAll(transactionList); + + this.bankStatementImportRepository.saveAndFlush(importRecord); + + createAuditLog(importId, "TRANSACTIONS_IMPORTED", totalTransactions + " transactions imported", currentUser.getId()); + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(importId).build(); + + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(command, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } catch (final PersistenceException ee) { + final Throwable throwable = ExceptionUtils.getRootCause(ee.getCause()); + handleDataIntegrityIssues(command, throwable, ee); + return CommandProcessingResult.empty(); + } + } + + @Override + @Transactional + public CommandProcessingResult autoMatch(Long importId) { + final AppUser currentUser = this.context.authenticatedUser(); + final BankStatementImport importRecord = this.bankStatementImportRepository.findById(importId) + .orElseThrow(() -> new ReconciliationNotFoundException(importId)); + + int exactMatches = this.matchingService.matchExact(importId); + int ruleMatches = this.matchingService.matchWithRules(importId); + int totalMatches = exactMatches + ruleMatches; + + this.bankStatementImportRepository.saveAndFlush(importRecord); + + createAuditLog(importId, "AUTO_MATCHED", totalMatches + " automatic matches created (" + exactMatches + " exact, " + ruleMatches + + " rule-based)", currentUser.getId()); + + return new CommandProcessingResultBuilder().withEntityId(importId).build(); + } + + @Override + @Transactional + public CommandProcessingResult createMatch(Long importId, JsonCommand command) { + try { + this.dataValidator.validateForCreateMatch(command.json()); + + final AppUser currentUser = this.context.authenticatedUser(); + final BankStatementImport importRecord = this.bankStatementImportRepository.findById(importId) + .orElseThrow(() -> new ReconciliationNotFoundException(importId)); + + final Long bankTransactionId = command + .longValueOfParameterNamed(ReconciliationJsonInputParams.BANK_TRANSACTION_ID.getValue()); + final Long glEntryId = command.longValueOfParameterNamed(ReconciliationJsonInputParams.GL_ENTRY_ID.getValue()); + + final BankStatementTransaction bankTransaction = this.bankStatementTransactionRepository.findById(bankTransactionId) + .orElseThrow(() -> new ReconciliationNotFoundException(bankTransactionId)); + + final JournalEntry journalEntry = this.journalEntryRepository.findById(glEntryId) + .orElseThrow(() -> new JournalEntryNotFoundException(glEntryId)); + + final BigDecimal amount = bankTransaction.getAmount(); + final ReconciliationMatch match = ReconciliationMatch.create(importRecord, bankTransaction, journalEntry.getId(), + MatchType.MANUAL, BigDecimal.valueOf(100), amount, null, currentUser.getId(), DateUtils.getAuditOffsetDateTime()); + + this.reconciliationMatchRepository.saveAndFlush(match); + + bankTransaction.markAsMatched(currentUser.getId(), DateUtils.getAuditOffsetDateTime()); + this.bankStatementTransactionRepository.saveAndFlush(bankTransaction); + + this.bankStatementImportRepository.saveAndFlush(importRecord); + + createAuditLog(importId, "MATCH_CREATED", "Manual match created for transaction " + bankTransactionId, currentUser.getId()); + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(match.getId()).build(); + + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(command, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } catch (final PersistenceException ee) { + final Throwable throwable = ExceptionUtils.getRootCause(ee.getCause()); + handleDataIntegrityIssues(command, throwable, ee); + return CommandProcessingResult.empty(); + } + } + + @Override + @Transactional + public CommandProcessingResult removeMatch(Long importId, Long matchId) { + final AppUser currentUser = this.context.authenticatedUser(); + final BankStatementImport importRecord = this.bankStatementImportRepository.findById(importId) + .orElseThrow(() -> new ReconciliationNotFoundException(importId)); + + final ReconciliationMatch match = this.reconciliationMatchRepository.findById(matchId) + .orElseThrow(() -> new ReconciliationMatchNotFoundException(matchId)); + + final BankStatementTransaction bankTransaction = match.getBankTransaction(); + bankTransaction.unmarkAsMatched(); + this.bankStatementTransactionRepository.saveAndFlush(bankTransaction); + + this.reconciliationMatchRepository.delete(match); + + this.bankStatementImportRepository.saveAndFlush(importRecord); + + createAuditLog(importId, "MATCH_REMOVED", "Match removed for transaction " + bankTransaction.getId(), currentUser.getId()); + + return new CommandProcessingResultBuilder().withEntityId(matchId).build(); + } + + @Override + @Transactional + public CommandProcessingResult createAdjustment(Long importId, JsonCommand command) { + try { + this.dataValidator.validateForCreateAdjustment(command.json()); + + final AppUser currentUser = this.context.authenticatedUser(); + final BankStatementImport importRecord = this.bankStatementImportRepository.findById(importId) + .orElseThrow(() -> new ReconciliationNotFoundException(importId)); + + final LocalDate adjustmentDate = command + .localDateValueOfParameterNamed(ReconciliationJsonInputParams.ADJUSTMENT_DATE.getValue()); + final String description = command.stringValueOfParameterNamed(ReconciliationJsonInputParams.DESCRIPTION.getValue()); + final Long debitAccountId = command + .longValueOfParameterNamed(ReconciliationJsonInputParams.DEBIT_ACCOUNT_ID.getValue()); + final Long creditAccountId = command + .longValueOfParameterNamed(ReconciliationJsonInputParams.CREDIT_ACCOUNT_ID.getValue()); + final BigDecimal amount = command.bigDecimalValueOfParameterNamed(ReconciliationJsonInputParams.AMOUNT.getValue()); + + final GLAccount debitAccount = this.glAccountRepository.findOneWithNotFoundDetection(debitAccountId); + final GLAccount creditAccount = this.glAccountRepository.findOneWithNotFoundDetection(creditAccountId); + + final Office office = this.officeRepository.findById(currentUser.getOffice().getId()).orElseThrow(); + + final JournalEntry debitEntry = JournalEntry.createNew(office, null, debitAccount, "USD", null, false, adjustmentDate, + JournalEntryType.DEBIT, amount, description, null, null, null, null, null, null, null); + + final JournalEntry creditEntry = JournalEntry.createNew(office, null, creditAccount, "USD", null, false, adjustmentDate, + JournalEntryType.CREDIT, amount, description, null, null, null, null, null, null, null); + + this.journalEntryRepository.saveAndFlush(debitEntry); + this.journalEntryRepository.saveAndFlush(creditEntry); + + final ReconciliationAdjustment adjustment = ReconciliationAdjustment.create(importRecord, AdjustmentType.OTHER, description, + amount, debitAccount.getId(), creditAccount.getId(), currentUser.getId(), DateUtils.getAuditOffsetDateTime()); + + adjustment.linkJournalEntry(debitEntry.getId()); + this.reconciliationAdjustmentRepository.saveAndFlush(adjustment); + + createAuditLog(importId, "ADJUSTMENT_CREATED", "Adjustment created: " + description, currentUser.getId()); + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(adjustment.getId()).build(); + + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(command, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } catch (final PersistenceException ee) { + final Throwable throwable = ExceptionUtils.getRootCause(ee.getCause()); + handleDataIntegrityIssues(command, throwable, ee); + return CommandProcessingResult.empty(); + } + } + + @Override + @Transactional + public CommandProcessingResult completeReconciliation(Long importId, JsonCommand command) { + final AppUser currentUser = this.context.authenticatedUser(); + final BankStatementImport importRecord = this.bankStatementImportRepository.findById(importId) + .orElseThrow(() -> new ReconciliationNotFoundException(importId)); + + importRecord.complete(currentUser.getId(), DateUtils.getAuditOffsetDateTime()); + this.bankStatementImportRepository.saveAndFlush(importRecord); + + createAuditLog(importId, "COMPLETED", "Reconciliation completed", currentUser.getId()); + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(importId).build(); + } + + @Override + @Transactional + public CommandProcessingResult approveReconciliation(Long importId, JsonCommand command) { + final AppUser currentUser = this.context.authenticatedUser(); + final BankStatementImport importRecord = this.bankStatementImportRepository.findById(importId) + .orElseThrow(() -> new ReconciliationNotFoundException(importId)); + + importRecord.approve(currentUser.getId(), DateUtils.getAuditOffsetDateTime()); + this.bankStatementImportRepository.saveAndFlush(importRecord); + + createAuditLog(importId, "APPROVED", "Reconciliation approved", currentUser.getId()); + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(importId).build(); + } + + @Override + @Transactional + public CommandProcessingResult deleteImport(Long importId) { + final AppUser currentUser = this.context.authenticatedUser(); + final BankStatementImport importRecord = this.bankStatementImportRepository.findById(importId) + .orElseThrow(() -> new ReconciliationNotFoundException(importId)); + + this.reconciliationMatchRepository.deleteByImportId(importId); + this.reconciliationAdjustmentRepository.deleteByImportId(importId); + this.bankStatementTransactionRepository.deleteByImportId(importId); + this.bankStatementImportRepository.delete(importRecord); + + createAuditLog(importId, "DELETED", "Import and all related data deleted", currentUser.getId()); + + return new CommandProcessingResultBuilder().withEntityId(importId).build(); Review Comment: `deleteImport()` deletes the `BankStatementImport` and then calls `createAuditLog(importId, ...)`, but `createAuditLog(...)` re-reads the import record from the repository. After deletion this will throw `ReconciliationNotFoundException`, causing the delete operation to fail/roll back. Create the audit log before deleting the import, or change `createAuditLog` to accept the already-loaded `importRecord` (and avoid the second lookup). ########## fineract-accounting/src/main/java/org/apache/fineract/accounting/reconciliation/service/ReconciliationWritePlatformServiceImpl.java: ########## @@ -0,0 +1,512 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.reconciliation.service; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import jakarta.persistence.PersistenceException; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.apache.fineract.accounting.glaccount.domain.GLAccount; +import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper; +import org.apache.fineract.accounting.journalentry.domain.JournalEntry; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryType; +import org.apache.fineract.accounting.journalentry.exception.JournalEntryNotFoundException; +import org.apache.fineract.accounting.reconciliation.api.ReconciliationJsonInputParams; +import org.apache.fineract.accounting.reconciliation.domain.AdjustmentType; +import org.apache.fineract.accounting.reconciliation.domain.BankStatementImport; +import org.apache.fineract.accounting.reconciliation.domain.BankStatementTransaction; +import org.apache.fineract.accounting.reconciliation.domain.MatchType; +import org.apache.fineract.accounting.reconciliation.domain.ReconciliationAdjustment; +import org.apache.fineract.accounting.reconciliation.domain.ReconciliationAuditLog; +import org.apache.fineract.accounting.reconciliation.domain.ReconciliationMatch; +import org.apache.fineract.accounting.reconciliation.domain.ReconciliationRule; +import org.apache.fineract.accounting.reconciliation.domain.repository.BankStatementImportRepository; +import org.apache.fineract.accounting.reconciliation.domain.repository.BankStatementTransactionRepository; +import org.apache.fineract.accounting.reconciliation.domain.repository.ReconciliationAdjustmentRepository; +import org.apache.fineract.accounting.reconciliation.domain.repository.ReconciliationAuditLogRepository; +import org.apache.fineract.accounting.reconciliation.domain.repository.ReconciliationMatchRepository; +import org.apache.fineract.accounting.reconciliation.domain.repository.ReconciliationRuleRepository; +import org.apache.fineract.accounting.reconciliation.exception.ReconciliationNotFoundException; +import org.apache.fineract.accounting.reconciliation.exception.ReconciliationMatchNotFoundException; +import org.apache.fineract.accounting.reconciliation.exception.ReconciliationRuleNotFoundException; +import org.apache.fineract.accounting.reconciliation.serialization.ReconciliationDataValidator; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.exception.ErrorHandler; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.organisation.office.domain.OfficeRepository; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.jpa.JpaSystemException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ReconciliationWritePlatformServiceImpl implements ReconciliationWritePlatformService { + + private final PlatformSecurityContext context; + private final FromJsonHelper fromApiJsonHelper; + private final ReconciliationDataValidator dataValidator; + private final BankStatementImportRepository bankStatementImportRepository; + private final BankStatementTransactionRepository bankStatementTransactionRepository; + private final ReconciliationMatchRepository reconciliationMatchRepository; + private final ReconciliationAdjustmentRepository reconciliationAdjustmentRepository; + private final ReconciliationRuleRepository reconciliationRuleRepository; + private final ReconciliationAuditLogRepository reconciliationAuditLogRepository; + private final GLAccountRepositoryWrapper glAccountRepository; + private final JournalEntryRepository journalEntryRepository; + private final OfficeRepository officeRepository; + private final ReconciliationMatchingService matchingService; + + @Override + @Transactional + public CommandProcessingResult createImport(JsonCommand command) { + try { + this.dataValidator.validateForCreateImport(command.json()); + + final AppUser currentUser = this.context.authenticatedUser(); + final Long glAccountId = command.longValueOfParameterNamed(ReconciliationJsonInputParams.GL_ACCOUNT_ID.getValue()); + final GLAccount glAccount = this.glAccountRepository.findOneWithNotFoundDetection(glAccountId); + + final String fileName = command.stringValueOfParameterNamed(ReconciliationJsonInputParams.FILE_NAME.getValue()); + final LocalDate fromDate = command.localDateValueOfParameterNamed(ReconciliationJsonInputParams.FROM_DATE.getValue()); + final LocalDate toDate = command.localDateValueOfParameterNamed(ReconciliationJsonInputParams.TO_DATE.getValue()); + final BigDecimal openingBalance = command + .bigDecimalValueOfParameterNamed(ReconciliationJsonInputParams.OPENING_BALANCE.getValue()); + final BigDecimal closingBalance = command + .bigDecimalValueOfParameterNamed(ReconciliationJsonInputParams.CLOSING_BALANCE.getValue()); + + final Office office = this.officeRepository.findById(currentUser.getOffice().getId()).orElseThrow(); + + final BankStatementImport importRecord = BankStatementImport.create(glAccount, office, fromDate, openingBalance, + closingBalance, fileName, "CSV", currentUser.getId(), DateUtils.getAuditOffsetDateTime(), null); + Review Comment: `createImport()` reads `fromDate`/`toDate` (and passes `fromDate` as the `statementDate` into `BankStatementImport.create(...)`), but the persisted entity/table only has a single `statement_date` and `toDate` is never used. Either persist both dates (add columns + entity fields) or change the API/service to accept and store a single statement date so the data model and API are consistent. ########## fineract-accounting/src/main/java/org/apache/fineract/accounting/reconciliation/service/ReconciliationReadPlatformServiceImpl.java: ########## @@ -0,0 +1,425 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.reconciliation.service; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.reconciliation.data.BankStatementImportData; +import org.apache.fineract.accounting.reconciliation.data.BankStatementTransactionData; +import org.apache.fineract.accounting.reconciliation.data.ReconciliationAdjustmentData; +import org.apache.fineract.accounting.reconciliation.data.ReconciliationMatchData; +import org.apache.fineract.accounting.reconciliation.data.ReconciliationRuleData; +import org.apache.fineract.accounting.reconciliation.data.ReconciliationSummaryData; +import org.apache.fineract.accounting.reconciliation.data.UnreconciledGLEntryData; +import org.apache.fineract.accounting.reconciliation.domain.BankStatementImport; +import org.apache.fineract.accounting.reconciliation.domain.ReconciliationRule; +import org.apache.fineract.accounting.reconciliation.domain.repository.BankStatementImportRepository; +import org.apache.fineract.accounting.reconciliation.domain.repository.ReconciliationRuleRepository; +import org.apache.fineract.accounting.reconciliation.exception.ReconciliationNotFoundException; +import org.apache.fineract.accounting.reconciliation.exception.ReconciliationRuleNotFoundException; +import org.apache.fineract.infrastructure.core.domain.JdbcSupport; +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.infrastructure.core.service.PaginationHelper; +import org.apache.fineract.infrastructure.core.service.SearchParameters; +import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ReconciliationReadPlatformServiceImpl implements ReconciliationReadPlatformService { + + private final JdbcTemplate jdbcTemplate; + private final BankStatementImportRepository bankStatementImportRepository; + private final ReconciliationRuleRepository reconciliationRuleRepository; + private final PaginationHelper paginationHelper; + private final DatabaseSpecificSQLGenerator sqlGenerator; + + private static final class BankStatementImportMapper implements RowMapper<BankStatementImportData> { + + public String schema() { + return " bsi.id as id, bsi.gl_account_id as glAccountId, gl.name as glAccountName, gl.gl_code as glAccountCode, " + + " bsi.file_name as fileName, bsi.import_date as importDate, bsi.from_date as fromDate, " + + " bsi.to_date as toDate, bsi.status as status, bsi.total_transactions as totalTransactions, " + + " bsi.matched_count as matchedCount, bsi.unmatched_count as unmatchedCount, " + + " bsi.total_debits as totalDebits, bsi.total_credits as totalCredits, " + + " bsi.opening_balance as openingBalance, bsi.closing_balance as closingBalance, " + + " bsi.completed_date as completedDate, bsi.approved_date as approvedDate, " + + " bsi.approved_by_user_id as approvedByUserId, approver.username as approvedByUsername, " + + " bsi.created_by as createdBy, creator.username as createdByUsername, " + + " bsi.created_date as createdDate, bsi.last_modified_by as lastModifiedBy, " + + " modifier.username as lastModifiedByUsername, bsi.last_modified_date as lastModifiedDate " + + " FROM acc_bank_statement_import bsi " + + " LEFT JOIN acc_gl_account gl ON gl.id = bsi.gl_account_id " + + " LEFT JOIN m_appuser approver ON approver.id = bsi.approved_by_user_id " + + " LEFT JOIN m_appuser creator ON creator.id = bsi.created_by " + + " LEFT JOIN m_appuser modifier ON modifier.id = bsi.last_modified_by "; Review Comment: The SQL schema in `BankStatementImportMapper.schema()` references `acc_bank_statement_import` and columns like `import_date`, `from_date`, `to_date`, `total_transactions`, etc., but the Liquibase changelog in this PR creates `bank_statement_import` with different column names (e.g., `statement_date`, `imported_date`, no `from_date/to_date`). This mapper/query will fail at runtime unless the table/columns are aligned (either update Liquibase/entities to match the `acc_*` schema, or update these SQL strings to match the created tables/columns). ########## fineract-accounting/src/main/java/org/apache/fineract/accounting/reconciliation/domain/repository/ReconciliationMatchRepository.java: ########## @@ -0,0 +1,45 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.reconciliation.domain.repository; + +import java.util.List; +import org.apache.fineract.accounting.reconciliation.domain.ReconciliationMatch; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ReconciliationMatchRepository + extends JpaRepository<ReconciliationMatch, Long>, JpaSpecificationExecutor<ReconciliationMatch> { + + @Query("SELECT m FROM ReconciliationMatch m WHERE m.statementImport.id = :importId ORDER BY m.createdDate DESC") + List<ReconciliationMatch> findByImportId(@Param("importId") Long importId); + + @Query("SELECT m FROM ReconciliationMatch m WHERE m.bankTransaction.id = :bankTransactionId") + List<ReconciliationMatch> findByBankTransactionId(@Param("bankTransactionId") Long bankTransactionId); + + @Query("SELECT m FROM ReconciliationMatch m WHERE m.glJournalEntryId = :journalEntryId") + List<ReconciliationMatch> findByGlJournalEntryId(@Param("journalEntryId") Long journalEntryId); + + @Query("SELECT m FROM ReconciliationMatch m WHERE m.matchType = :matchType AND m.statementImport.id = :importId") + List<ReconciliationMatch> findByImportIdAndMatchType(@Param("importId") Long importId, @Param("matchType") String matchType); + + @Query("DELETE FROM ReconciliationMatch m WHERE m.statementImport.id = :importId") + void deleteByImportId(@Param("importId") Long importId); +} Review Comment: These repository methods declare JPQL `DELETE` queries, but they are missing `@Modifying` (and typically `flushAutomatically=true`). Without it, Spring Data treats the query as non-modifying and will fail at runtime. Add `@Modifying` (and ensure the call occurs within a transaction), or replace with a derived delete method name that Spring can generate. ########## fineract-accounting/src/main/java/org/apache/fineract/accounting/reconciliation/domain/repository/ReconciliationAdjustmentRepository.java: ########## @@ -0,0 +1,42 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.reconciliation.domain.repository; + +import java.util.List; +import org.apache.fineract.accounting.reconciliation.domain.ReconciliationAdjustment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ReconciliationAdjustmentRepository + extends JpaRepository<ReconciliationAdjustment, Long>, JpaSpecificationExecutor<ReconciliationAdjustment> { + + @Query("SELECT a FROM ReconciliationAdjustment a WHERE a.statementImport.id = :importId ORDER BY a.createdDate DESC") + List<ReconciliationAdjustment> findByImportId(@Param("importId") Long importId); + + @Query("SELECT a FROM ReconciliationAdjustment a WHERE a.statementImport.id = :importId AND a.approved = :approved") + List<ReconciliationAdjustment> findByImportIdAndApprovalStatus(@Param("importId") Long importId, @Param("approved") boolean approved); + + @Query("SELECT a FROM ReconciliationAdjustment a WHERE a.adjustmentType = :adjustmentType") + List<ReconciliationAdjustment> findByAdjustmentType(@Param("adjustmentType") String adjustmentType); + + @Query("DELETE FROM ReconciliationAdjustment a WHERE a.statementImport.id = :importId") + void deleteByImportId(@Param("importId") Long importId); +} Review Comment: This repository `DELETE` query is missing `@Modifying`, so the delete will not execute correctly at runtime. Add `@Modifying` (and ensure it runs in a transaction) or use a derived delete method instead. ########## fineract-accounting/src/main/java/org/apache/fineract/accounting/reconciliation/service/ReconciliationMatchingServiceImpl.java: ########## @@ -0,0 +1,302 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.reconciliation.service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.journalentry.domain.JournalEntry; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; +import org.apache.fineract.accounting.reconciliation.data.ReconciliationMatchData; +import org.apache.fineract.accounting.reconciliation.domain.BankStatementImport; +import org.apache.fineract.accounting.reconciliation.domain.BankStatementTransaction; +import org.apache.fineract.accounting.reconciliation.domain.MatchType; +import org.apache.fineract.accounting.reconciliation.domain.ReconciliationAuditLog; +import org.apache.fineract.accounting.reconciliation.domain.ReconciliationMatch; +import org.apache.fineract.accounting.reconciliation.domain.ReconciliationRule; +import org.apache.fineract.accounting.reconciliation.domain.repository.BankStatementImportRepository; +import org.apache.fineract.accounting.reconciliation.domain.repository.BankStatementTransactionRepository; +import org.apache.fineract.accounting.reconciliation.domain.repository.ReconciliationAuditLogRepository; +import org.apache.fineract.accounting.reconciliation.domain.repository.ReconciliationMatchRepository; +import org.apache.fineract.accounting.reconciliation.domain.repository.ReconciliationRuleRepository; +import org.apache.fineract.accounting.reconciliation.exception.ReconciliationNotFoundException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ReconciliationMatchingServiceImpl implements ReconciliationMatchingService { + + private final PlatformSecurityContext context; + private final BankStatementImportRepository bankStatementImportRepository; + private final BankStatementTransactionRepository bankStatementTransactionRepository; + private final JournalEntryRepository journalEntryRepository; + private final ReconciliationMatchRepository reconciliationMatchRepository; + private final ReconciliationRuleRepository reconciliationRuleRepository; + private final ReconciliationAuditLogRepository reconciliationAuditLogRepository; + + @Override + @Transactional + public int matchExact(Long importId) { + final AppUser currentUser = this.context.authenticatedUser(); + final BankStatementImport importRecord = this.bankStatementImportRepository.findById(importId) + .orElseThrow(() -> new ReconciliationNotFoundException(importId)); + + final List<BankStatementTransaction> unmatchedTransactions = this.bankStatementTransactionRepository + .findByImportIdAndMatchStatus(importId, false); + + int matchCount = 0; + + for (BankStatementTransaction bankTransaction : unmatchedTransactions) { + final BigDecimal amount = getTransactionAmount(bankTransaction); + final LocalDate transactionDate = bankTransaction.getTransactionDate(); + + final List<JournalEntry> potentialMatches = new ArrayList<>(); + + if (potentialMatches.size() == 1) { + final JournalEntry journalEntry = potentialMatches.get(0); + + final List<ReconciliationMatch> existingMatches = this.reconciliationMatchRepository + .findByGlJournalEntryId(journalEntry.getId()); Review Comment: `matchExact()` currently initializes `potentialMatches` as an empty list and never populates it (same pattern appears in `suggestMatches()` and `tryMatchWithRule()`). As a result, auto-matching and suggestions will always return 0/empty and the feature is effectively non-functional. Implement the actual `JournalEntry` lookup (e.g., via `journalEntryRepository`) before checking `potentialMatches.size()`. ########## fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java: ########## @@ -3929,4 +3929,103 @@ public CommandWrapperBuilder updateLoanAvailableDisbursementAmount(final Long lo this.href = "/loans/" + loanId; return this; } + + public CommandWrapperBuilder createReconciliationImport() { + this.actionName = "CREATE"; + this.entityName = "RECONCILIATIONIMPORT"; + this.entityId = null; + this.href = "/accounting/reconciliation"; + return this; + } + + public CommandWrapperBuilder importReconciliationTransactions(final Long importId) { + this.actionName = "IMPORTTRANSACTIONS"; + this.entityName = "RECONCILIATIONIMPORT"; + this.entityId = importId; + this.href = "/accounting/reconciliation/" + importId + "/transactions"; + return this; Review Comment: The reconciliation command wrappers set `entityName` to `RECONCILIATIONIMPORT`/`RECONCILIATIONRULE`, but there are no corresponding command handlers/processor mappings elsewhere in the codebase (the only occurrences of these entity names are in this builder). As-is, commands logged from `ReconciliationApiResource` won’t be processed. Add the appropriate command handler(s) and wiring for these entity/action names, or adjust to an existing supported entity name. ########## fineract-accounting/src/main/java/org/apache/fineract/accounting/reconciliation/domain/repository/BankStatementTransactionRepository.java: ########## @@ -0,0 +1,47 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.reconciliation.domain.repository; + +import java.time.LocalDate; +import java.util.List; +import org.apache.fineract.accounting.reconciliation.domain.BankStatementTransaction; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface BankStatementTransactionRepository + extends JpaRepository<BankStatementTransaction, Long>, JpaSpecificationExecutor<BankStatementTransaction> { + + @Query("SELECT t FROM BankStatementTransaction t WHERE t.statementImport.id = :importId ORDER BY t.transactionDate ASC") + List<BankStatementTransaction> findByImportId(@Param("importId") Long importId); + + @Query("SELECT t FROM BankStatementTransaction t WHERE t.statementImport.id = :importId AND t.matched = :matched ORDER BY t.transactionDate ASC") + List<BankStatementTransaction> findByImportIdAndMatchStatus(@Param("importId") Long importId, @Param("matched") boolean matched); + + @Query("SELECT t FROM BankStatementTransaction t WHERE t.statementImport.id = :importId AND t.transactionDate BETWEEN :fromDate AND :toDate ORDER BY t.transactionDate ASC") + List<BankStatementTransaction> findByImportIdAndDateRange(@Param("importId") Long importId, @Param("fromDate") LocalDate fromDate, + @Param("toDate") LocalDate toDate); + + @Query("SELECT t FROM BankStatementTransaction t WHERE t.referenceNumber = :referenceNumber AND t.matched = false") + List<BankStatementTransaction> findUnmatchedByReferenceNumber(@Param("referenceNumber") String referenceNumber); + + @Query("DELETE FROM BankStatementTransaction t WHERE t.statementImport.id = :importId") + void deleteByImportId(@Param("importId") Long importId); Review Comment: This repository `DELETE` query is missing `@Modifying`, so the delete will not execute correctly at runtime. Add `@Modifying` (and ensure it runs in a transaction) or use a derived delete method instead. ########## fineract-accounting/src/main/java/org/apache/fineract/accounting/reconciliation/service/ReconciliationReadPlatformServiceImpl.java: ########## @@ -0,0 +1,425 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.reconciliation.service; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.reconciliation.data.BankStatementImportData; +import org.apache.fineract.accounting.reconciliation.data.BankStatementTransactionData; +import org.apache.fineract.accounting.reconciliation.data.ReconciliationAdjustmentData; +import org.apache.fineract.accounting.reconciliation.data.ReconciliationMatchData; +import org.apache.fineract.accounting.reconciliation.data.ReconciliationRuleData; +import org.apache.fineract.accounting.reconciliation.data.ReconciliationSummaryData; +import org.apache.fineract.accounting.reconciliation.data.UnreconciledGLEntryData; +import org.apache.fineract.accounting.reconciliation.domain.BankStatementImport; +import org.apache.fineract.accounting.reconciliation.domain.ReconciliationRule; +import org.apache.fineract.accounting.reconciliation.domain.repository.BankStatementImportRepository; +import org.apache.fineract.accounting.reconciliation.domain.repository.ReconciliationRuleRepository; +import org.apache.fineract.accounting.reconciliation.exception.ReconciliationNotFoundException; +import org.apache.fineract.accounting.reconciliation.exception.ReconciliationRuleNotFoundException; +import org.apache.fineract.infrastructure.core.domain.JdbcSupport; +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.infrastructure.core.service.PaginationHelper; +import org.apache.fineract.infrastructure.core.service.SearchParameters; +import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ReconciliationReadPlatformServiceImpl implements ReconciliationReadPlatformService { + + private final JdbcTemplate jdbcTemplate; + private final BankStatementImportRepository bankStatementImportRepository; + private final ReconciliationRuleRepository reconciliationRuleRepository; + private final PaginationHelper paginationHelper; + private final DatabaseSpecificSQLGenerator sqlGenerator; + + private static final class BankStatementImportMapper implements RowMapper<BankStatementImportData> { + + public String schema() { + return " bsi.id as id, bsi.gl_account_id as glAccountId, gl.name as glAccountName, gl.gl_code as glAccountCode, " + + " bsi.file_name as fileName, bsi.import_date as importDate, bsi.from_date as fromDate, " + + " bsi.to_date as toDate, bsi.status as status, bsi.total_transactions as totalTransactions, " + + " bsi.matched_count as matchedCount, bsi.unmatched_count as unmatchedCount, " + + " bsi.total_debits as totalDebits, bsi.total_credits as totalCredits, " + + " bsi.opening_balance as openingBalance, bsi.closing_balance as closingBalance, " + + " bsi.completed_date as completedDate, bsi.approved_date as approvedDate, " + + " bsi.approved_by_user_id as approvedByUserId, approver.username as approvedByUsername, " + + " bsi.created_by as createdBy, creator.username as createdByUsername, " + + " bsi.created_date as createdDate, bsi.last_modified_by as lastModifiedBy, " + + " modifier.username as lastModifiedByUsername, bsi.last_modified_date as lastModifiedDate " + + " FROM acc_bank_statement_import bsi " + + " LEFT JOIN acc_gl_account gl ON gl.id = bsi.gl_account_id " + + " LEFT JOIN m_appuser approver ON approver.id = bsi.approved_by_user_id " + + " LEFT JOIN m_appuser creator ON creator.id = bsi.created_by " + + " LEFT JOIN m_appuser modifier ON modifier.id = bsi.last_modified_by "; + } + + @Override + public BankStatementImportData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException { + final Long id = rs.getLong("id"); + final Long glAccountId = rs.getLong("glAccountId"); + final String glAccountName = rs.getString("glAccountName"); + final String glAccountCode = rs.getString("glAccountCode"); + final String fileName = rs.getString("fileName"); + final LocalDate importDate = JdbcSupport.getLocalDate(rs, "importDate"); + final LocalDate fromDate = JdbcSupport.getLocalDate(rs, "fromDate"); + final LocalDate toDate = JdbcSupport.getLocalDate(rs, "toDate"); + final String status = rs.getString("status"); + final Integer totalTransactions = JdbcSupport.getInteger(rs, "totalTransactions"); + final Integer matchedCount = JdbcSupport.getInteger(rs, "matchedCount"); + final Integer unmatchedCount = JdbcSupport.getInteger(rs, "unmatchedCount"); + final BigDecimal totalDebits = rs.getBigDecimal("totalDebits"); + final BigDecimal totalCredits = rs.getBigDecimal("totalCredits"); + final BigDecimal openingBalance = rs.getBigDecimal("openingBalance"); + final BigDecimal closingBalance = rs.getBigDecimal("closingBalance"); + final LocalDate completedDate = JdbcSupport.getLocalDate(rs, "completedDate"); + final LocalDate approvedDate = JdbcSupport.getLocalDate(rs, "approvedDate"); + final Long approvedByUserId = JdbcSupport.getLong(rs, "approvedByUserId"); + final String approvedByUsername = rs.getString("approvedByUsername"); + final Long createdBy = JdbcSupport.getLong(rs, "createdBy"); + final String createdByUsername = rs.getString("createdByUsername"); + final LocalDate createdDate = JdbcSupport.getLocalDate(rs, "createdDate"); + final Long lastModifiedBy = JdbcSupport.getLong(rs, "lastModifiedBy"); + final String lastModifiedByUsername = rs.getString("lastModifiedByUsername"); + final LocalDate lastModifiedDate = JdbcSupport.getLocalDate(rs, "lastModifiedDate"); + + return new BankStatementImportData().setId(id).setGlAccountId(glAccountId).setGlAccountName(glAccountName) + .setFileName(fileName).setStatementDate(fromDate).setOpeningBalance(openingBalance) + .setClosingBalance(closingBalance).setStatus(status); + } + } + + private static final class BankStatementTransactionMapper implements RowMapper<BankStatementTransactionData> { + + public String schema() { + return " bst.id as id, bst.import_id as importId, bst.transaction_date as transactionDate, " + + " bst.value_date as valueDate, bst.description as description, bst.reference_number as referenceNumber, " + + " bst.debit_amount as debitAmount, bst.credit_amount as creditAmount, bst.balance as balance, " + + " bst.is_matched as isMatched, bst.match_confidence as matchConfidence " + + " FROM acc_bank_statement_transaction bst "; + } Review Comment: `BankStatementTransactionMapper.schema()` uses `bst.import_id` and `acc_bank_statement_transaction`, but the Liquibase changeset creates `bank_statement_transaction` with the FK column `statement_import_id`. The SQL here should be updated to use the correct table/column names (and be consistent with the rest of the module’s naming). ########## fineract-accounting/src/main/resources/jpa/accounting/db/changelog/tenant/module/accounting/0001_add_reconciliation_tables.xml: ########## @@ -0,0 +1,315 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + +--> +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog + http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd"> + + <!-- Bank Statement Import --> + <changeSet id="1-create-bank-statement-import" author="reconciliation-module"> + <createTable tableName="bank_statement_import"> + <column name="id" type="BIGINT" autoIncrement="true"> Review Comment: This module changelog comment says changeset IDs should start from 3000, but the included reconciliation changesets use IDs 1–6. To avoid collisions and follow the stated convention, rename the changeset IDs to the expected range (or update/remove the convention comment if it’s no longer applicable). ########## fineract-accounting/src/main/java/org/apache/fineract/accounting/reconciliation/api/ReconciliationApiResource.java: ########## @@ -0,0 +1,479 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.reconciliation.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.reconciliation.data.BankStatementImportData; +import org.apache.fineract.accounting.reconciliation.data.ReconciliationRuleData; +import org.apache.fineract.accounting.reconciliation.data.ReconciliationSummaryData; +import org.apache.fineract.accounting.reconciliation.data.UnreconciledGLEntryData; +import org.apache.fineract.accounting.reconciliation.service.ReconciliationReadPlatformService; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.exception.UnsupportedCommandException; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.infrastructure.core.service.SearchParameters; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.stereotype.Component; + +@Path("/v1/accounting/reconciliation") +@Component +@Tag(name = "Account Reconciliation", description = """ + The Account Reconciliation module enables reconciliation of bank statements with GL account entries. + It supports importing bank transactions, automatic and manual matching of transactions, + adjustments, and completion/approval workflows. + """) +@RequiredArgsConstructor +public class ReconciliationApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSION = "ACCOUNTRECONCILIATION"; + + private final PlatformSecurityContext context; + private final ReconciliationReadPlatformService readPlatformService; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(tags = { "Account Reconciliation" }, summary = "List Reconciliations", description = """ + Retrieve a list of all reconciliations with optional filters. + + Example Requests: + + accounting/reconciliation + + accounting/reconciliation?glAccountId=1 + + accounting/reconciliation?status=PENDING&offset=0&limit=10 + """) + @ApiResponse(responseCode = "200", description = "OK") + public Page<BankStatementImportData> retrieveAll(@QueryParam("glAccountId") @Parameter(description = "GL Account ID") final Long glAccountId, + @QueryParam("fromDate") @Parameter(description = "From Date") final String fromDateStr, + @QueryParam("toDate") @Parameter(description = "To Date") final String toDateStr, + @QueryParam("status") @Parameter(description = "Status") final String status, + @QueryParam("offset") @Parameter(description = "offset") final Integer offset, + @QueryParam("limit") @Parameter(description = "limit") final Integer limit) { + + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate fromDate = fromDateStr != null ? LocalDate.parse(fromDateStr, DateUtils.DEFAULT_DATE_FORMATTER) : null; + final LocalDate toDate = toDateStr != null ? LocalDate.parse(toDateStr, DateUtils.DEFAULT_DATE_FORMATTER) : null; + + final SearchParameters searchParameters = SearchParameters.builder().limit(limit).offset(offset).build(); + return this.readPlatformService.retrieveAll(glAccountId, fromDate, toDate, status, searchParameters); + } + + @GET + @Path("{importId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(tags = { "Account Reconciliation" }, summary = "Retrieve a Reconciliation", description = """ + Retrieve details of a specific reconciliation import. + + Example Request: + + accounting/reconciliation/1 + """) + @ApiResponse(responseCode = "200", description = "OK") + public BankStatementImportData retrieveOne(@PathParam("importId") @Parameter(description = "importId") final Long importId) { + + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + return this.readPlatformService.retrieveOne(importId); + } + + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(tags = { "Account Reconciliation" }, summary = "Create Reconciliation Import", description = """ + Create a new reconciliation import for a GL account. + + Mandatory Fields: + glAccountId, importDate + + Example Request: + + { + "glAccountId": 1, + "importDate": "2024-01-15", + "description": "January 2024 Bank Statement" Review Comment: The OpenAPI description/example for creating an import refers to fields like `importDate` and `description`, but the validator/service code in this PR expects different fields (e.g., `fromDate`/`toDate`, `openingBalance`/`closingBalance`, and rejects unknown parameters). Please update the API docs/examples to match the actual accepted JSON payload, or update the backend contract to match this documentation. ```suggestion glAccountId, fromDate, toDate, openingBalance, closingBalance Example Request: { "glAccountId": 1, "fromDate": "2024-01-01", "toDate": "2024-01-31", "openingBalance": 1000.00, "closingBalance": 1500.25 ``` ########## fineract-accounting/src/main/java/org/apache/fineract/accounting/reconciliation/service/ReconciliationWritePlatformServiceImpl.java: ########## @@ -0,0 +1,512 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.reconciliation.service; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import jakarta.persistence.PersistenceException; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.apache.fineract.accounting.glaccount.domain.GLAccount; +import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper; +import org.apache.fineract.accounting.journalentry.domain.JournalEntry; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryType; +import org.apache.fineract.accounting.journalentry.exception.JournalEntryNotFoundException; +import org.apache.fineract.accounting.reconciliation.api.ReconciliationJsonInputParams; +import org.apache.fineract.accounting.reconciliation.domain.AdjustmentType; +import org.apache.fineract.accounting.reconciliation.domain.BankStatementImport; +import org.apache.fineract.accounting.reconciliation.domain.BankStatementTransaction; +import org.apache.fineract.accounting.reconciliation.domain.MatchType; +import org.apache.fineract.accounting.reconciliation.domain.ReconciliationAdjustment; +import org.apache.fineract.accounting.reconciliation.domain.ReconciliationAuditLog; +import org.apache.fineract.accounting.reconciliation.domain.ReconciliationMatch; +import org.apache.fineract.accounting.reconciliation.domain.ReconciliationRule; +import org.apache.fineract.accounting.reconciliation.domain.repository.BankStatementImportRepository; +import org.apache.fineract.accounting.reconciliation.domain.repository.BankStatementTransactionRepository; +import org.apache.fineract.accounting.reconciliation.domain.repository.ReconciliationAdjustmentRepository; +import org.apache.fineract.accounting.reconciliation.domain.repository.ReconciliationAuditLogRepository; +import org.apache.fineract.accounting.reconciliation.domain.repository.ReconciliationMatchRepository; +import org.apache.fineract.accounting.reconciliation.domain.repository.ReconciliationRuleRepository; +import org.apache.fineract.accounting.reconciliation.exception.ReconciliationNotFoundException; +import org.apache.fineract.accounting.reconciliation.exception.ReconciliationMatchNotFoundException; +import org.apache.fineract.accounting.reconciliation.exception.ReconciliationRuleNotFoundException; +import org.apache.fineract.accounting.reconciliation.serialization.ReconciliationDataValidator; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.exception.ErrorHandler; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.organisation.office.domain.OfficeRepository; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.jpa.JpaSystemException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ReconciliationWritePlatformServiceImpl implements ReconciliationWritePlatformService { + + private final PlatformSecurityContext context; + private final FromJsonHelper fromApiJsonHelper; + private final ReconciliationDataValidator dataValidator; + private final BankStatementImportRepository bankStatementImportRepository; + private final BankStatementTransactionRepository bankStatementTransactionRepository; + private final ReconciliationMatchRepository reconciliationMatchRepository; + private final ReconciliationAdjustmentRepository reconciliationAdjustmentRepository; + private final ReconciliationRuleRepository reconciliationRuleRepository; + private final ReconciliationAuditLogRepository reconciliationAuditLogRepository; + private final GLAccountRepositoryWrapper glAccountRepository; + private final JournalEntryRepository journalEntryRepository; + private final OfficeRepository officeRepository; + private final ReconciliationMatchingService matchingService; + + @Override + @Transactional + public CommandProcessingResult createImport(JsonCommand command) { + try { + this.dataValidator.validateForCreateImport(command.json()); + + final AppUser currentUser = this.context.authenticatedUser(); + final Long glAccountId = command.longValueOfParameterNamed(ReconciliationJsonInputParams.GL_ACCOUNT_ID.getValue()); + final GLAccount glAccount = this.glAccountRepository.findOneWithNotFoundDetection(glAccountId); + + final String fileName = command.stringValueOfParameterNamed(ReconciliationJsonInputParams.FILE_NAME.getValue()); + final LocalDate fromDate = command.localDateValueOfParameterNamed(ReconciliationJsonInputParams.FROM_DATE.getValue()); + final LocalDate toDate = command.localDateValueOfParameterNamed(ReconciliationJsonInputParams.TO_DATE.getValue()); + final BigDecimal openingBalance = command + .bigDecimalValueOfParameterNamed(ReconciliationJsonInputParams.OPENING_BALANCE.getValue()); + final BigDecimal closingBalance = command + .bigDecimalValueOfParameterNamed(ReconciliationJsonInputParams.CLOSING_BALANCE.getValue()); + + final Office office = this.officeRepository.findById(currentUser.getOffice().getId()).orElseThrow(); + + final BankStatementImport importRecord = BankStatementImport.create(glAccount, office, fromDate, openingBalance, + closingBalance, fileName, "CSV", currentUser.getId(), DateUtils.getAuditOffsetDateTime(), null); + + this.bankStatementImportRepository.saveAndFlush(importRecord); + + createAuditLog(importRecord.getId(), "CREATED", "Bank statement import created", currentUser.getId()); + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(importRecord.getId()).build(); + + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(command, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } catch (final PersistenceException ee) { + final Throwable throwable = ExceptionUtils.getRootCause(ee.getCause()); + handleDataIntegrityIssues(command, throwable, ee); + return CommandProcessingResult.empty(); + } + } + + @Override + @Transactional + public CommandProcessingResult importTransactions(Long importId, JsonCommand command) { + try { + this.dataValidator.validateForImportTransactions(command.json()); + + final AppUser currentUser = this.context.authenticatedUser(); + final BankStatementImport importRecord = this.bankStatementImportRepository.findById(importId) + .orElseThrow(() -> new ReconciliationNotFoundException(importId)); + + final JsonArray transactions = command.arrayOfParameterNamed(ReconciliationJsonInputParams.TRANSACTIONS.getValue()); + + int totalTransactions = 0; + BigDecimal totalDebits = BigDecimal.ZERO; + BigDecimal totalCredits = BigDecimal.ZERO; + + final List<BankStatementTransaction> transactionList = new ArrayList<>(); + + for (JsonElement element : transactions) { + final LocalDate transactionDate = this.fromApiJsonHelper.extractLocalDateNamed("transactionDate", element.getAsJsonObject()); + final LocalDate valueDate = this.fromApiJsonHelper.extractLocalDateNamed("valueDate", element.getAsJsonObject()); + final String description = this.fromApiJsonHelper.extractStringNamed("description", element.getAsJsonObject()); + final String referenceNumber = this.fromApiJsonHelper.extractStringNamed("referenceNumber", element.getAsJsonObject()); + final BigDecimal debitAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("debitAmount", + element.getAsJsonObject()); + final BigDecimal creditAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("creditAmount", + element.getAsJsonObject()); + final BigDecimal balance = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("balance", element.getAsJsonObject()); + + final BankStatementTransaction transaction = BankStatementTransaction.create(importRecord, transactionDate, valueDate, + description, referenceNumber, null, debitAmount, creditAmount, balance, null, null); + + transactionList.add(transaction); + + totalTransactions++; + if (debitAmount != null) { + totalDebits = totalDebits.add(debitAmount); + } + if (creditAmount != null) { + totalCredits = totalCredits.add(creditAmount); + } + } + + this.bankStatementTransactionRepository.saveAll(transactionList); + + this.bankStatementImportRepository.saveAndFlush(importRecord); + + createAuditLog(importId, "TRANSACTIONS_IMPORTED", totalTransactions + " transactions imported", currentUser.getId()); + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(importId).build(); + + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(command, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } catch (final PersistenceException ee) { + final Throwable throwable = ExceptionUtils.getRootCause(ee.getCause()); + handleDataIntegrityIssues(command, throwable, ee); + return CommandProcessingResult.empty(); + } + } + + @Override + @Transactional + public CommandProcessingResult autoMatch(Long importId) { + final AppUser currentUser = this.context.authenticatedUser(); + final BankStatementImport importRecord = this.bankStatementImportRepository.findById(importId) + .orElseThrow(() -> new ReconciliationNotFoundException(importId)); + + int exactMatches = this.matchingService.matchExact(importId); + int ruleMatches = this.matchingService.matchWithRules(importId); + int totalMatches = exactMatches + ruleMatches; + + this.bankStatementImportRepository.saveAndFlush(importRecord); + + createAuditLog(importId, "AUTO_MATCHED", totalMatches + " automatic matches created (" + exactMatches + " exact, " + ruleMatches + + " rule-based)", currentUser.getId()); + + return new CommandProcessingResultBuilder().withEntityId(importId).build(); + } + + @Override + @Transactional + public CommandProcessingResult createMatch(Long importId, JsonCommand command) { + try { + this.dataValidator.validateForCreateMatch(command.json()); + + final AppUser currentUser = this.context.authenticatedUser(); + final BankStatementImport importRecord = this.bankStatementImportRepository.findById(importId) + .orElseThrow(() -> new ReconciliationNotFoundException(importId)); + + final Long bankTransactionId = command + .longValueOfParameterNamed(ReconciliationJsonInputParams.BANK_TRANSACTION_ID.getValue()); + final Long glEntryId = command.longValueOfParameterNamed(ReconciliationJsonInputParams.GL_ENTRY_ID.getValue()); + + final BankStatementTransaction bankTransaction = this.bankStatementTransactionRepository.findById(bankTransactionId) + .orElseThrow(() -> new ReconciliationNotFoundException(bankTransactionId)); + Review Comment: `createMatch()` fetches `BankStatementImport` by `importId`, but it does not verify that the provided `bankTransactionId` actually belongs to that import (and likewise doesn’t verify any relationship between the match and `importId`). This allows creating/removing matches across different imports. Please add a check that `bankTransaction.getStatementImport().getId().equals(importId)` (and similarly for `matchId` in `removeMatch`). ########## fineract-accounting/src/main/java/org/apache/fineract/accounting/reconciliation/api/ReconciliationApiResource.java: ########## @@ -0,0 +1,479 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.reconciliation.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.reconciliation.data.BankStatementImportData; +import org.apache.fineract.accounting.reconciliation.data.ReconciliationRuleData; +import org.apache.fineract.accounting.reconciliation.data.ReconciliationSummaryData; +import org.apache.fineract.accounting.reconciliation.data.UnreconciledGLEntryData; +import org.apache.fineract.accounting.reconciliation.service.ReconciliationReadPlatformService; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.exception.UnsupportedCommandException; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.infrastructure.core.service.SearchParameters; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.stereotype.Component; + +@Path("/v1/accounting/reconciliation") +@Component +@Tag(name = "Account Reconciliation", description = """ + The Account Reconciliation module enables reconciliation of bank statements with GL account entries. + It supports importing bank transactions, automatic and manual matching of transactions, + adjustments, and completion/approval workflows. + """) +@RequiredArgsConstructor +public class ReconciliationApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSION = "ACCOUNTRECONCILIATION"; + + private final PlatformSecurityContext context; + private final ReconciliationReadPlatformService readPlatformService; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(tags = { "Account Reconciliation" }, summary = "List Reconciliations", description = """ + Retrieve a list of all reconciliations with optional filters. + + Example Requests: + + accounting/reconciliation + + accounting/reconciliation?glAccountId=1 + + accounting/reconciliation?status=PENDING&offset=0&limit=10 + """) + @ApiResponse(responseCode = "200", description = "OK") + public Page<BankStatementImportData> retrieveAll(@QueryParam("glAccountId") @Parameter(description = "GL Account ID") final Long glAccountId, + @QueryParam("fromDate") @Parameter(description = "From Date") final String fromDateStr, + @QueryParam("toDate") @Parameter(description = "To Date") final String toDateStr, + @QueryParam("status") @Parameter(description = "Status") final String status, + @QueryParam("offset") @Parameter(description = "offset") final Integer offset, + @QueryParam("limit") @Parameter(description = "limit") final Integer limit) { + + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate fromDate = fromDateStr != null ? LocalDate.parse(fromDateStr, DateUtils.DEFAULT_DATE_FORMATTER) : null; + final LocalDate toDate = toDateStr != null ? LocalDate.parse(toDateStr, DateUtils.DEFAULT_DATE_FORMATTER) : null; + + final SearchParameters searchParameters = SearchParameters.builder().limit(limit).offset(offset).build(); + return this.readPlatformService.retrieveAll(glAccountId, fromDate, toDate, status, searchParameters); + } + + @GET + @Path("{importId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(tags = { "Account Reconciliation" }, summary = "Retrieve a Reconciliation", description = """ + Retrieve details of a specific reconciliation import. + + Example Request: + + accounting/reconciliation/1 + """) + @ApiResponse(responseCode = "200", description = "OK") + public BankStatementImportData retrieveOne(@PathParam("importId") @Parameter(description = "importId") final Long importId) { + + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + return this.readPlatformService.retrieveOne(importId); + } + + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(tags = { "Account Reconciliation" }, summary = "Create Reconciliation Import", description = """ + Create a new reconciliation import for a GL account. + + Mandatory Fields: + glAccountId, importDate + + Example Request: + + { + "glAccountId": 1, + "importDate": "2024-01-15", + "description": "January 2024 Bank Statement" + } + """) + @RequestBody(required = true) + @ApiResponse(responseCode = "200", description = "OK") + public CommandProcessingResult createImport(final String apiRequestBodyAsJson) { + + final CommandWrapper commandRequest = new CommandWrapperBuilder().createReconciliationImport() + .withJson(apiRequestBodyAsJson).build(); + + return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + } + + @POST + @Path("{importId}/transactions") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(tags = { "Account Reconciliation" }, summary = "Import Bank Transactions", description = """ + Import bank transactions for a reconciliation import. + + Mandatory Fields: + transactions (array) + + Example Request: + + { + "transactions": [ + { + "transactionDate": "2024-01-10", + "description": "Payment received", + "amount": 1000.00, + "type": "CREDIT" + } + ] + } + """) + @RequestBody(required = true) + @ApiResponse(responseCode = "200", description = "OK") + public CommandProcessingResult importTransactions(@PathParam("importId") @Parameter(description = "importId") final Long importId, + final String apiRequestBodyAsJson) { + + final CommandWrapper commandRequest = new CommandWrapperBuilder().importReconciliationTransactions(importId) + .withJson(apiRequestBodyAsJson).build(); + + return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + } + + @POST + @Path("{importId}/auto-match") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(tags = { "Account Reconciliation" }, summary = "Auto-Match Transactions", description = """ + Automatically match bank transactions with GL entries using configured rules. + + Example Request: + + POST accounting/reconciliation/1/auto-match + """) + @ApiResponse(responseCode = "200", description = "OK") + public CommandProcessingResult autoMatch(@PathParam("importId") @Parameter(description = "importId") final Long importId) { + + final CommandWrapper commandRequest = new CommandWrapperBuilder().autoMatchReconciliation(importId).build(); + + return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + } + + @POST + @Path("{importId}/matches") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(tags = { "Account Reconciliation" }, summary = "Create Manual Match", description = """ + Manually create a match between a bank transaction and GL entry. + + Mandatory Fields: + bankTransactionId, glEntryId + + Example Request: + + { + "bankTransactionId": 1, + "glEntryId": 100 + } + """) + @RequestBody(required = true) + @ApiResponse(responseCode = "200", description = "OK") + public CommandProcessingResult createMatch(@PathParam("importId") @Parameter(description = "importId") final Long importId, + final String apiRequestBodyAsJson) { + + final CommandWrapper commandRequest = new CommandWrapperBuilder().createReconciliationMatch(importId) + .withJson(apiRequestBodyAsJson).build(); + + return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + } + + @DELETE + @Path("{importId}/matches/{matchId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(tags = { "Account Reconciliation" }, summary = "Remove Match", description = """ + Remove a reconciliation match. + + Example Request: + + DELETE accounting/reconciliation/1/matches/5 + """) + @ApiResponse(responseCode = "200", description = "OK") + public CommandProcessingResult removeMatch(@PathParam("importId") @Parameter(description = "importId") final Long importId, + @PathParam("matchId") @Parameter(description = "matchId") final Long matchId) { + + final CommandWrapper commandRequest = new CommandWrapperBuilder().removeReconciliationMatch(importId, matchId).build(); + + return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + } + + @POST + @Path("{importId}/adjustments") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(tags = { "Account Reconciliation" }, summary = "Create Adjustment", description = """ + Create a reconciliation adjustment entry. + + Mandatory Fields: + adjustmentDate, amount, type, description + + Example Request: + + { + "adjustmentDate": "2024-01-15", + "amount": 50.00, + "type": "OUTSTANDING_CHEQUE", + "description": "Cheque #12345 not yet cleared" + } + """) + @RequestBody(required = true) + @ApiResponse(responseCode = "200", description = "OK") + public CommandProcessingResult createAdjustment(@PathParam("importId") @Parameter(description = "importId") final Long importId, + final String apiRequestBodyAsJson) { + + final CommandWrapper commandRequest = new CommandWrapperBuilder().createReconciliationAdjustment(importId) + .withJson(apiRequestBodyAsJson).build(); + + return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + } + + @GET + @Path("{importId}/summary") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(tags = { "Account Reconciliation" }, summary = "Get Reconciliation Summary", description = """ + Retrieve summary information for a reconciliation import. + + Example Request: + + accounting/reconciliation/1/summary + """) + @ApiResponse(responseCode = "200", description = "OK") + public ReconciliationSummaryData retrieveSummary(@PathParam("importId") @Parameter(description = "importId") final Long importId) { + + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + return this.readPlatformService.retrieveSummary(importId); + } + + @POST + @Path("{importId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(tags = { "Account Reconciliation" }, summary = "Complete or Approve Reconciliation", description = """ + Complete or approve a reconciliation import. + + Supported Commands: + - complete: Mark reconciliation as complete + - approve: Approve a completed reconciliation + + Example Requests: + + POST accounting/reconciliation/1?command=complete + + POST accounting/reconciliation/1?command=approve + """) + @RequestBody(required = false) + @ApiResponse(responseCode = "200", description = "OK") + public CommandProcessingResult handleCommand(@PathParam("importId") @Parameter(description = "importId") final Long importId, + @QueryParam("command") @Parameter(description = "command") final String commandParam, final String apiRequestBodyAsJson) { + + CommandWrapper commandRequest = null; + + if ("complete".equalsIgnoreCase(commandParam)) { + commandRequest = new CommandWrapperBuilder().completeReconciliation(importId).withJson(apiRequestBodyAsJson).build(); + } else if ("approve".equalsIgnoreCase(commandParam)) { + commandRequest = new CommandWrapperBuilder().approveReconciliation(importId).withJson(apiRequestBodyAsJson).build(); + } + + if (commandRequest == null) { + throw new UnsupportedCommandException(commandParam, + "Unsupported command: " + commandParam + ". Supported commands are: complete, approve"); + } + + return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + } + + @DELETE + @Path("{importId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(tags = { "Account Reconciliation" }, summary = "Delete Reconciliation", description = """ + Delete a reconciliation import. + + Example Request: + + DELETE accounting/reconciliation/1 + """) + @ApiResponse(responseCode = "200", description = "OK") + public CommandProcessingResult deleteImport(@PathParam("importId") @Parameter(description = "importId") final Long importId) { + + final CommandWrapper commandRequest = new CommandWrapperBuilder().deleteReconciliationImport(importId).build(); + + return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + } + + @GET + @Path("unreconciled-entries") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(tags = { "Account Reconciliation" }, summary = "Get Unreconciled GL Entries", description = """ + Retrieve unreconciled GL entries for a GL account. + + Mandatory Parameters: + glAccountId + + Example Requests: + + accounting/reconciliation/unreconciled-entries?glAccountId=1 + + accounting/reconciliation/unreconciled-entries?glAccountId=1&fromDate=2024-01-01&toDate=2024-01-31 + """) + @ApiResponse(responseCode = "200", description = "OK") + public List<UnreconciledGLEntryData> retrieveUnreconciledEntries( + @QueryParam("glAccountId") @Parameter(description = "glAccountId") final Long glAccountId, + @QueryParam("fromDate") @Parameter(description = "fromDate") final String fromDateStr, + @QueryParam("toDate") @Parameter(description = "toDate") final String toDateStr) { + + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate fromDate = fromDateStr != null ? LocalDate.parse(fromDateStr, DateUtils.DEFAULT_DATE_FORMATTER) : null; + final LocalDate toDate = toDateStr != null ? LocalDate.parse(toDateStr, DateUtils.DEFAULT_DATE_FORMATTER) : null; + + return this.readPlatformService.retrieveUnreconciledGLEntries(glAccountId, fromDate, toDate); + } + + @GET + @Path("rules") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(tags = { "Account Reconciliation" }, summary = "List Reconciliation Rules", description = """ + Retrieve all reconciliation rules, optionally filtered by GL account. + + Example Requests: + + accounting/reconciliation/rules + + accounting/reconciliation/rules?glAccountId=1 + """) + @ApiResponse(responseCode = "200", description = "OK") + public List<ReconciliationRuleData> retrieveAllRules( + @QueryParam("glAccountId") @Parameter(description = "glAccountId") final Long glAccountId) { + + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + Review Comment: `retrieveAllRules()` advertises the `glAccountId` filter as optional, but the implementation always executes `WHERE rr.gl_account_id = ?` and passes through the provided value. When `glAccountId` is null this will return no rows (or behave unexpectedly), not “all rules”. Handle the null case explicitly (e.g., omit the WHERE clause and return all rules). ```suggestion Retrieve reconciliation rules for a GL account. Mandatory Parameters: glAccountId Example Requests: accounting/reconciliation/rules?glAccountId=1 """) @ApiResponse(responseCode = "200", description = "OK") public List<ReconciliationRuleData> retrieveAllRules( @QueryParam("glAccountId") @Parameter(description = "glAccountId (required)") final Long glAccountId) { this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); if (glAccountId == null) { throw new UnsupportedCommandException("The 'glAccountId' query parameter is required for listing reconciliation rules."); } ``` ########## docs/LOANS_API_IMPROVEMENTS.md: ########## @@ -0,0 +1,83 @@ +# Fineract Loans API Improvements + +This document describes the improvements made to the Apache Fineract Loans API. + +## 1. Fix: Loans Endpoint Pagination on MariaDB/MySQL + +### Problem + +The `GET /v1/loans` endpoint was returning `totalFilteredRecords: 0` even when loans existed in the database. This broke pagination in frontend applications. + Review Comment: The PR title suggests this is primarily an account reconciliation change, but this document (and the code changes it describes) focuses on Loans API pagination and adding `productId` filtering. Consider updating the PR title/description to reflect the broader scope, or splitting unrelated changes into separate PRs for easier review/release notes. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
