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 }