aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cashflow.cpp399
-rw-r--r--cashflow.h5
-rw-r--r--cashflow.ui15
-rw-r--r--database.cpp168
-rw-r--r--database.h3
-rw-r--r--settingsdialog.cpp3
-rw-r--r--settingsdialog.ui23
-rw-r--r--transaction.h8
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();
diff --git a/cashflow.h b/cashflow.h
index b75d62b..f76e11f 100644
--- a/cashflow.h
+++ b/cashflow.h
@@ -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);
+ }
+}
+
+
diff --git a/database.h b/database.h
index cdde765..0296675 100644
--- a/database.h
+++ b/database.h
@@ -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) {}