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]

Reply via email to