File indexing completed on 2024-05-12 05:07:49

0001 /*
0002     SPDX-FileCopyrightText: 2021 Thomas Baumgart <tbaumgart@kde.org>
0003     SPDX-License-Identifier: GPL-2.0-or-later
0004 */
0005 
0006 #include "reconciliationledgerviewpage.h"
0007 #include "ledgerviewpage_p.h"
0008 
0009 // ----------------------------------------------------------------------------
0010 // QT Includes
0011 
0012 #include <QAction>
0013 #include <QKeyEvent>
0014 
0015 // ----------------------------------------------------------------------------
0016 // KDE Includes
0017 
0018 #include <KMessageBox>
0019 
0020 // ----------------------------------------------------------------------------
0021 // Project Includes
0022 
0023 #include "icons.h"
0024 #include "journalmodel.h"
0025 #include "kendingbalancedlg.h"
0026 #include "kmymoneysettings.h"
0027 #include "menuenums.h"
0028 #include "mymoneyaccount.h"
0029 #include "mymoneyenums.h"
0030 #include "mymoneyexception.h"
0031 #include "mymoneyfile.h"
0032 #include "mymoneyreconciliationreport.h"
0033 #include "reconciliationmodel.h"
0034 #include "schedulesjournalmodel.h"
0035 #include "specialdatesmodel.h"
0036 #include "transactionmatcher.h"
0037 #include "widgetenums.h"
0038 
0039 #include "kmmyesno.h"
0040 
0041 using namespace Icons;
0042 using namespace eWidgets;
0043 
0044 class ReconciliationLedgerViewPage::Private : public LedgerViewPage::Private
0045 {
0046 public:
0047     Private(ReconciliationLedgerViewPage* parent)
0048         : LedgerViewPage::Private(parent)
0049         , endingBalanceDlg(nullptr)
0050     {
0051     }
0052 
0053     QStringList doAutoReconciliation(const QStringList& journalEntryIds, const MyMoneyMoney& difference)
0054     {
0055         auto result = journalEntryIds;
0056         const auto journalModel = MyMoneyFile::instance()->journalModel();
0057         MyMoneyMoney transactionsBalance;
0058 
0059         // optimize the most common case - all transactions are already cleared
0060         QStringList unclearedJournalEntryIds;
0061         for (const auto& journalEntryId : journalEntryIds) {
0062             const auto idx = journalModel->indexById(journalEntryId);
0063             if (idx.data(eMyMoney::Model::SplitReconcileFlagRole).value<eMyMoney::Split::State>() == eMyMoney::Split::State::Cleared) {
0064                 transactionsBalance += idx.data(eMyMoney::Model::SplitSharesRole).value<MyMoneyMoney>();
0065             } else {
0066                 qDebug() << "Found uncleared" << journalEntryId;
0067                 unclearedJournalEntryIds.append(journalEntryId);
0068             }
0069         }
0070         if (difference == transactionsBalance) {
0071             return {};
0072         }
0073 
0074         // only one transaction is uncleared
0075         if (unclearedJournalEntryIds.count() == 1) {
0076             const auto idx = journalModel->indexById(unclearedJournalEntryIds.front());
0077             const auto splitAmount = idx.data(eMyMoney::Model::SplitSharesRole).value<MyMoneyMoney>();
0078             if (transactionsBalance + splitAmount == difference) {
0079                 return unclearedJournalEntryIds;
0080             }
0081         }
0082 
0083         // more than one transaction is uncleared - apply the algorithm
0084         // (which I don't understand anymore, the original can be found
0085         // in KGlobalLedgerViewPrivate::automaticReconciliation()
0086         return {};
0087     }
0088 
0089     QStringList journalEntriesToReconcile()
0090     {
0091         const auto endDate = endingBalanceDlg->statementDate();
0092         const auto model = ui->m_ledgerView->model();
0093         const auto journalModel = MyMoneyFile::instance()->journalModel();
0094         const auto rows = model->rowCount();
0095         // collect possible candidates
0096         QStringList journalEntryIds;
0097         for (int row = 0; row < rows; ++row) {
0098             const auto idx = model->index(row, 0);
0099             const auto baseIdx = journalModel->mapToBaseSource(idx);
0100             if (baseIdx.model() == journalModel) {
0101                 if (baseIdx.data(eMyMoney::Model::TransactionPostDateRole).toDate() <= endDate) {
0102                     const auto state = baseIdx.data(eMyMoney::Model::SplitReconcileFlagRole).value<eMyMoney::Split::State>();
0103                     if ((state == eMyMoney::Split::State::NotReconciled) || (state == eMyMoney::Split::State::Cleared)) {
0104                         journalEntryIds.append(baseIdx.data(eMyMoney::Model::IdRole).toString());
0105                     }
0106                 }
0107             }
0108         }
0109         return journalEntryIds;
0110     }
0111 
0112     void autoReconciliation()
0113     {
0114         const auto startBalance = endingBalanceDlg->previousBalance();
0115         const auto endBalance = endingBalanceDlg->endingBalance();
0116 
0117         // collect possible candidates
0118         QStringList journalEntryIds(journalEntriesToReconcile());
0119         if (!journalEntryIds.isEmpty()) {
0120             const auto autoClearList = doAutoReconciliation(journalEntryIds, endBalance - startBalance);
0121             if (!autoClearList.empty()) {
0122                 QString message =
0123                     i18n("KMyMoney has detected transactions matching your reconciliation data.\nWould you like KMyMoney to clear these transactions for you?");
0124                 if (KMessageBox::questionTwoActions(q,
0125                                                     message,
0126                                                     i18n("Automatic reconciliation"),
0127                                                     KMMYesNo::yes(),
0128                                                     KMMYesNo::no(),
0129                                                     "AcceptAutomaticReconciliation")
0130                     == KMessageBox::PrimaryAction) {
0131                     // Select the journal entries to be cleared
0132                     SelectedObjects tempSelections;
0133                     tempSelections.setSelection(SelectedObjects::JournalEntry, autoClearList);
0134                     Q_EMIT q->requestSelectionChanged(tempSelections);
0135                     // mark them cleared
0136                     pActions[eMenu::Action::MarkCleared]->trigger();
0137                     // and reset the selection to what it was before
0138                     Q_EMIT q->requestSelectionChanged(selections);
0139                 }
0140             }
0141         }
0142     }
0143 
0144     void startReconciliation()
0145     {
0146         delete endingBalanceDlg;
0147         endingBalanceDlg = nullptr;
0148 
0149         const auto file = MyMoneyFile::instance();
0150         try {
0151             auto account = file->account(accountId);
0152             endingBalanceDlg = new KEndingBalanceDlg(account, q);
0153             if (account.isAssetLiability()) {
0154                 if (endingBalanceDlg->exec() == QDialog::Accepted) {
0155                     if (KMyMoneySettings::autoReconciliation()) {
0156                         autoReconciliation();
0157                     }
0158                     auto ti = endingBalanceDlg->interestTransaction();
0159                     auto tc = endingBalanceDlg->chargeTransaction();
0160                     MyMoneyFileTransaction ft;
0161                     try {
0162                         account.deletePair("lastReconciledBalance");
0163                         account.setValue("statementBalance", endingBalanceDlg->endingBalance().toString());
0164                         account.setValue("statementDate", endingBalanceDlg->statementDate().toString(Qt::ISODate));
0165                         MyMoneyFile::instance()->modifyAccount(account);
0166 
0167                         if (ti != MyMoneyTransaction()) {
0168                             MyMoneyFile::instance()->addTransaction(ti);
0169                         }
0170                         if (tc != MyMoneyTransaction()) {
0171                             MyMoneyFile::instance()->addTransaction(tc);
0172                         }
0173                         ft.commit();
0174                         file->reconciliationModel()->updateData();
0175 
0176                     } catch (const MyMoneyException& e) {
0177                         qWarning("interest transaction not stored: '%s'", e.what());
0178                     }
0179                     updateSummaryInformation();
0180 
0181                     // select a transaction close to the reconciliation date
0182                     const auto view = ui->m_ledgerView;
0183                     const auto model = view->model();
0184                     const auto rows = model->rowCount();
0185                     const auto targetDate = endingBalanceDlg->statementDate();
0186                     const auto journalModel = MyMoneyFile::instance()->journalModel();
0187                     int rowToSelect = -1;
0188                     for (int row = 0; row < rows; ++row) {
0189                         const auto idx = model->index(row, 0);
0190                         const auto postDate = idx.data(eMyMoney::Model::TransactionPostDateRole).toDate();
0191                         if (postDate.isValid() && MyMoneyModelBase::baseModel(idx) == journalModel) {
0192                             if (postDate <= targetDate) {
0193                                 rowToSelect = row;
0194                             }
0195                         }
0196                     }
0197                     if (rowToSelect != -1) {
0198                         const auto idx = model->index(rowToSelect, 0);
0199                         QStringList selection;
0200                         selection.append(idx.data(eMyMoney::Model::IdRole).toString());
0201                         view->setSelectedJournalEntries(selection);
0202                     }
0203                 } else {
0204                     pActions[eMenu::Action::CancelReconciliation]->trigger();
0205                 }
0206             } else {
0207                 pActions[eMenu::Action::CancelReconciliation]->trigger();
0208             }
0209         } catch (const MyMoneyException& e) {
0210             qDebug() << "Starting reconciliation dialog failed" << e.what();
0211         }
0212     }
0213 
0214     bool cancelReconciliation()
0215     {
0216         const auto file = MyMoneyFile::instance();
0217         auto reconciliationAccount = file->account(accountId);
0218 
0219         reconciliationAccount.deletePair("lastReconciledBalance");
0220         reconciliationAccount.deletePair("statementBalance");
0221         reconciliationAccount.deletePair("statementDate");
0222         MyMoneyFileTransaction ft;
0223         try {
0224             // update the account data
0225             file->modifyAccount(reconciliationAccount);
0226             ft.commit();
0227             file->reconciliationModel()->updateData();
0228 
0229         } catch (const MyMoneyException&) {
0230             qDebug() << "Unexpected exception while cancelling of reconciliation of" << reconciliationAccount.name();
0231         }
0232         return true;
0233     }
0234 
0235     bool finishReconciliation()
0236     {
0237         const auto file = MyMoneyFile::instance();
0238         const auto journalModel = file->journalModel();
0239 
0240         // collect candidates
0241         const QStringList journalEntryIds(journalEntriesToReconcile());
0242         if (!journalEntryIds.isEmpty()) {
0243             auto balance = file->balance(accountId, endingBalanceDlg->statementDate());
0244 
0245             // walk the list of journalEntries to figure out the balance(s)
0246             for (const auto& journalEntryId : qAsConst(journalEntryIds)) {
0247                 const auto idx = journalModel->indexById(journalEntryId);
0248                 if (idx.data(eMyMoney::Model::SplitReconcileFlagRole).value<eMyMoney::Split::State>() == eMyMoney::Split::State::NotReconciled) {
0249                     balance -= idx.data(eMyMoney::Model::SplitSharesRole).value<MyMoneyMoney>();
0250                 }
0251             }
0252 
0253             if (endingBalanceDlg->endingBalance() != balance) {
0254                 auto message = i18n(
0255                     "You are about to finish the reconciliation of this account with a difference between your bank statement and the transactions marked as "
0256                     "cleared.\n"
0257                     "Are you sure you want to finish the reconciliation?");
0258                 if (KMessageBox::questionTwoActions(q, message, i18n("Confirm end of reconciliation"), KMMYesNo::yes(), KMMYesNo::no())
0259                     == KMessageBox::SecondaryAction) {
0260                     return false;
0261                 }
0262             }
0263         }
0264 
0265         MyMoneyFileTransaction ft;
0266 
0267         // refresh object
0268         auto reconciliationAccount = file->account(accountId);
0269 
0270         // only update the last statement balance here, if we haven't a newer one due
0271         // to download of online statements.
0272         if (reconciliationAccount.value("lastImportedTransactionDate").isEmpty()
0273             || QDate::fromString(reconciliationAccount.value("lastImportedTransactionDate"), Qt::ISODate) < endingBalanceDlg->statementDate()) {
0274             reconciliationAccount.setValue("lastStatementBalance", endingBalanceDlg->endingBalance().toString());
0275             // in case we override the last statement balance here, we have to make sure
0276             // that we don't show the online balance anymore, as it might be different
0277             reconciliationAccount.deletePair("lastImportedTransactionDate");
0278         }
0279 
0280         // if this is a newer reconciliation then bump the date
0281         if (reconciliationAccount.lastReconciliationDate() < endingBalanceDlg->statementDate()) {
0282             reconciliationAccount.setLastReconciliationDate(endingBalanceDlg->statementDate());
0283         }
0284 
0285         // keep a record of this reconciliation
0286         reconciliationAccount.addReconciliation(endingBalanceDlg->statementDate(), endingBalanceDlg->endingBalance());
0287 
0288         reconciliationAccount.deletePair("lastReconciledBalance");
0289         reconciliationAccount.deletePair("statementBalance");
0290         reconciliationAccount.deletePair("statementDate");
0291 
0292         try {
0293             // update the account data
0294             file->modifyAccount(reconciliationAccount);
0295 
0296             // walk the list of transactions/splits and mark the cleared ones as reconciled
0297             QStringList reconciledJournalEntryIds;
0298             TransactionMatcher matcher;
0299             for (const auto& journalEntryId : qAsConst(journalEntryIds)) {
0300                 const auto journalEntry = journalModel->itemById(journalEntryId);
0301                 auto sp = journalEntry.split();
0302 
0303                 // skip the ones that are not marked cleared
0304                 if (sp.reconcileFlag() != eMyMoney::Split::State::Cleared)
0305                     continue;
0306 
0307                 auto t = journalEntry.transaction();
0308 
0309                 sp.setReconcileFlag(eMyMoney::Split::State::Reconciled);
0310                 sp.setReconcileDate(endingBalanceDlg->statementDate());
0311                 t.setImported(false);
0312                 t.modifySplit(sp);
0313 
0314                 // update the engine ...
0315                 file->modifyTransaction(t);
0316 
0317                 matcher.accept(t, sp);
0318 
0319                 // ... and the list
0320                 reconciledJournalEntryIds.append(journalEntryId);
0321             }
0322             ft.commit();
0323 
0324             /// send information to plugins through a QAction. Data is
0325             /// a) accountId
0326             /// b) reconciledJournalEntryIds
0327             /// c) statementDate
0328             /// d) previousBalance
0329             /// e) endingBalance
0330 
0331             MyMoneyReconciliationReport report;
0332             report.accountId = reconciliationAccount.id();
0333             report.journalEntryIds = journalEntryIds;
0334             report.statementDate = endingBalanceDlg->statementDate();
0335             report.startingBalance = endingBalanceDlg->previousBalance();
0336             report.endingBalance = endingBalanceDlg->endingBalance();
0337 
0338             if (!report.accountId.isEmpty() && !report.journalEntryIds.isEmpty() && report.statementDate.isValid()) {
0339                 pActions[eMenu::Action::ReconciliationReport]->setData(QVariant::fromValue(report));
0340                 pActions[eMenu::Action::ReconciliationReport]->trigger();
0341             }
0342 
0343         } catch (const MyMoneyException&) {
0344             qDebug("Unexpected exception when setting cleared to reconcile");
0345         }
0346         return true;
0347     }
0348 
0349     void postponeReconciliation()
0350     {
0351         MyMoneyFileTransaction ft;
0352         const auto file = MyMoneyFile::instance();
0353 
0354         // refresh object
0355         auto reconciliationAccount = file->account(accountId);
0356 
0357         if (!reconciliationAccount.id().isEmpty()) {
0358             reconciliationAccount.setValue("lastReconciledBalance", endingBalanceDlg->previousBalance().toString());
0359             reconciliationAccount.setValue("statementBalance", endingBalanceDlg->endingBalance().toString());
0360             reconciliationAccount.setValue("statementDate", endingBalanceDlg->statementDate().toString(Qt::ISODate));
0361 
0362             try {
0363                 file->modifyAccount(reconciliationAccount);
0364                 ft.commit();
0365             } catch (const MyMoneyException&) {
0366                 qDebug("Unexpected exception when setting last reconcile info into account");
0367                 ft.rollback();
0368             }
0369         }
0370     }
0371 
0372     void updateAccountData(const MyMoneyAccount& account) override
0373     {
0374         LedgerViewPage::Private::updateAccountData(account);
0375 
0376         if (account.accountType() == eMyMoney::Account::Type::Investment) {
0377             sortOrderType = LedgerViewSettings::SortOrderReconcileInvest;
0378         } else {
0379             sortOrderType = LedgerViewSettings::SortOrderReconcileStd;
0380         }
0381 
0382         // check if we have a specific sort order or rely on the default
0383         if (!account.value("kmm-sort-reconcile").isEmpty()) {
0384             sortOrder = LedgerSortOrder(account.value("kmm-sort-reconcile"));
0385         } else {
0386             sortOrder = LedgerViewSettings::instance()->sortOrder(sortOrderType);
0387         }
0388     }
0389 
0390     void updateSummaryInformation() const override
0391     {
0392         ui->m_reconciliationContainer->setVisible(endingBalanceDlg != nullptr);
0393         if (endingBalanceDlg) {
0394             const auto endingBalance = endingBalanceDlg->endingBalance();
0395             const auto balance = MyMoneyFile::instance()->journalModel()->clearedBalance(accountId, endingBalanceDlg->statementDate());
0396             ui->m_leftLabel->setText(i18nc("@label:textbox Statement balance", "Statement: %1", endingBalance.formatMoney("", precision)));
0397             ui->m_centerLabel->setText(i18nc("@label:textbox Cleared balance", "Cleared: %1", balance.formatMoney("", precision)));
0398             ui->m_rightLabel->setText(i18nc("@label:textbox Difference to statement", "Difference: %1", (balance - endingBalance).formatMoney("", precision)));
0399             stateFilter->setEndDate(endingBalanceDlg->startDate());
0400         }
0401     }
0402 
0403     void clearFilter() override
0404     {
0405         LedgerViewPage::Private::clearFilter();
0406         stateFilter->setStateFilter(LedgerFilter::State::NotReconciled);
0407         stateFilter->setEndDate(endingBalanceDlg->startDate());
0408     }
0409 
0410     KEndingBalanceDlg* endingBalanceDlg;
0411 };
0412 
0413 ReconciliationLedgerViewPage::ReconciliationLedgerViewPage(QWidget* parent, const QString& configGroupName)
0414     : LedgerViewPage(*new Private(this), parent, configGroupName)
0415 {
0416     // in reconciliation mode we use a fixed state filter
0417     d->ui->m_searchWidget->comboBox()->setCurrentIndex(static_cast<int>(LedgerFilter::State::NotReconciled));
0418     d->ui->m_searchWidget->comboBox()->setDisabled(true);
0419 }
0420 
0421 ReconciliationLedgerViewPage::~ReconciliationLedgerViewPage()
0422 {
0423 }
0424 
0425 void ReconciliationLedgerViewPage::setAccount(const MyMoneyAccount& account)
0426 {
0427     LedgerViewPage::setAccount(account);
0428     if (d->needModelInit) {
0429         return;
0430     }
0431 
0432     d->selections.setSelection(SelectedObjects::ReconciliationAccount, account.id());
0433     d->stateFilter->setStateFilter(LedgerFilter::State::NotReconciled);
0434     d->specialItemFilter->setFilterBalanceMode(SpecialLedgerItemFilter::FilterBalanceMode::FilterBalanceReconciliation);
0435 }
0436 
0437 bool ReconciliationLedgerViewPage::executeAction(eMenu::Action action, const SelectedObjects& selections)
0438 {
0439     Q_UNUSED(selections)
0440 
0441     auto dd = static_cast<ReconciliationLedgerViewPage::Private*>(d);
0442     switch (action) {
0443     case eMenu::Action::StartReconciliation:
0444         dd->startReconciliation();
0445         break;
0446 
0447     case eMenu::Action::PostponeReconciliation:
0448         dd->postponeReconciliation();
0449         break;
0450 
0451     case eMenu::Action::FinishReconciliation:
0452         return dd->finishReconciliation();
0453 
0454     case eMenu::Action::CancelReconciliation:
0455         return dd->cancelReconciliation();
0456 
0457     default:
0458         break;
0459     }
0460     return true;
0461 }
0462 
0463 void ReconciliationLedgerViewPage::updateSummaryInformation(const QHash<QString, AccountBalances>& balances)
0464 {
0465     Q_UNUSED(balances)
0466 
0467     auto dd = static_cast<ReconciliationLedgerViewPage::Private*>(d);
0468     dd->updateSummaryInformation();
0469 }