diff options
| author | Calvin Morrison <calvin@pobox.com> | 2025-12-27 19:26:21 -0500 |
|---|---|---|
| committer | Calvin Morrison <calvin@pobox.com> | 2025-12-27 19:26:21 -0500 |
| commit | 5cf763ea3ba2a89acfa5f24422cc71e0ff7fb35b (patch) | |
| tree | ef3541cf3650eb14a46b2a54f1b63b1351c08082 | |
| parent | 5e9b299dfe95a7f99f029802089c047a392eee3a (diff) | |
Add reconciliation system with checkpoints and adjustments
- Add Reconciliation transaction type that appears as visible rows
- Reconciliation checkpoints show expected vs calculated balance
- Red background if out of balance, green if balanced
- Description shows balance status (Balanced or Out of balance by .XX)
- Create Adjustment button appears when reconciliation is out of balance
- Adjustment transactions automatically created to match bank balance
- Reconciliations always sort last on their date
- Auto-recalculate all reconciliations when any transaction changes
- Persistent projection months setting (1-24 months, default 3)
- Persistent date range between sessions
- Fix projection regeneration to start from rule start date
- Fix duplicate projections when converting to actuals
- Grey out irrelevant fields when Reconciliation type selected
| -rw-r--r-- | cashflow.cpp | 399 | ||||
| -rw-r--r-- | cashflow.h | 5 | ||||
| -rw-r--r-- | cashflow.ui | 15 | ||||
| -rw-r--r-- | database.cpp | 168 | ||||
| -rw-r--r-- | database.h | 3 | ||||
| -rw-r--r-- | settingsdialog.cpp | 3 | ||||
| -rw-r--r-- | settingsdialog.ui | 23 | ||||
| -rw-r--r-- | transaction.h | 8 |
8 files changed, 444 insertions, 180 deletions
diff --git a/cashflow.cpp b/cashflow.cpp index f302110..e157a8f 100644 --- a/cashflow.cpp +++ b/cashflow.cpp @@ -2,6 +2,7 @@ #include "ui_cashflow.h" #include "settingsdialog.h" #include <QMessageBox> +#include <QDebug> #include <QDir> #include <QStandardPaths> #include <QFontDialog> @@ -34,6 +35,10 @@ CashFlow::CashFlow(QWidget *parent) QMessageBox::critical(this, "Database Error", "Failed to open default database: " + database->lastError()); return; } + + // Regenerate all projections on startup to ensure they're current + database->regenerateAllProjections(); + refreshView(); // Refresh to show new projection IDs } CashFlow::~CashFlow() @@ -70,6 +75,16 @@ void CashFlow::setupConnections() { connect(ui->saveBtn, &QPushButton::clicked, this, &CashFlow::onSaveTransaction); connect(ui->newBtn, &QPushButton::clicked, this, &CashFlow::onNewTransaction); connect(ui->deleteBtn, &QPushButton::clicked, this, &CashFlow::onDeleteTransaction); + connect(ui->adjustmentBtn, &QPushButton::clicked, this, &CashFlow::onCreateAdjustment); + + // Transaction type change - grey out fields for reconciliation + connect(ui->entryTypeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this](int index) { + bool isReconciliation = (index == 2); // Reconciliation is index 2 + ui->entryCategoryCombo->setEnabled(!isReconciliation); + ui->entryRecurringCombo->setEnabled(!isReconciliation); + ui->entryOccurrenceEdit->setEnabled(!isReconciliation); + ui->adjustmentBtn->setVisible(false); // Hide until we load a reconciliation + }); // Transaction entry recurring rule linking connect(ui->entryRecurringCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &CashFlow::onRecurringRuleChanged); @@ -207,19 +222,24 @@ void CashFlow::refreshTransactionTable() { // Store ID in first column's data for retrieval later QTableWidgetItem *dateItem = new QTableWidgetItem(t.date.toString("MM/dd/yy")); dateItem->setData(Qt::UserRole, t.id); - // Store full transaction for projected items (id == -1) - if (t.id == -1) { - dateItem->setData(Qt::UserRole + 1, QVariant::fromValue(t)); - } dateItem->setFlags(dateItem->flags() & ~Qt::ItemIsEditable); ui->transactionTable->setItem(row, 0, dateItem); + // For reconciliation rows, show expected balance in amount column, variance in balance column + double displayAmount = t.amount; + double displayBalance = runningBalance; + + if (t.type == TransactionType::Reconciliation) { + displayAmount = t.expectedBalance; // Show what bank says + displayBalance = t.expectedBalance - t.calculatedBalance; // Show variance + } + // Format amount with color, right-align, monospace - QTableWidgetItem *amountItem = new QTableWidgetItem(QString("$%1").arg(formatCurrency(t.amount))); + QTableWidgetItem *amountItem = new QTableWidgetItem(QString("$%1").arg(formatCurrency(displayAmount))); amountItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); amountItem->setFont(currentAmountFont); amountItem->setFlags(amountItem->flags() & ~Qt::ItemIsEditable); - if (t.amount < 0) { + if (displayAmount < 0) { amountItem->setForeground(QColor(200, 0, 0)); } else { amountItem->setForeground(QColor(0, 150, 0)); @@ -227,11 +247,11 @@ void CashFlow::refreshTransactionTable() { ui->transactionTable->setItem(row, 1, amountItem); // Format balance with right-align, monospace - QTableWidgetItem *balanceItem = new QTableWidgetItem(QString("$%1").arg(formatCurrency(runningBalance))); + QTableWidgetItem *balanceItem = new QTableWidgetItem(QString("$%1").arg(formatCurrency(displayBalance))); balanceItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); balanceItem->setFont(currentAmountFont); balanceItem->setFlags(balanceItem->flags() & ~Qt::ItemIsEditable); - if (runningBalance < 0) { + if (displayBalance < 0) { balanceItem->setForeground(QColor(200, 0, 0)); } ui->transactionTable->setItem(row, 2, balanceItem); @@ -244,18 +264,35 @@ void CashFlow::refreshTransactionTable() { categoryItem->setFlags(categoryItem->flags() & ~Qt::ItemIsEditable); ui->transactionTable->setItem(row, 4, categoryItem); - QTableWidgetItem *descItem = new QTableWidgetItem(t.description); + // For reconciliation, show calculated status in description + QString displayDescription = t.description; + if (t.type == TransactionType::Reconciliation) { + double variance = t.expectedBalance - t.calculatedBalance; + QString status = (qAbs(variance) < 0.01) ? "Balanced" : QString("Out of balance by $%1").arg(formatCurrency(variance)); + displayDescription = t.description.isEmpty() ? status : QString("%1 - %2").arg(t.description, status); + } + + QTableWidgetItem *descItem = new QTableWidgetItem(displayDescription); descItem->setFlags(descItem->flags() & ~Qt::ItemIsEditable); ui->transactionTable->setItem(row, 5, descItem); - QTableWidgetItem *typeItem = new QTableWidgetItem( - t.type == TransactionType::Actual ? "Actual" : "Estimated"); + QString typeLabel = "Estimated"; + if (t.type == TransactionType::Actual) typeLabel = "Actual"; + else if (t.type == TransactionType::Reconciliation) typeLabel = "Reconciliation"; + + QTableWidgetItem *typeItem = new QTableWidgetItem(typeLabel); typeItem->setFlags(typeItem->flags() & ~Qt::ItemIsEditable); ui->transactionTable->setItem(row, 6, typeItem); - // Color code estimated vs actual - QColor rowColor = t.type == TransactionType::Actual ? - QColor(200, 255, 200) : QColor(255, 255, 200); + // Color code: Actual=green, Estimated=yellow, Reconciliation=red if mismatch, green if balanced + QColor rowColor; + if (t.type == TransactionType::Reconciliation) { + double variance = qAbs(t.expectedBalance - t.calculatedBalance); + rowColor = (variance < 0.01) ? QColor(200, 255, 200) : QColor(255, 200, 200); + } else { + rowColor = t.type == TransactionType::Actual ? QColor(200, 255, 200) : QColor(255, 255, 200); + } + for (int col = 0; col < 7; col++) { if (ui->transactionTable->item(row, col)) { ui->transactionTable->item(row, col)->setBackground(rowColor); @@ -456,14 +493,8 @@ QList<Transaction> CashFlow::getAllTransactionsInRange() { accountFilter = "All Accounts"; } - // Get actual transactions from database - QList<Transaction> actualTransactions = database->getTransactions(startDate, endDate); - - // Generate projected transactions from recurring rules - QList<Transaction> projectedTransactions = generateProjectedTransactions(); - - // Combine - QList<Transaction> allTransactions = actualTransactions + projectedTransactions; + // Get all transactions from database (includes both actual and projected) + QList<Transaction> allTransactions = database->getTransactions(startDate, endDate); // Filter by account if not "All Accounts" if (accountFilter != "All Accounts") { @@ -476,10 +507,15 @@ QList<Transaction> CashFlow::getAllTransactionsInRange() { allTransactions = filtered; } - // Sort by date, then by sort_order, then credits before debits + // Sort by date, then reconciliation always last, then by sort_order, then credits before debits std::sort(allTransactions.begin(), allTransactions.end(), [](const Transaction &a, const Transaction &b) { if (a.date != b.date) return a.date < b.date; + + // Reconciliation transactions always sort LAST on their date + if (a.type == TransactionType::Reconciliation && b.type != TransactionType::Reconciliation) return false; + if (a.type != TransactionType::Reconciliation && b.type == TransactionType::Reconciliation) return true; + if (a.sortOrder != b.sortOrder) return a.sortOrder < b.sortOrder; // Credits (positive amounts) before debits (negative amounts) return a.amount > b.amount; @@ -488,116 +524,11 @@ QList<Transaction> CashFlow::getAllTransactionsInRange() { return allTransactions; } -QList<Transaction> CashFlow::generateProjectedTransactions() { - QList<Transaction> projections; - QList<RecurringRule> rules = database->getAllRecurringRules(); - - QDate startDate = ui->dateFromEdit->date(); - QDate endDate = ui->dateToEdit->date(); - - // Load all actual transactions with recurring_id (converted projections) - QList<Transaction> actualTransactions = database->getAllTransactions(); - QSet<QString> existingOccurrences; // recurring_id:occurrence_key pairs - for (const Transaction &t : actualTransactions) { - if (t.recurringId != -1 && !t.occurrenceKey.isEmpty()) { - existingOccurrences.insert(QString("%1:%2").arg(t.recurringId).arg(t.occurrenceKey)); - } - } - - for (const RecurringRule &rule : rules) { - QDate currentDate = rule.startDate > startDate ? rule.startDate : startDate; - - // Align to proper day based on frequency - if (rule.frequency == RecurrenceFrequency::Weekly || rule.frequency == RecurrenceFrequency::BiWeekly) { - // Find next occurrence of the specified day of week - while (currentDate <= endDate && currentDate.dayOfWeek() != rule.dayOfWeek) { - currentDate = currentDate.addDays(1); - } - } else if (rule.frequency == RecurrenceFrequency::Monthly) { - // Set to the specified day of month - int targetDay = qMin(rule.dayOfMonth, currentDate.daysInMonth()); - currentDate = QDate(currentDate.year(), currentDate.month(), targetDay); - if (currentDate < startDate) { - currentDate = currentDate.addMonths(1); - targetDay = qMin(rule.dayOfMonth, currentDate.daysInMonth()); - currentDate = QDate(currentDate.year(), currentDate.month(), targetDay); - } - } - - int count = 0; - while (currentDate <= endDate) { - if (rule.occurrences != -1 && count >= rule.occurrences) { - break; - } - - if (rule.endDate.isValid() && currentDate > rule.endDate) { - break; - } - - // Generate occurrence key based on frequency - QString occurrenceKey; - if (rule.frequency == RecurrenceFrequency::Daily) { - occurrenceKey = currentDate.toString("yyyy-MM-dd"); - } else if (rule.frequency == RecurrenceFrequency::Weekly || rule.frequency == RecurrenceFrequency::BiWeekly) { - occurrenceKey = QString("%1-W%2").arg(currentDate.year()).arg(currentDate.weekNumber(), 2, 10, QChar('0')); - } else if (rule.frequency == RecurrenceFrequency::Monthly) { - occurrenceKey = currentDate.toString("yyyy-MM"); - } else if (rule.frequency == RecurrenceFrequency::Yearly) { - occurrenceKey = QString::number(currentDate.year()); - } - - // Skip if actual already exists for this occurrence - QString occurrenceCheck = QString("%1:%2").arg(rule.id).arg(occurrenceKey); - if (existingOccurrences.contains(occurrenceCheck)) { - // Actual exists, skip this projection - } else { - Transaction t; - t.id = -1; // Projected transactions have no ID - t.date = currentDate; - t.amount = rule.amount; - t.account = rule.account; - t.category = rule.category; - t.description = rule.description + " (projected)"; - t.type = TransactionType::Estimated; - t.recurringId = rule.id; - t.occurrenceKey = occurrenceKey; - t.reconciled = false; - - projections.append(t); - } - count++; - - // Calculate next occurrence - switch (rule.frequency) { - case RecurrenceFrequency::Daily: - currentDate = currentDate.addDays(1); - break; - case RecurrenceFrequency::Weekly: - currentDate = currentDate.addDays(7); - break; - case RecurrenceFrequency::BiWeekly: - currentDate = currentDate.addDays(14); - break; - case RecurrenceFrequency::Monthly: { - currentDate = currentDate.addMonths(1); - int targetDay = qMin(rule.dayOfMonth, currentDate.daysInMonth()); - currentDate = QDate(currentDate.year(), currentDate.month(), targetDay); - break; - } - case RecurrenceFrequency::Yearly: - currentDate = currentDate.addYears(1); - break; - default: - currentDate = endDate.addDays(1); // Exit loop - break; - } - } - } - - return projections; -} - void CashFlow::onDateRangeChanged() { + // Save date range to settings + database->setSetting("date_from", ui->dateFromEdit->date().toString(Qt::ISODate)); + database->setSetting("date_to", ui->dateToEdit->date().toString(Qt::ISODate)); + refreshView(); } @@ -614,26 +545,14 @@ void CashFlow::onTransactionSelected() { int row = selected[0]->row(); int id = ui->transactionTable->item(row, 0)->data(Qt::UserRole).toInt(); - // If it's a projected transaction (id = -1), load it for editing - if (id == -1) { - QVariant projectedData = ui->transactionTable->item(row, 0)->data(Qt::UserRole + 1); - if (projectedData.canConvert<Transaction>()) { - Transaction t = projectedData.value<Transaction>(); - currentTransactionId = -1; // Will create new actual when saved - currentProjectedTransaction = t; // Store for conversion - loadTransactionToEntry(t); - ui->entryStatusLabel->setText("(Converting Projection to Actual)"); - } - return; - } - // Load from database QList<Transaction> allTrans = database->getAllTransactions(); for (const Transaction &t : allTrans) { if (t.id == id) { currentTransactionId = id; loadTransactionToEntry(t); - ui->entryStatusLabel->setText(QString("Editing ID: %1").arg(id)); + QString typeLabel = (t.type == TransactionType::Estimated) ? "Estimated" : "Actual"; + ui->entryStatusLabel->setText(QString("Editing ID: %1 (%2)").arg(id).arg(typeLabel)); return; } } @@ -664,22 +583,29 @@ void CashFlow::onSaveTransaction() { t.account = ui->entryAccountCombo->currentText(); t.category = ui->entryCategoryCombo->currentText(); t.description = ui->entryDescriptionEdit->text(); - t.type = ui->entryTypeCombo->currentText() == "Actual" ? TransactionType::Actual : TransactionType::Estimated; + + QString typeText = ui->entryTypeCombo->currentText(); + if (typeText == "Actual") t.type = TransactionType::Actual; + else if (typeText == "Reconciliation") t.type = TransactionType::Reconciliation; + else t.type = TransactionType::Estimated; + + // If this is a reconciliation checkpoint, calculate the variance + if (t.type == TransactionType::Reconciliation) { + // amount field holds the expected balance from bank + t.expectedBalance = t.amount; + // Calculate what we think the balance should be + t.calculatedBalance = calculateBalanceUpTo(t.date, t.account); + // Don't overwrite user's description - it's saved as-is + // Category is irrelevant for reconciliation + t.category = ""; + t.amount = 0; // Reconciliation rows don't affect balance + } // Check if user manually linked to a recurring rule int manualRuleId = ui->entryRecurringCombo->currentData().toInt(); QString manualOccurrenceKey = ui->entryOccurrenceEdit->text().trimmed(); - // Check if we're converting a projection to actual - if (currentTransactionId == -1 && currentProjectedTransaction.recurringId != -1) { - // Converting projection - keep recurring link and store expected values - t.recurringId = currentProjectedTransaction.recurringId; - t.occurrenceKey = currentProjectedTransaction.occurrenceKey; - t.expectedAmount = currentProjectedTransaction.amount; - t.expectedDate = currentProjectedTransaction.date; - t.reconciled = true; - t.type = TransactionType::Actual; // Force to actual when converting - } else if (manualRuleId != -1 && !manualOccurrenceKey.isEmpty()) { + if (manualRuleId != -1 && !manualOccurrenceKey.isEmpty()) { // User manually linked to recurring rule t.recurringId = manualRuleId; t.occurrenceKey = manualOccurrenceKey; @@ -693,7 +619,7 @@ void CashFlow::onSaveTransaction() { } } } else if (currentTransactionId != -1) { - // Editing existing transaction - load from database to preserve reconciliation fields + // Editing existing transaction - preserve existing reconciliation fields QList<Transaction> allTrans = database->getAllTransactions(); for (const Transaction &existing : allTrans) { if (existing.id == currentTransactionId) { @@ -702,6 +628,16 @@ void CashFlow::onSaveTransaction() { t.expectedAmount = existing.expectedAmount; t.expectedDate = existing.expectedDate; t.reconciled = existing.reconciled; + // If user is converting Estimated to Actual, mark as reconciled + if (existing.type == TransactionType::Estimated && t.type == TransactionType::Actual) { + t.reconciled = true; + if (t.expectedAmount == 0) { + t.expectedAmount = existing.amount; // Store original projected amount + } + if (!t.expectedDate.isValid()) { + t.expectedDate = existing.date; // Store original projected date + } + } break; } } @@ -728,6 +664,9 @@ void CashFlow::onSaveTransaction() { } if (success) { + // Recalculate all reconciliation checkpoints since balances may have changed + recalculateAllReconciliations(); + ui->entryStatusLabel->setText("Saved!"); refreshView(); } else { @@ -737,7 +676,6 @@ void CashFlow::onSaveTransaction() { void CashFlow::onNewTransaction() { clearTransactionEntry(); - currentProjectedTransaction = Transaction(); // Reset projected transaction ui->entryDateEdit->setDate(QDate::currentDate()); ui->entryDateEdit->setFocus(); } @@ -751,6 +689,8 @@ void CashFlow::onDeleteTransaction() { if (QMessageBox::question(this, "Confirm Delete", QString("Delete transaction ID %1?").arg(currentTransactionId)) == QMessageBox::Yes) { if (database->deleteTransaction(currentTransactionId)) { + // Recalculate all reconciliation checkpoints since balances may have changed + recalculateAllReconciliations(); clearTransactionEntry(); refreshView(); } else { @@ -759,6 +699,61 @@ void CashFlow::onDeleteTransaction() { } } +void CashFlow::onCreateAdjustment() { + // Get the current reconciliation details + if (currentTransactionId == -1) return; + + QList<Transaction> allTrans = database->getAllTransactions(); + Transaction recon; + bool found = false; + + for (const Transaction &t : allTrans) { + if (t.id == currentTransactionId && t.type == TransactionType::Reconciliation) { + recon = t; + found = true; + break; + } + } + + if (!found) return; + + double variance = recon.expectedBalance - recon.calculatedBalance; + + QString msg = QString("Create an adjustment transaction for $%1 to match the bank balance?\n\n" + "Bank says: $%2\n" + "We calculate: $%3\n" + "Adjustment needed: $%4") + .arg(formatCurrency(qAbs(variance))) + .arg(formatCurrency(recon.expectedBalance)) + .arg(formatCurrency(recon.calculatedBalance)) + .arg(formatCurrency(variance)); + + if (QMessageBox::question(this, "Create Adjustment", msg) == QMessageBox::Yes) { + // Create adjustment transaction on the same date, same account + Transaction adj; + adj.id = -1; + adj.date = recon.date; + adj.amount = variance; + adj.account = recon.account; + adj.category = "Adjustment"; + adj.description = "Balance adjustment to match bank statement"; + adj.type = TransactionType::Actual; + adj.recurringId = -1; + adj.reconciled = false; + adj.sortOrder = 0; // Doesn't matter - reconciliation always sorts last + + if (database->addTransaction(adj)) { + // Recalculate reconciliations and refresh + recalculateAllReconciliations(); + refreshView(); + QMessageBox::information(this, "Success", "Adjustment transaction created."); + ui->adjustmentBtn->setVisible(false); // Hide button since we're now balanced + } else { + QMessageBox::critical(this, "Error", "Failed to create adjustment: " + database->lastError()); + } + } +} + void CashFlow::onRecurringSelected() { QList<QTableWidgetItem*> selected = ui->recurringTable->selectedItems(); if (selected.isEmpty()) { @@ -819,8 +814,20 @@ void CashFlow::onSaveRecurring() { bool success; if (currentRecurringId == -1) { success = database->addRecurringRule(r); + if (success) { + // Get the newly created rule ID to regenerate projections + QList<RecurringRule> rules = database->getAllRecurringRules(); + if (!rules.isEmpty()) { + RecurringRule newRule = rules.last(); + database->regenerateProjectionsForRule(newRule); + } + } } else { success = database->updateRecurringRule(r); + if (success) { + // Regenerate projections for updated rule + database->regenerateProjectionsForRule(r); + } } if (success) { @@ -844,7 +851,10 @@ void CashFlow::onDeleteRecurring() { } if (QMessageBox::question(this, "Confirm Delete", - "Delete this recurring rule?") == QMessageBox::Yes) { + "Delete this recurring rule and all its future projections?") == QMessageBox::Yes) { + // Delete future projections first + database->deleteProjectionsForRule(currentRecurringId); + // Then delete the rule if (database->deleteRecurringRule(currentRecurringId)) { clearRecurringEntry(); refreshView(); @@ -881,11 +891,19 @@ void CashFlow::loadTransactionToEntry(const Transaction &t) { ui->entryOccurrenceEdit->blockSignals(true); ui->entryDateEdit->setDate(t.date); - ui->entryAmountSpin->setValue(t.amount); + + // For reconciliation rows, load the expected balance into amount field + double loadAmount = (t.type == TransactionType::Reconciliation) ? t.expectedBalance : t.amount; + ui->entryAmountSpin->setValue(loadAmount); + ui->entryAccountCombo->setCurrentText(t.account); ui->entryCategoryCombo->setCurrentText(t.category); ui->entryDescriptionEdit->setText(t.description); - ui->entryTypeCombo->setCurrentIndex(t.type == TransactionType::Actual ? 1 : 0); + + int typeIndex = 0; // Estimated + if (t.type == TransactionType::Actual) typeIndex = 1; + else if (t.type == TransactionType::Reconciliation) typeIndex = 2; + ui->entryTypeCombo->setCurrentIndex(typeIndex); // Set recurring rule link if present if (t.recurringId != -1) { @@ -913,6 +931,14 @@ void CashFlow::loadTransactionToEntry(const Transaction &t) { ui->entryRecurringCombo->blockSignals(false); ui->entryOccurrenceEdit->blockSignals(false); + // Show adjustment button if this is a reconciliation that's out of balance + if (t.type == TransactionType::Reconciliation) { + double variance = qAbs(t.expectedBalance - t.calculatedBalance); + ui->adjustmentBtn->setVisible(variance >= 0.01); + } else { + ui->adjustmentBtn->setVisible(false); + } + updateAmountColors(); } @@ -977,10 +1003,20 @@ void CashFlow::loadSettings() { bool showAccountBalances = database->getSetting("show_account_balances", "0").toInt(); weekStartDay = database->getSetting("week_start_day", "1").toInt(); + // Load date range + QDate today = QDate::currentDate(); + QString dateFromStr = database->getSetting("date_from", ""); + QString dateToStr = database->getSetting("date_to", ""); + + QDate dateFrom = dateFromStr.isEmpty() ? QDate(today.year(), today.month(), 1) : QDate::fromString(dateFromStr, Qt::ISODate); + QDate dateTo = dateToStr.isEmpty() ? today.addMonths(3) : QDate::fromString(dateToStr, Qt::ISODate); + // Apply to member variables and main UI currentAmountFont = QFont(fontFamily, fontSize); ui->periodCombo->setCurrentIndex(defaultPeriod); ui->showAccountBalancesCheck->setChecked(showAccountBalances); + ui->dateFromEdit->setDate(dateFrom); + ui->dateToEdit->setDate(dateTo); } QString CashFlow::formatCurrency(double amount) const { @@ -988,6 +1024,40 @@ QString CashFlow::formatCurrency(double amount) const { return locale.toString(amount, 'f', 2); } +double CashFlow::calculateBalanceUpTo(const QDate &date, const QString &account) { + QList<Transaction> allTrans = database->getAllTransactions(); + double balance = 0.0; + + // Sort by date to ensure proper ordering + std::sort(allTrans.begin(), allTrans.end(), [](const Transaction &a, const Transaction &b) { + if (a.date != b.date) return a.date < b.date; + return a.sortOrder < b.sortOrder; + }); + + for (const Transaction &t : allTrans) { + if (t.date > date) continue; + if (!account.isEmpty() && t.account != account) continue; + if (t.type == TransactionType::Reconciliation) continue; // Don't count reconciliation rows + + balance += t.amount; + } + + return balance; +} + +void CashFlow::recalculateAllReconciliations() { + QList<Transaction> allTrans = database->getAllTransactions(); + + for (Transaction &t : allTrans) { + if (t.type == TransactionType::Reconciliation) { + // Recalculate the calculated balance + t.calculatedBalance = calculateBalanceUpTo(t.date, t.account); + // Update in database + database->updateTransaction(t); + } + } +} + bool CashFlow::openDatabase(const QString &filePath) { if (database->open(filePath)) { currentFilePath = filePath; @@ -996,11 +1066,6 @@ bool CashFlow::openDatabase(const QString &filePath) { loadSettings(); - // Set default date range (current month to 3 months out) - QDate today = QDate::currentDate(); - ui->dateFromEdit->setDate(QDate(today.year(), today.month(), 1)); - ui->dateToEdit->setDate(today.addMonths(3)); - clearTransactionEntry(); clearRecurringEntry(); refreshView(); @@ -36,6 +36,7 @@ private slots: void onPreferences(); void onRecurringRuleChanged(); void onTransactionDateChanged(); + void onCreateAdjustment(); private: Ui::CashFlow *ui; @@ -46,7 +47,6 @@ private: QFont currentAmountFont; int weekStartDay; QString currentFilePath; - Transaction currentProjectedTransaction; // For converting projections to actuals enum PeriodType { Daily, @@ -61,7 +61,6 @@ private: void refreshRecurringTable(); void calculateAndDisplayBalance(); QList<Transaction> getAllTransactionsInRange(); - QList<Transaction> generateProjectedTransactions(); void clearTransactionEntry(); void loadTransactionToEntry(const Transaction &t); void clearRecurringEntry(); @@ -73,6 +72,8 @@ private: void updateAmountColors(); void loadSettings(); QString formatCurrency(double amount) const; + double calculateBalanceUpTo(const QDate &date, const QString &account); + void recalculateAllReconciliations(); bool openDatabase(const QString &filePath); void populateRecurringRulesCombo(); void updateOccurrenceKey(); diff --git a/cashflow.ui b/cashflow.ui index 4f34590..c69afdb 100644 --- a/cashflow.ui +++ b/cashflow.ui @@ -269,6 +269,11 @@ <string>Actual</string> </property> </item> + <item> + <property name="text"> + <string>Reconciliation</string> + </property> + </item> </widget> </item> <item row="2" column="0"> @@ -324,6 +329,16 @@ </property> </widget> </item> + <item> + <widget class="QPushButton" name="adjustmentBtn"> + <property name="text"> + <string>Create Adjustment</string> + </property> + <property name="visible"> + <bool>false</bool> + </property> + </widget> + </item> </layout> </item> <item row="3" column="4" colspan="2"> diff --git a/database.cpp b/database.cpp index 4041344..1610536 100644 --- a/database.cpp +++ b/database.cpp @@ -96,6 +96,11 @@ bool Database::createTables() { query.exec("ALTER TABLE transactions ADD COLUMN occurrence_key TEXT"); query.exec("ALTER TABLE transactions ADD COLUMN expected_amount REAL"); query.exec("ALTER TABLE transactions ADD COLUMN expected_date TEXT"); + query.exec("ALTER TABLE transactions ADD COLUMN expected_balance REAL DEFAULT 0"); + query.exec("ALTER TABLE transactions ADD COLUMN calculated_balance REAL DEFAULT 0"); + + // Add projection_end_date to recurring_rules (default to 1 year from now) + query.exec("ALTER TABLE recurring_rules ADD COLUMN projection_end_date TEXT"); // Create reconciliation_checkpoints table QString createCheckpoints = R"( @@ -119,21 +124,28 @@ bool Database::createTables() { bool Database::addTransaction(const Transaction &transaction) { QSqlQuery query; query.prepare("INSERT INTO transactions (date, amount, account, category, description, type, recurring_id, sort_order, " - "reconciled, occurrence_key, expected_amount, expected_date) " + "reconciled, occurrence_key, expected_amount, expected_date, expected_balance, calculated_balance) " "VALUES (:date, :amount, :account, :category, :description, :type, :recurring_id, :sort_order, " - ":reconciled, :occurrence_key, :expected_amount, :expected_date)"); + ":reconciled, :occurrence_key, :expected_amount, :expected_date, :expected_balance, :calculated_balance)"); query.bindValue(":date", transaction.date.toString(Qt::ISODate)); query.bindValue(":amount", transaction.amount); query.bindValue(":account", transaction.account); query.bindValue(":category", transaction.category); query.bindValue(":description", transaction.description); - query.bindValue(":type", transaction.type == TransactionType::Actual ? "actual" : "estimated"); + + QString typeStr = "estimated"; + if (transaction.type == TransactionType::Actual) typeStr = "actual"; + else if (transaction.type == TransactionType::Reconciliation) typeStr = "reconciliation"; + query.bindValue(":type", typeStr); + query.bindValue(":recurring_id", transaction.recurringId); query.bindValue(":sort_order", transaction.sortOrder); query.bindValue(":reconciled", transaction.reconciled ? 1 : 0); query.bindValue(":occurrence_key", transaction.occurrenceKey); query.bindValue(":expected_amount", transaction.expectedAmount > 0 ? transaction.expectedAmount : QVariant()); query.bindValue(":expected_date", transaction.expectedDate.isValid() ? transaction.expectedDate.toString(Qt::ISODate) : QVariant()); + query.bindValue(":expected_balance", transaction.expectedBalance); + query.bindValue(":calculated_balance", transaction.calculatedBalance); if (!query.exec()) { errorMsg = query.lastError().text(); @@ -147,20 +159,27 @@ bool Database::updateTransaction(const Transaction &transaction) { query.prepare("UPDATE transactions SET date=:date, amount=:amount, account=:account, category=:category, " "description=:description, type=:type, recurring_id=:recurring_id, sort_order=:sort_order, " "reconciled=:reconciled, occurrence_key=:occurrence_key, expected_amount=:expected_amount, " - "expected_date=:expected_date WHERE id=:id"); + "expected_date=:expected_date, expected_balance=:expected_balance, calculated_balance=:calculated_balance WHERE id=:id"); query.bindValue(":id", transaction.id); query.bindValue(":date", transaction.date.toString(Qt::ISODate)); query.bindValue(":amount", transaction.amount); query.bindValue(":account", transaction.account); query.bindValue(":category", transaction.category); query.bindValue(":description", transaction.description); - query.bindValue(":type", transaction.type == TransactionType::Actual ? "actual" : "estimated"); + + QString typeStr = "estimated"; + if (transaction.type == TransactionType::Actual) typeStr = "actual"; + else if (transaction.type == TransactionType::Reconciliation) typeStr = "reconciliation"; + query.bindValue(":type", typeStr); + query.bindValue(":recurring_id", transaction.recurringId); query.bindValue(":sort_order", transaction.sortOrder); query.bindValue(":reconciled", transaction.reconciled ? 1 : 0); query.bindValue(":occurrence_key", transaction.occurrenceKey); query.bindValue(":expected_amount", transaction.expectedAmount > 0 ? transaction.expectedAmount : QVariant()); query.bindValue(":expected_date", transaction.expectedDate.isValid() ? transaction.expectedDate.toString(Qt::ISODate) : QVariant()); + query.bindValue(":expected_balance", transaction.expectedBalance); + query.bindValue(":calculated_balance", transaction.calculatedBalance); query.bindValue(":sort_order", transaction.sortOrder); if (!query.exec()) { @@ -218,9 +237,9 @@ QList<Transaction> Database::getAllTransactions() { bool Database::addRecurringRule(const RecurringRule &rule) { QSqlQuery query; query.prepare("INSERT INTO recurring_rules (name, frequency, start_date, end_date, amount, " - "account, category, description, day_of_week, day_of_month, occurrences, sort_order) " + "account, category, description, day_of_week, day_of_month, occurrences, sort_order, projection_end_date) " "VALUES (:name, :frequency, :start_date, :end_date, :amount, :account, " - ":category, :description, :day_of_week, :day_of_month, :occurrences, :sort_order)"); + ":category, :description, :day_of_week, :day_of_month, :occurrences, :sort_order, :projection_end_date)"); query.bindValue(":name", rule.name); QString freq; @@ -243,6 +262,7 @@ bool Database::addRecurringRule(const RecurringRule &rule) { query.bindValue(":day_of_month", rule.dayOfMonth); query.bindValue(":occurrences", rule.occurrences); query.bindValue(":sort_order", rule.sortOrder); + query.bindValue(":projection_end_date", rule.projectionEndDate.isValid() ? rule.projectionEndDate.toString(Qt::ISODate) : QVariant()); if (!query.exec()) { errorMsg = query.lastError().text(); @@ -255,7 +275,8 @@ bool Database::updateRecurringRule(const RecurringRule &rule) { QSqlQuery query; query.prepare("UPDATE recurring_rules SET name=:name, frequency=:frequency, start_date=:start_date, " "end_date=:end_date, amount=:amount, account=:account, category=:category, description=:description, " - "day_of_week=:day_of_week, day_of_month=:day_of_month, occurrences=:occurrences, sort_order=:sort_order WHERE id=:id"); + "day_of_week=:day_of_week, day_of_month=:day_of_month, occurrences=:occurrences, sort_order=:sort_order, " + "projection_end_date=:projection_end_date WHERE id=:id"); query.bindValue(":id", rule.id); query.bindValue(":name", rule.name); @@ -279,6 +300,7 @@ bool Database::updateRecurringRule(const RecurringRule &rule) { query.bindValue(":day_of_month", rule.dayOfMonth); query.bindValue(":occurrences", rule.occurrences); query.bindValue(":sort_order", rule.sortOrder); + query.bindValue(":projection_end_date", rule.projectionEndDate.isValid() ? rule.projectionEndDate.toString(Qt::ISODate) : QVariant()); if (!query.exec()) { errorMsg = query.lastError().text(); @@ -339,7 +361,12 @@ Transaction Database::queryToTransaction(QSqlQuery &query) { t.account = query.value("account").toString(); t.category = query.value("category").toString(); t.description = query.value("description").toString(); - t.type = query.value("type").toString() == "actual" ? TransactionType::Actual : TransactionType::Estimated; + + QString typeStr = query.value("type").toString(); + if (typeStr == "actual") t.type = TransactionType::Actual; + else if (typeStr == "reconciliation") t.type = TransactionType::Reconciliation; + else t.type = TransactionType::Estimated; + t.recurringId = query.value("recurring_id").toInt(); t.sortOrder = query.value("sort_order").toInt(); t.reconciled = query.value("reconciled").toInt() == 1; @@ -349,6 +376,8 @@ Transaction Database::queryToTransaction(QSqlQuery &query) { if (!expectedDateStr.isEmpty()) { t.expectedDate = QDate::fromString(expectedDateStr, Qt::ISODate); } + t.expectedBalance = query.value("expected_balance").toDouble(); + t.calculatedBalance = query.value("calculated_balance").toDouble(); return t; } @@ -378,6 +407,10 @@ RecurringRule Database::queryToRecurringRule(QSqlQuery &query) { r.dayOfMonth = query.value("day_of_month").toInt(); r.occurrences = query.value("occurrences").toInt(); r.sortOrder = query.value("sort_order").toInt(); + QString projEndDateStr = query.value("projection_end_date").toString(); + if (!projEndDateStr.isEmpty()) { + r.projectionEndDate = QDate::fromString(projEndDateStr, Qt::ISODate); + } return r; } @@ -404,3 +437,120 @@ bool Database::setSetting(const QString &name, const QString &value) { } return true; } + +void Database::deleteProjectionsForRule(int ruleId) { + QSqlQuery query; + // Delete estimated transactions for this rule only where no actual exists for that date + query.prepare("DELETE FROM transactions WHERE recurring_id = :rule_id AND type = 'estimated' " + "AND NOT EXISTS (SELECT 1 FROM transactions t2 WHERE t2.recurring_id = :rule_id AND t2.date = transactions.date AND t2.type = 'actual')"); + query.bindValue(":rule_id", ruleId); + query.exec(); +} + +void Database::regenerateProjectionsForRule(const RecurringRule &rule) { + // Delete ALL existing projections for this rule (not just future ones) + deleteProjectionsForRule(rule.id); + + // Get projection months setting (default 3) + int projectionMonths = getSetting("projection_months", "3").toInt(); + + // Determine end date for projections + QDate endDate = rule.projectionEndDate.isValid() ? + rule.projectionEndDate : + QDate::currentDate().addMonths(projectionMonths); + + // Start from the rule's start date (not today) + QDate currentDate = rule.startDate; + + // Align to proper day based on frequency + if (rule.frequency == RecurrenceFrequency::Weekly || rule.frequency == RecurrenceFrequency::BiWeekly) { + while (currentDate <= endDate && currentDate.dayOfWeek() != rule.dayOfWeek) { + currentDate = currentDate.addDays(1); + } + } else if (rule.frequency == RecurrenceFrequency::Monthly) { + int targetDay = qMin(rule.dayOfMonth, currentDate.daysInMonth()); + currentDate = QDate(currentDate.year(), currentDate.month(), targetDay); + } + + int count = 0; + while (currentDate <= endDate) { + if (rule.occurrences != -1 && count >= rule.occurrences) { + break; + } + + if (rule.endDate.isValid() && currentDate > rule.endDate) { + break; + } + + // Check if an actual transaction already exists for this date and rule + QSqlQuery checkQuery(db); + checkQuery.prepare("SELECT COUNT(*) FROM transactions WHERE recurring_id = :rid AND date = :date AND type = 'actual'"); + checkQuery.bindValue(":rid", rule.id); + checkQuery.bindValue(":date", currentDate.toString(Qt::ISODate)); + + bool hasActual = false; + if (checkQuery.exec() && checkQuery.next()) { + hasActual = checkQuery.value(0).toInt() > 0; + } + + // Only create projection if no actual exists + if (!hasActual) { + Transaction t; + t.date = currentDate; + t.amount = rule.amount; + t.account = rule.account; + t.category = rule.category; + t.description = rule.description; + t.type = TransactionType::Estimated; + t.recurringId = rule.id; + t.reconciled = false; + t.sortOrder = rule.sortOrder; + + addTransaction(t); + } + count++; + + // Calculate next occurrence + switch (rule.frequency) { + case RecurrenceFrequency::Daily: + currentDate = currentDate.addDays(1); + break; + case RecurrenceFrequency::Weekly: + currentDate = currentDate.addDays(7); + break; + case RecurrenceFrequency::BiWeekly: + currentDate = currentDate.addDays(14); + break; + case RecurrenceFrequency::Monthly: { + currentDate = currentDate.addMonths(1); + int targetDay = qMin(rule.dayOfMonth, currentDate.daysInMonth()); + currentDate = QDate(currentDate.year(), currentDate.month(), targetDay); + break; + } + case RecurrenceFrequency::Yearly: + currentDate = currentDate.addYears(1); + break; + default: + currentDate = endDate.addDays(1); // Exit loop + break; + } + } +} + +void Database::regenerateAllProjections() { + // Delete ALL existing projections + QSqlQuery deleteQuery(db); + deleteQuery.prepare("DELETE FROM transactions WHERE type = 'estimated'"); + if (!deleteQuery.exec()) { + errorMsg = deleteQuery.lastError().text(); + return; + } + + // Regenerate projections for all recurring rules + QList<RecurringRule> rules = getAllRecurringRules(); + for (const RecurringRule &rule : rules) { + regenerateProjectionsForRule(rule); + } +} + + @@ -29,6 +29,9 @@ public: bool updateRecurringRule(const RecurringRule &rule); bool deleteRecurringRule(int id); QList<RecurringRule> getAllRecurringRules(); + void regenerateProjectionsForRule(const RecurringRule &rule); + void deleteProjectionsForRule(int ruleId); + void regenerateAllProjections(); // Reconciliation bool convertToActual(int transactionId, double actualAmount); diff --git a/settingsdialog.cpp b/settingsdialog.cpp index 644fa89..52cf791 100644 --- a/settingsdialog.cpp +++ b/settingsdialog.cpp @@ -31,6 +31,7 @@ void SettingsDialog::loadSettings() int defaultPeriod = database->getSetting("default_period", "2").toInt(); bool showAccountBalances = database->getSetting("show_account_balances", "0").toInt(); weekStartDay = database->getSetting("week_start_day", "1").toInt(); + int projectionMonths = database->getSetting("projection_months", "3").toInt(); // Set UI ui->currencyEdit->setText(currency); @@ -39,6 +40,7 @@ void SettingsDialog::loadSettings() ui->defaultPeriodCombo->setCurrentIndex(defaultPeriod); ui->defaultShowAccountBalancesCheck->setChecked(showAccountBalances); ui->weekStartDayCombo->setCurrentIndex(weekStartDay - 1); + ui->projectionMonthsSpin->setValue(projectionMonths); } void SettingsDialog::saveSettings() @@ -50,6 +52,7 @@ void SettingsDialog::saveSettings() database->setSetting("default_period", QString::number(ui->defaultPeriodCombo->currentIndex())); database->setSetting("show_account_balances", QString::number(ui->defaultShowAccountBalancesCheck->isChecked() ? 1 : 0)); database->setSetting("week_start_day", QString::number(ui->weekStartDayCombo->currentIndex() + 1)); + database->setSetting("projection_months", QString::number(ui->projectionMonthsSpin->value())); weekStartDay = ui->weekStartDayCombo->currentIndex() + 1; } diff --git a/settingsdialog.ui b/settingsdialog.ui index fad4c35..3cc19ef 100644 --- a/settingsdialog.ui +++ b/settingsdialog.ui @@ -135,6 +135,29 @@ </item> </widget> </item> + <item row="5" column="0"> + <widget class="QLabel" name="label_projectionMonths"> + <property name="text"> + <string>Project Rules Ahead:</string> + </property> + </widget> + </item> + <item row="5" column="1"> + <widget class="QSpinBox" name="projectionMonthsSpin"> + <property name="suffix"> + <string> months</string> + </property> + <property name="minimum"> + <number>1</number> + </property> + <property name="maximum"> + <number>24</number> + </property> + <property name="value"> + <number>3</number> + </property> + </widget> + </item> </layout> </widget> </item> diff --git a/transaction.h b/transaction.h index 9628a4e..0d608f2 100644 --- a/transaction.h +++ b/transaction.h @@ -7,7 +7,8 @@ enum class TransactionType { Estimated, - Actual + Actual, + Reconciliation }; enum class RecurrenceFrequency { @@ -33,8 +34,10 @@ struct Transaction { QString occurrenceKey; // e.g. "2025-01" for monthly, "2025-W03" for weekly double expectedAmount; // original projected amount (for variance tracking) QDate expectedDate; // original projected date (for variance tracking) + double expectedBalance; // for reconciliation checkpoints - what bank says + double calculatedBalance; // for reconciliation checkpoints - what we calculated - Transaction() : id(-1), amount(0.0), type(TransactionType::Estimated), recurringId(-1), sortOrder(0), reconciled(false), expectedAmount(0.0) {} + Transaction() : id(-1), amount(0.0), type(TransactionType::Estimated), recurringId(-1), sortOrder(0), reconciled(false), expectedAmount(0.0), expectedBalance(0.0), calculatedBalance(0.0) {} double getBalance() const { return amount; } }; @@ -53,6 +56,7 @@ struct RecurringRule { int dayOfMonth; // 1-31 for monthly, -1 if not applicable int occurrences; // Number of times to repeat, -1 for indefinite int sortOrder; // For ordering transactions on same date + QDate projectionEndDate; // How far into future to generate projections RecurringRule() : id(-1), frequency(RecurrenceFrequency::None), amount(0.0), dayOfWeek(-1), dayOfMonth(-1), occurrences(-1), sortOrder(0) {} |
