#include "cashflow.h" #include "ui_cashflow.h" #include "settingsdialog.h" #include #include #include #include #include #include #include #include CashFlow::CashFlow(QWidget *parent) : QMainWindow(parent) , ui(new Ui::CashFlow) , currentTransactionId(-1) , currentRecurringId(-1) , startingBalance(0.0) , currentAmountFont("Courier New", 10) , weekStartDay(1) // Default to Monday { ui->setupUi(this); // Initialize database database = new Database(); // Try to open default database QString defaultDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); QDir().mkpath(defaultDir); QString defaultPath = defaultDir + "/default.cashflo.sqlite"; setupConnections(); if (!openDatabase(defaultPath)) { 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() { delete database; delete ui; } void CashFlow::setupConnections() { // File menu connect(ui->actionNew, &QAction::triggered, this, &CashFlow::onNewFile); connect(ui->actionOpen, &QAction::triggered, this, &CashFlow::onOpenFile); connect(ui->actionQuit, &QAction::triggered, this, &CashFlow::onQuit); // Settings menu connect(ui->actionPreferences, &QAction::triggered, this, &CashFlow::onPreferences); // Transaction tab connect(ui->dateFromEdit, &QDateEdit::dateChanged, this, &CashFlow::onDateRangeChanged); connect(ui->dateToEdit, &QDateEdit::dateChanged, this, &CashFlow::onDateRangeChanged); connect(ui->periodCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &CashFlow::onPeriodChanged); connect(ui->accountFilterCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &CashFlow::onDateRangeChanged); connect(ui->showAccountBalancesCheck, &QCheckBox::stateChanged, this, &CashFlow::onDateRangeChanged); // Auto-save period and show balances settings connect(ui->periodCombo, QOverload::of(&QComboBox::currentIndexChanged), this, [this]() { database->setSetting("default_period", QString::number(ui->periodCombo->currentIndex())); }); connect(ui->showAccountBalancesCheck, &QCheckBox::stateChanged, this, [this]() { database->setSetting("show_account_balances", QString::number(ui->showAccountBalancesCheck->isChecked() ? 1 : 0)); }); connect(ui->transactionTable, &QTableWidget::itemSelectionChanged, this, &CashFlow::onTransactionSelected); 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::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::of(&QComboBox::currentIndexChanged), this, &CashFlow::onRecurringRuleChanged); connect(ui->entryDateEdit, &QDateEdit::dateChanged, this, &CashFlow::onTransactionDateChanged); // Set up Delete key shortcut for transaction table ui->deleteBtn->setShortcut(Qt::Key_Delete); // Color-code amount inputs connect(ui->entryAmountSpin, QOverload::of(&QDoubleSpinBox::valueChanged), this, &CashFlow::updateAmountColors); connect(ui->recurringAmountSpin, QOverload::of(&QDoubleSpinBox::valueChanged), this, &CashFlow::updateAmountColors); // Recurring tab connect(ui->recurringTable, &QTableWidget::itemSelectionChanged, this, &CashFlow::onRecurringSelected); connect(ui->saveRecurringBtn, &QPushButton::clicked, this, &CashFlow::onSaveRecurring); connect(ui->newRecurringBtn, &QPushButton::clicked, this, &CashFlow::onNewRecurring); connect(ui->deleteRecurringBtn, &QPushButton::clicked, this, &CashFlow::onDeleteRecurring); // Set up Delete key shortcut for recurring table ui->deleteRecurringBtn->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_Delete)); } void CashFlow::refreshView() { // Populate account filter dropdown with unique accounts // Block signals to prevent recursive refresh ui->accountFilterCombo->blockSignals(true); QString currentFilter = ui->accountFilterCombo->currentText(); ui->accountFilterCombo->clear(); ui->accountFilterCombo->addItem("All Accounts"); // Get unique account names and categories QSet accounts; QSet categories; for (const Transaction &t : database->getAllTransactions()) { if (!t.account.isEmpty()) accounts.insert(t.account); if (!t.category.isEmpty()) categories.insert(t.category); } for (const RecurringRule &r : database->getAllRecurringRules()) { if (!r.account.isEmpty()) accounts.insert(r.account); if (!r.category.isEmpty()) categories.insert(r.category); } QStringList sortedAccounts = accounts.values(); sortedAccounts.sort(); ui->accountFilterCombo->addItems(sortedAccounts); // Populate entry form account combo ui->entryAccountCombo->blockSignals(true); QString currentAccount = ui->entryAccountCombo->currentText(); ui->entryAccountCombo->clear(); ui->entryAccountCombo->addItems(sortedAccounts); ui->entryAccountCombo->setCurrentText(currentAccount); ui->entryAccountCombo->blockSignals(false); // Populate entry form category combo ui->entryCategoryCombo->blockSignals(true); QString currentCategory = ui->entryCategoryCombo->currentText(); ui->entryCategoryCombo->clear(); QStringList sortedCategories = categories.values(); sortedCategories.sort(); ui->entryCategoryCombo->addItems(sortedCategories); ui->entryCategoryCombo->setCurrentText(currentCategory); ui->entryCategoryCombo->blockSignals(false); // Populate recurring rules combo populateRecurringRulesCombo(); // Populate recurring rule account and category combos ui->recurringAccountCombo->blockSignals(true); ui->recurringAccountCombo->clear(); ui->recurringAccountCombo->addItems(sortedAccounts); ui->recurringAccountCombo->blockSignals(false); ui->recurringCategoryCombo->blockSignals(true); ui->recurringCategoryCombo->clear(); ui->recurringCategoryCombo->addItems(sortedCategories); ui->recurringCategoryCombo->blockSignals(false); // Restore previous selection if possible int index = ui->accountFilterCombo->findText(currentFilter); if (index >= 0) { ui->accountFilterCombo->setCurrentIndex(index); } ui->accountFilterCombo->blockSignals(false); refreshTransactionTable(); refreshRecurringTable(); calculateAndDisplayBalance(); } void CashFlow::refreshTransactionTable() { QList allTransactions = getAllTransactionsInRange(); ui->transactionTable->setRowCount(0); ui->transactionTable->setColumnCount(7); ui->transactionTable->setHorizontalHeaderLabels({"Date", "Amount", "Balance", "Account", "Category", "Description", "Type"}); double runningBalance = startingBalance; QMap accountBalances; // Track per-account balances QDate currentPeriodEnd; QString periodLabel; int periodCount = 1; // Determine period type PeriodType periodType = static_cast(ui->periodCombo->currentIndex()); // Get first period end date if (!allTransactions.isEmpty()) { currentPeriodEnd = getPeriodEnd(allTransactions.first().date, periodType); periodLabel = getPeriodLabel(allTransactions.first().date, periodType, periodCount); } for (const Transaction &t : allTransactions) { // Check if we've crossed into a new period if (t.date > currentPeriodEnd) { // Insert period end row insertPeriodEndRow(periodLabel, runningBalance, accountBalances); // Move to next period periodCount++; currentPeriodEnd = getPeriodEnd(t.date, periodType); periodLabel = getPeriodLabel(t.date, periodType, periodCount); } // Update balances runningBalance += t.amount; accountBalances[t.account] += t.amount; // Insert transaction row int row = ui->transactionTable->rowCount(); ui->transactionTable->insertRow(row); // 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); 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(displayAmount))); amountItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); amountItem->setFont(currentAmountFont); amountItem->setFlags(amountItem->flags() & ~Qt::ItemIsEditable); if (displayAmount < 0) { amountItem->setForeground(QColor(200, 0, 0)); } else { amountItem->setForeground(QColor(0, 150, 0)); } ui->transactionTable->setItem(row, 1, amountItem); // Format balance with right-align, monospace 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 (displayBalance < 0) { balanceItem->setForeground(QColor(200, 0, 0)); } ui->transactionTable->setItem(row, 2, balanceItem); QTableWidgetItem *accountItem = new QTableWidgetItem(t.account); accountItem->setFlags(accountItem->flags() & ~Qt::ItemIsEditable); ui->transactionTable->setItem(row, 3, accountItem); QTableWidgetItem *categoryItem = new QTableWidgetItem(t.category); categoryItem->setFlags(categoryItem->flags() & ~Qt::ItemIsEditable); ui->transactionTable->setItem(row, 4, categoryItem); // 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); 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: 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); } } } // Insert final period end row if (!allTransactions.isEmpty()) { insertPeriodEndRow(periodLabel, runningBalance, accountBalances); } ui->transactionTable->resizeColumnsToContents(); // Set minimum and optimal widths for specific columns ui->transactionTable->setColumnWidth(0, 100); // Date ui->transactionTable->setColumnWidth(1, 100); // Amount ui->transactionTable->setColumnWidth(2, 100); // Balance ui->transactionTable->setColumnWidth(3, 120); // Account ui->transactionTable->setColumnWidth(4, 120); // Category ui->transactionTable->horizontalHeader()->setStretchLastSection(false); ui->transactionTable->setColumnWidth(5, 250); // Description ui->transactionTable->setColumnWidth(6, 80); // Type } QDate CashFlow::getPeriodEnd(const QDate &date, PeriodType periodType) { switch (periodType) { case Daily: return date; case Weekly: { // End on day before week start day int weekEndDay = (weekStartDay == 1) ? 7 : weekStartDay - 1; int currentDay = date.dayOfWeek(); int daysUntilWeekEnd = (weekEndDay - currentDay + 7) % 7; if (daysUntilWeekEnd == 0 && currentDay != weekEndDay) { daysUntilWeekEnd = 7; // If we're past the end, go to next week's end } return date.addDays(daysUntilWeekEnd); } case Monthly: return QDate(date.year(), date.month(), date.daysInMonth()); case Quarterly: { int quarter = (date.month() - 1) / 3; int lastMonthOfQuarter = (quarter + 1) * 3; QDate lastDayOfQuarter(date.year(), lastMonthOfQuarter, 1); return QDate(date.year(), lastMonthOfQuarter, lastDayOfQuarter.daysInMonth()); } } return date; } QString CashFlow::getPeriodLabel(const QDate &date, PeriodType periodType, int count) { QDate periodStart = getPeriodStart(date, periodType); QDate periodEnd = getPeriodEnd(date, periodType); QString dateRange = QString("%1 - %2").arg(periodStart.toString("MM/dd/yy")).arg(periodEnd.toString("MM/dd/yy")); switch (periodType) { case Daily: return QString("DAY %1 (%2) END").arg(count).arg(date.toString("MM/dd/yy")); case Weekly: return QString("WEEK %1 END (%2)").arg(count).arg(dateRange); case Monthly: return QString("%1 END (%2)").arg(date.toString("MMMM yyyy").toUpper()).arg(dateRange); case Quarterly: return QString("Q%1 %2 END (%3)").arg((date.month() - 1) / 3 + 1).arg(date.year()).arg(dateRange); } return ""; } QDate CashFlow::getPeriodStart(const QDate &date, PeriodType periodType) { switch (periodType) { case Daily: return date; case Weekly: { // Start on configured week start day (1=Monday, 7=Sunday) int currentDay = date.dayOfWeek(); // 1=Monday, 7=Sunday int daysFromWeekStart = (currentDay - weekStartDay + 7) % 7; return date.addDays(-daysFromWeekStart); } case Monthly: return QDate(date.year(), date.month(), 1); case Quarterly: { int quarter = (date.month() - 1) / 3; int firstMonthOfQuarter = quarter * 3 + 1; return QDate(date.year(), firstMonthOfQuarter, 1); } } return date; } void CashFlow::insertPeriodEndRow(const QString &label, double balance, const QMap &accountBalances) { int row = ui->transactionTable->rowCount(); ui->transactionTable->insertRow(row); // Build display text with optional account balances QString displayText = QString("%1 Balance: $%2").arg(label).arg(formatCurrency(balance)); if (ui->showAccountBalancesCheck->isChecked() && !accountBalances.isEmpty()) { QStringList accountTexts; QMapIterator it(accountBalances); while (it.hasNext()) { it.next(); accountTexts.append(QString("%1: $%2").arg(it.key()).arg(formatCurrency(it.value()))); } displayText += " " + accountTexts.join(" "); } QTableWidgetItem *spanItem = new QTableWidgetItem(displayText); spanItem->setFont(QFont("Arial", 11, QFont::Bold)); spanItem->setBackground(QColor(180, 180, 180)); spanItem->setForeground(balance < 0 ? QColor(200, 0, 0) : QColor(0, 100, 0)); spanItem->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter); spanItem->setFlags(spanItem->flags() & ~Qt::ItemIsSelectable); // Make non-selectable ui->transactionTable->setItem(row, 0, spanItem); // Span across all columns ui->transactionTable->setSpan(row, 0, 1, 7); // Make the row taller ui->transactionTable->setRowHeight(row, 30); } void CashFlow::refreshRecurringTable() { QList rules = database->getAllRecurringRules(); ui->recurringTable->setRowCount(0); ui->recurringTable->setColumnCount(7); ui->recurringTable->setHorizontalHeaderLabels({"ID", "Name", "Frequency", "Amount", "Account", "Category", "Start Date"}); ui->recurringTable->setEditTriggers(QAbstractItemView::NoEditTriggers); for (const RecurringRule &r : rules) { int row = ui->recurringTable->rowCount(); ui->recurringTable->insertRow(row); ui->recurringTable->setItem(row, 0, new QTableWidgetItem(QString::number(r.id))); ui->recurringTable->setItem(row, 1, new QTableWidgetItem(r.name)); QString freqStr; switch (r.frequency) { case RecurrenceFrequency::Daily: freqStr = "Daily"; break; case RecurrenceFrequency::Weekly: freqStr = "Weekly"; break; case RecurrenceFrequency::BiWeekly: freqStr = "Bi-Weekly"; break; case RecurrenceFrequency::Monthly: freqStr = "Monthly"; break; case RecurrenceFrequency::Yearly: freqStr = "Yearly"; break; default: freqStr = "None"; break; } ui->recurringTable->setItem(row, 2, new QTableWidgetItem(freqStr)); // Amount with color coding, right-align, monospace QTableWidgetItem *amountItem = new QTableWidgetItem(QString("$%1").arg(formatCurrency(r.amount))); amountItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); amountItem->setFont(currentAmountFont); if (r.amount < 0) { amountItem->setForeground(QColor(200, 0, 0)); // Red for debits } else { amountItem->setForeground(QColor(0, 150, 0)); // Green for credits } ui->recurringTable->setItem(row, 3, amountItem); ui->recurringTable->setItem(row, 4, new QTableWidgetItem(r.account)); ui->recurringTable->setItem(row, 5, new QTableWidgetItem(r.category)); ui->recurringTable->setItem(row, 6, new QTableWidgetItem(r.startDate.toString("yyyy-MM-dd"))); } ui->recurringTable->resizeColumnsToContents(); } void CashFlow::calculateAndDisplayBalance() { QList allTransactions = getAllTransactionsInRange(); double endBalance = startingBalance; for (const Transaction &t : allTransactions) { endBalance += t.amount; } ui->startBalanceLabel->setText(QString("Starting Balance: $%1").arg(formatCurrency(startingBalance))); ui->endBalanceLabel->setText(QString("Ending Balance: $%1").arg(formatCurrency(endBalance))); // Color code the ending balance if (endBalance < 0) { ui->endBalanceLabel->setStyleSheet("font-weight: bold; font-size: 12pt; color: red;"); } else if (endBalance < 1000) { ui->endBalanceLabel->setStyleSheet("font-weight: bold; font-size: 12pt; color: orange;"); } else { ui->endBalanceLabel->setStyleSheet("font-weight: bold; font-size: 12pt; color: green;"); } } QList CashFlow::getAllTransactionsInRange() { QDate startDate = ui->dateFromEdit->date(); QDate endDate = ui->dateToEdit->date(); QString accountFilter = ui->accountFilterCombo->currentText(); // Safety check if (accountFilter.isEmpty()) { accountFilter = "All Accounts"; } // Get all transactions from database (includes both actual and projected) QList allTransactions = database->getTransactions(startDate, endDate); // Filter by account if not "All Accounts" if (accountFilter != "All Accounts") { QList filtered; for (const Transaction &t : allTransactions) { if (t.account == accountFilter) { filtered.append(t); } } allTransactions = filtered; } // 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; }); return allTransactions; } 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(); } void CashFlow::onPeriodChanged() { refreshView(); } void CashFlow::onTransactionSelected() { QList selected = ui->transactionTable->selectedItems(); if (selected.isEmpty()) { return; } int row = selected[0]->row(); int id = ui->transactionTable->item(row, 0)->data(Qt::UserRole).toInt(); // Load from database QList allTrans = database->getAllTransactions(); for (const Transaction &t : allTrans) { if (t.id == id) { currentTransactionId = id; loadTransactionToEntry(t); QString typeLabel = (t.type == TransactionType::Estimated) ? "Estimated" : "Actual"; ui->entryStatusLabel->setText(QString("Editing ID: %1 (%2)").arg(id).arg(typeLabel)); return; } } } void CashFlow::onSaveTransaction() { // Skip validation if this is a new empty transaction being auto-saved bool isEmptyNew = (currentTransactionId == -1 && ui->entryAccountCombo->currentText().isEmpty() && ui->entryAmountSpin->value() == 0.0); if (!isEmptyNew) { // Validate required fields if (ui->entryAccountCombo->currentText().isEmpty()) { QMessageBox::warning(this, "Required Field", "Account is required."); return; } if (ui->entryAmountSpin->value() == 0.0) { QMessageBox::warning(this, "Required Field", "Amount cannot be zero."); return; } } Transaction t; t.id = currentTransactionId; t.date = ui->entryDateEdit->date(); t.amount = ui->entryAmountSpin->value(); t.account = ui->entryAccountCombo->currentText(); t.category = ui->entryCategoryCombo->currentText(); t.description = ui->entryDescriptionEdit->text(); 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(); if (manualRuleId != -1 && !manualOccurrenceKey.isEmpty()) { // User manually linked to recurring rule t.recurringId = manualRuleId; t.occurrenceKey = manualOccurrenceKey; t.reconciled = true; // Try to get expected amount from the rule QList rules = database->getAllRecurringRules(); for (const RecurringRule &rule : rules) { if (rule.id == manualRuleId) { t.expectedAmount = rule.amount; break; } } } else if (currentTransactionId != -1) { // Editing existing transaction - preserve existing reconciliation fields QList allTrans = database->getAllTransactions(); for (const Transaction &existing : allTrans) { if (existing.id == currentTransactionId) { t.recurringId = existing.recurringId; t.occurrenceKey = existing.occurrenceKey; 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; } } } else { // New manual transaction with no recurring link t.recurringId = -1; t.reconciled = false; } bool success; if (currentTransactionId == -1) { // New transaction success = database->addTransaction(t); if (success) { // Get the new ID and update currentTransactionId QList allTrans = database->getAllTransactions(); if (!allTrans.isEmpty()) { currentTransactionId = allTrans.last().id; } } } else { // Update existing success = database->updateTransaction(t); } if (success) { // Recalculate all reconciliation checkpoints since balances may have changed recalculateAllReconciliations(); ui->entryStatusLabel->setText("Saved!"); refreshView(); } else { QMessageBox::critical(this, "Error", "Failed to save: " + database->lastError()); } } void CashFlow::onNewTransaction() { clearTransactionEntry(); ui->entryDateEdit->setDate(QDate::currentDate()); ui->entryDateEdit->setFocus(); } void CashFlow::onDeleteTransaction() { if (currentTransactionId == -1) { QMessageBox::warning(this, "No Selection", "Please select a transaction to delete."); return; } 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 { QMessageBox::critical(this, "Error", "Failed to delete: " + database->lastError()); } } } void CashFlow::onCreateAdjustment() { // Get the current reconciliation details if (currentTransactionId == -1) return; QList 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 selected = ui->recurringTable->selectedItems(); if (selected.isEmpty()) { return; } int row = selected[0]->row(); int id = ui->recurringTable->item(row, 0)->text().toInt(); QList rules = database->getAllRecurringRules(); for (const RecurringRule &r : rules) { if (r.id == id) { loadRecurringToEntry(r); currentRecurringId = id; return; } } } void CashFlow::onSaveRecurring() { RecurringRule r; r.id = currentRecurringId; r.name = ui->recurringNameEdit->text(); r.startDate = ui->recurringStartDateEdit->date(); r.amount = ui->recurringAmountSpin->value(); r.account = ui->recurringAccountCombo->currentText(); r.category = ui->recurringCategoryCombo->currentText(); r.description = ui->recurringDescriptionEdit->text(); r.occurrences = -1; // Default to infinite QString freqStr = ui->recurringFrequencyCombo->currentText(); if (freqStr == "Daily") { r.frequency = RecurrenceFrequency::Daily; r.dayOfWeek = -1; r.dayOfMonth = -1; } else if (freqStr == "Weekly") { r.frequency = RecurrenceFrequency::Weekly; r.dayOfWeek = r.startDate.dayOfWeek(); // Use the day of week from start date r.dayOfMonth = -1; } else if (freqStr == "Bi-Weekly") { r.frequency = RecurrenceFrequency::BiWeekly; r.dayOfWeek = r.startDate.dayOfWeek(); // Use the day of week from start date r.dayOfMonth = -1; } else if (freqStr == "Monthly") { r.frequency = RecurrenceFrequency::Monthly; r.dayOfWeek = -1; r.dayOfMonth = r.startDate.day(); // Use the day of month from start date } else if (freqStr == "Yearly") { r.frequency = RecurrenceFrequency::Yearly; r.dayOfWeek = -1; r.dayOfMonth = r.startDate.day(); } bool success; if (currentRecurringId == -1) { success = database->addRecurringRule(r); if (success) { // Get the newly created rule ID to regenerate projections QList 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) { refreshView(); QMessageBox::information(this, "Success", "Recurring rule saved. Projections updated automatically."); } else { QMessageBox::critical(this, "Error", "Failed to save: " + database->lastError()); } } void CashFlow::onNewRecurring() { clearRecurringEntry(); ui->recurringStartDateEdit->setDate(QDate::currentDate()); ui->recurringNameEdit->setFocus(); } void CashFlow::onDeleteRecurring() { if (currentRecurringId == -1) { QMessageBox::warning(this, "No Selection", "Please select a recurring rule to delete."); return; } if (QMessageBox::question(this, "Confirm Delete", "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(); } else { QMessageBox::critical(this, "Error", "Failed to delete: " + database->lastError()); } } } void CashFlow::clearTransactionEntry() { currentTransactionId = -1; ui->entryDateEdit->setDate(QDate::currentDate()); ui->entryAmountSpin->setValue(0.0); ui->entryAccountCombo->setCurrentText(""); ui->entryCategoryCombo->setCurrentText(""); ui->entryDescriptionEdit->clear(); ui->entryTypeCombo->setCurrentIndex(0); ui->entryRecurringCombo->setCurrentIndex(0); // (None) ui->entryOccurrenceEdit->clear(); ui->entryOccurrenceEdit->setEnabled(false); ui->entryStatusLabel->setText("(New transaction)"); updateAmountColors(); } void CashFlow::loadTransactionToEntry(const Transaction &t) { // Block signals to prevent auto-save while loading ui->entryDateEdit->blockSignals(true); ui->entryAmountSpin->blockSignals(true); ui->entryAccountCombo->blockSignals(true); ui->entryCategoryCombo->blockSignals(true); ui->entryDescriptionEdit->blockSignals(true); ui->entryTypeCombo->blockSignals(true); ui->entryRecurringCombo->blockSignals(true); ui->entryOccurrenceEdit->blockSignals(true); ui->entryDateEdit->setDate(t.date); // 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); 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) { // Find and select the rule in combo for (int i = 0; i < ui->entryRecurringCombo->count(); i++) { if (ui->entryRecurringCombo->itemData(i).toInt() == t.recurringId) { ui->entryRecurringCombo->setCurrentIndex(i); break; } } ui->entryOccurrenceEdit->setText(t.occurrenceKey); ui->entryOccurrenceEdit->setEnabled(true); } else { ui->entryRecurringCombo->setCurrentIndex(0); // (None) ui->entryOccurrenceEdit->clear(); ui->entryOccurrenceEdit->setEnabled(false); } ui->entryDateEdit->blockSignals(false); ui->entryAmountSpin->blockSignals(false); ui->entryAccountCombo->blockSignals(false); ui->entryCategoryCombo->blockSignals(false); ui->entryDescriptionEdit->blockSignals(false); ui->entryTypeCombo->blockSignals(false); 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(); } void CashFlow::clearRecurringEntry() { currentRecurringId = -1; ui->recurringNameEdit->clear(); ui->recurringStartDateEdit->setDate(QDate::currentDate()); ui->recurringAmountSpin->setValue(0.0); ui->recurringAccountCombo->setCurrentText(""); ui->recurringCategoryCombo->setCurrentText(""); ui->recurringDescriptionEdit->clear(); ui->recurringFrequencyCombo->setCurrentIndex(3); // Default to Monthly updateAmountColors(); } void CashFlow::loadRecurringToEntry(const RecurringRule &r) { ui->recurringNameEdit->setText(r.name); ui->recurringStartDateEdit->setDate(r.startDate); ui->recurringAmountSpin->setValue(r.amount); ui->recurringAccountCombo->setCurrentText(r.account); ui->recurringCategoryCombo->setCurrentText(r.category); ui->recurringDescriptionEdit->setText(r.description); int freqIndex = 3; // Default monthly switch (r.frequency) { case RecurrenceFrequency::Daily: freqIndex = 0; break; case RecurrenceFrequency::Weekly: freqIndex = 1; break; case RecurrenceFrequency::BiWeekly: freqIndex = 2; break; case RecurrenceFrequency::Monthly: freqIndex = 3; break; case RecurrenceFrequency::Yearly: freqIndex = 4; break; default: break; } ui->recurringFrequencyCombo->setCurrentIndex(freqIndex); updateAmountColors(); } void CashFlow::updateAmountColors() { // Color code transaction amount if (ui->entryAmountSpin->value() < 0) { ui->entryAmountSpin->setStyleSheet("QDoubleSpinBox { color: rgb(200, 0, 0); font-weight: bold; }"); } else if (ui->entryAmountSpin->value() > 0) { ui->entryAmountSpin->setStyleSheet("QDoubleSpinBox { color: rgb(0, 150, 0); font-weight: bold; }"); } else { ui->entryAmountSpin->setStyleSheet(""); } // Color code recurring amount if (ui->recurringAmountSpin->value() < 0) { ui->recurringAmountSpin->setStyleSheet("QDoubleSpinBox { color: rgb(200, 0, 0); font-weight: bold; }"); } else if (ui->recurringAmountSpin->value() > 0) { ui->recurringAmountSpin->setStyleSheet("QDoubleSpinBox { color: rgb(0, 150, 0); font-weight: bold; }"); } else { ui->recurringAmountSpin->setStyleSheet(""); } } void CashFlow::loadSettings() { // Load settings from database QString fontFamily = database->getSetting("amount_font", "Courier New"); int fontSize = database->getSetting("amount_font_size", "10").toInt(); 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(); // 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 { QLocale locale; return locale.toString(amount, 'f', 2); } double CashFlow::calculateBalanceUpTo(const QDate &date, const QString &account) { QList 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 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; QFileInfo fileInfo(filePath); setWindowTitle(QString("CashFlo - %1").arg(fileInfo.fileName())); loadSettings(); clearTransactionEntry(); clearRecurringEntry(); refreshView(); return true; } return false; } void CashFlow::onNewFile() { QString defaultDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); QDir().mkpath(defaultDir); QString fileName = QFileDialog::getSaveFileName( this, "New CashFlo File", defaultDir, "CashFlo Files (*.cashflo.sqlite);;All Files (*)" ); if (fileName.isEmpty()) { return; } // Ensure .cashflo.sqlite extension if (!fileName.endsWith(".cashflo.sqlite", Qt::CaseInsensitive)) { fileName += ".cashflo.sqlite"; } // Remove file if it exists if (QFile::exists(fileName)) { QFile::remove(fileName); } // Close current database delete database; database = new Database(); if (!openDatabase(fileName)) { QMessageBox::critical(this, "Error", "Failed to create new file: " + database->lastError()); } } void CashFlow::onOpenFile() { QString defaultDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); QString fileName = QFileDialog::getOpenFileName( this, "Open CashFlo File", defaultDir, "CashFlo Files (*.cashflo.sqlite);;All Files (*)" ); if (fileName.isEmpty()) { return; } // Close current database delete database; database = new Database(); if (!openDatabase(fileName)) { QMessageBox::critical(this, "Error", "Failed to open file: " + database->lastError()); } } void CashFlow::onQuit() { QApplication::quit(); } void CashFlow::onPreferences() { SettingsDialog dialog(database, this); if (dialog.exec() == QDialog::Accepted) { // Reload settings currentAmountFont = dialog.getCurrentAmountFont(); weekStartDay = dialog.getWeekStartDay(); loadSettings(); refreshView(); } } void CashFlow::populateRecurringRulesCombo() { ui->entryRecurringCombo->clear(); ui->entryRecurringCombo->addItem("(None)", -1); QList rules = database->getAllRecurringRules(); for (const RecurringRule &rule : rules) { QString label = QString("%1 (%2)").arg(rule.name).arg( rule.frequency == RecurrenceFrequency::Daily ? "Daily" : rule.frequency == RecurrenceFrequency::Weekly ? "Weekly" : rule.frequency == RecurrenceFrequency::BiWeekly ? "Bi-weekly" : rule.frequency == RecurrenceFrequency::Monthly ? "Monthly" : rule.frequency == RecurrenceFrequency::Yearly ? "Yearly" : "Unknown" ); ui->entryRecurringCombo->addItem(label, rule.id); } } QString CashFlow::generateOccurrenceKey(const QDate &date, RecurrenceFrequency frequency) const { if (frequency == RecurrenceFrequency::Daily) { return date.toString("yyyy-MM-dd"); } else if (frequency == RecurrenceFrequency::Weekly || frequency == RecurrenceFrequency::BiWeekly) { return QString("%1-W%2").arg(date.year()).arg(date.weekNumber(), 2, 10, QChar('0')); } else if (frequency == RecurrenceFrequency::Monthly) { return date.toString("yyyy-MM"); } else if (frequency == RecurrenceFrequency::Yearly) { return QString::number(date.year()); } return QString(); } void CashFlow::updateOccurrenceKey() { int ruleId = ui->entryRecurringCombo->currentData().toInt(); if (ruleId == -1) { ui->entryOccurrenceEdit->clear(); ui->entryOccurrenceEdit->setEnabled(false); return; } ui->entryOccurrenceEdit->setEnabled(true); // Find the rule to get its frequency QList rules = database->getAllRecurringRules(); for (const RecurringRule &rule : rules) { if (rule.id == ruleId) { QString occurrenceKey = generateOccurrenceKey(ui->entryDateEdit->date(), rule.frequency); ui->entryOccurrenceEdit->setText(occurrenceKey); break; } } } void CashFlow::onRecurringRuleChanged() { updateOccurrenceKey(); } void CashFlow::onTransactionDateChanged() { // Update occurrence key if a recurring rule is selected if (ui->entryRecurringCombo->currentData().toInt() != -1) { updateOccurrenceKey(); } }