From 88b069141faafd1c5aefda1573b2285a38885ce4 Mon Sep 17 00:00:00 2001 From: Calvin Morrison Date: Sat, 27 Dec 2025 14:19:21 -0500 Subject: initial commit --- .gitignore | 33 +++ CashFlo.pro | 27 ++ README.md | 147 ++++++++++ cashflow.cpp | 907 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ cashflow.h | 72 +++++ cashflow.ui | 619 +++++++++++++++++++++++++++++++++++++++ database.cpp | 364 +++++++++++++++++++++++ database.h | 50 ++++ main.cpp | 11 + transaction.h | 56 ++++ 10 files changed, 2286 insertions(+) create mode 100644 .gitignore create mode 100644 CashFlo.pro create mode 100644 README.md create mode 100644 cashflow.cpp create mode 100644 cashflow.h create mode 100644 cashflow.ui create mode 100644 database.cpp create mode 100644 database.h create mode 100644 main.cpp create mode 100644 transaction.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..611ddb5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Qt build artifacts +*.o +moc_*.cpp +moc_predefs.h +ui_*.h +qrc_*.cpp + +# Compiled binary +CashFlo + +# Qt Creator user files +*.pro.user +*.pro.user.* + +# Database files (user data) +*.db +*.db-shm +*.db-wal + +# OS specific +.DS_Store +Thumbs.db +*~ +*.swp +*.swo + +# IDE +.vscode/ +.idea/ + +# Makefiles (generated by qmake) +Makefile +.qmake.stash diff --git a/CashFlo.pro b/CashFlo.pro new file mode 100644 index 0000000..70333c2 --- /dev/null +++ b/CashFlo.pro @@ -0,0 +1,27 @@ +QT += core gui sql + +greaterThan(QT_MAJOR_VERSION, 4): QT += widgets + +CONFIG += c++17 + +# You can make your code fail to compile if it uses deprecated APIs. +# In order to do so, uncomment the following line. +#DEFINES += QT_DISABLE_DEPRECATED_BEFORE=0x060000 # disables all the APIs deprecated before Qt 6.0.0 + +SOURCES += \ + main.cpp \ + cashflow.cpp \ + database.cpp + +HEADERS += \ + cashflow.h \ + database.h \ + transaction.h + +FORMS += \ + cashflow.ui + +# Default rules for deployment. +qnx: target.path = /tmp/$${TARGET}/bin +else: unix:!android: target.path = /opt/$${TARGET}/bin +!isEmpty(target.path): INSTALLS += target diff --git a/README.md b/README.md new file mode 100644 index 0000000..3845713 --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +# CashFlo - Cash Flow Projection & Reconciliation App + +A Qt-based cash flow projection application with SQLite backend for managing financial transactions and forecasting future cash flow. + +## Features + +### Core Functionality +- **Transaction Management**: Add, edit, and delete transactions with date, amount, account, and description +- **Transaction Types**: + - **Estimated**: Future projections based on recurring rules + - **Actual**: Confirmed transactions after reconciliation +- **Recurring Rules**: Define repeating transactions with various schedules: + - Daily + - Weekly (specify day of week) + - Bi-Weekly (every 2 weeks) + - Monthly (specify day of month) + - Yearly +- **Projection Generation**: Automatically create estimated future transactions from recurring rules +- **Reconciliation**: Convert estimated transactions to actuals with updated amounts +- **Date Range Filtering**: View transactions within specific date ranges +- **Running Balance**: See projected balance based on selected date range + +## Building the Application + +### Prerequisites +- Qt 6 (with Widgets and SQL modules) +- C++17 compiler +- SQLite (included with Qt) + +### Build Instructions +```bash +cd /home/calvin/CashFlo +qmake6 +make +./CashFlo +``` + +## Usage Guide + +### 1. Setting Up Recurring Rules +1. Click the **"Recurring Rules"** tab +2. Click **"Add Recurring Rule"** +3. Enter details: + - Name (e.g., "Rent", "Salary") + - Frequency (Daily, Weekly, Monthly, etc.) + - Start Date + - Number of occurrences (-1 for indefinite) + - Amount (negative for expenses, positive for income) + - Account name + - Description + +**Examples:** +- **Monthly Rent**: Frequency: Monthly, Day: 1, Amount: -2350.00, Occurrences: 12 +- **Bi-weekly Salary**: Frequency: Bi-Weekly, Day: Friday (5), Amount: 3500.00 +- **Weekly Grocery**: Frequency: Weekly, Day: Saturday (6), Amount: -250.00 + +### 2. Generating Projections +1. Set your desired date range (From/To dates) +2. Click **"Generate Projection"** +3. The system will create estimated transactions based on your recurring rules +4. View all projected transactions in the Transactions tab + - Yellow rows = Estimated transactions + - Green rows = Actual transactions + +### 3. Adding Manual Transactions +1. Go to the **"Transactions"** tab +2. Click **"Add Transaction"** +3. Enter date, amount, account, description, and type (Estimated/Actual) + +### 4. Reconciliation (Closing the Books) +When an estimated transaction occurs and you want to record the actual amount: +1. Select the estimated transaction in the table +2. Click **"Reconcile (Est → Actual)"** +3. Enter the actual amount (can be different from estimate) +4. The transaction is now marked as "Actual" and the amount is updated + +### 5. Viewing Cash Flow +- The **"Projected Balance"** shows the cumulative balance for the selected date range +- Sort transactions by clicking column headers +- Adjust date range to see different projection periods + +## Database Schema + +### Transactions Table +```sql +id INTEGER PRIMARY KEY +date TEXT (ISO format) +amount REAL (negative=expense, positive=income) +account TEXT +description TEXT +type TEXT ('estimated' or 'actual') +recurring_id INTEGER (links to recurring rule, -1 if manual) +``` + +### Recurring Rules Table +```sql +id INTEGER PRIMARY KEY +name TEXT +frequency TEXT ('daily', 'weekly', 'biweekly', 'monthly', 'yearly') +start_date TEXT +end_date TEXT (optional) +amount REAL +account TEXT +description TEXT +day_of_week INTEGER (1=Mon, 7=Sun, -1 if not applicable) +day_of_month INTEGER (1-31, -1 if not applicable) +occurrences INTEGER (-1 for indefinite) +``` + +## Database Location +The SQLite database is stored at: +- Linux: `~/.local/share/CashFlo/cashflow.db` +- Created automatically on first run + +## Use Cases + +### Example: 3-Month Budget Planning +1. Add recurring rules for: + - Rent (-$2,350/month on 1st) + - Salary (+$7,000/month, bi-weekly) + - Utilities (-$150/month on 15th) + - Groceries (-$250/week on Saturdays) +2. Set date range: Jan 1 - Mar 31 +3. Generate projection +4. View projected balance to see cash flow over 3 months + +### Example: Reconciling Actual Expenses +1. At end of month, review estimated transactions +2. Select each estimate (e.g., "Grocery estimated $250") +3. Reconcile with actual amount (e.g., actual was $267.43) +4. Transaction converts to "Actual" with correct amount +5. Projected balance updates to reflect reality + +## Tips +- Use negative amounts for expenses (outflows) +- Use positive amounts for income (inflows) +- Generate projections after adding/updating recurring rules +- Reconcile regularly to keep projections accurate +- Use descriptive account names (e.g., "Checking", "Savings", "Credit Card") + +## Future Enhancements (Potential) +- Multiple accounts with transfers +- Categories and reporting +- CSV import/export +- Charts and visualizations +- Budget vs actual comparison +- Search and filtering diff --git a/cashflow.cpp b/cashflow.cpp new file mode 100644 index 0000000..8e48d27 --- /dev/null +++ b/cashflow.cpp @@ -0,0 +1,907 @@ +#include "cashflow.h" +#include "ui_cashflow.h" +#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) +{ + ui->setupUi(this); + + // Initialize database + database = new Database(); + QString dbPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/cashflow.db"; + QDir().mkpath(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)); + + if (!database->open(dbPath)) { + QMessageBox::critical(this, "Database Error", "Failed to open database: " + database->lastError()); + return; + } + + setupConnections(); + 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(); +} + +CashFlow::~CashFlow() +{ + delete database; + delete ui; +} + +void CashFlow::setupConnections() { + // 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); + + // Set up Delete key shortcut for transaction table + ui->deleteBtn->setShortcut(Qt::Key_Delete); + + // Auto-save transaction fields on change + connect(ui->entryDateEdit, &QDateEdit::dateChanged, this, &CashFlow::onTransactionFieldChanged); + connect(ui->entryAmountSpin, QOverload::of(&QDoubleSpinBox::valueChanged), this, &CashFlow::onTransactionFieldChanged); + connect(ui->entryAccountCombo, &QComboBox::currentTextChanged, this, &CashFlow::onTransactionFieldChanged); + connect(ui->entryCategoryCombo, &QComboBox::currentTextChanged, this, &CashFlow::onTransactionFieldChanged); + connect(ui->entryDescriptionEdit, &QLineEdit::textChanged, this, &CashFlow::onTransactionFieldChanged); + connect(ui->entryTypeCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &CashFlow::onTransactionFieldChanged); + + // 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); + + // Settings tab + connect(ui->amountFontBtn, &QPushButton::clicked, this, &CashFlow::onChooseAmountFont); + connect(ui->saveSettingsBtn, &QPushButton::clicked, this, &CashFlow::onSaveSettings); + + // 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 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); + + // Format amount with color, right-align, monospace + QTableWidgetItem *amountItem = new QTableWidgetItem(QString("$%1").arg(formatCurrency(t.amount))); + amountItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + amountItem->setFont(currentAmountFont); + amountItem->setFlags(amountItem->flags() & ~Qt::ItemIsEditable); + if (t.amount < 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(runningBalance))); + balanceItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + balanceItem->setFont(currentAmountFont); + balanceItem->setFlags(balanceItem->flags() & ~Qt::ItemIsEditable); + if (runningBalance < 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); + + QTableWidgetItem *descItem = new QTableWidgetItem(t.description); + descItem->setFlags(descItem->flags() & ~Qt::ItemIsEditable); + ui->transactionTable->setItem(row, 5, descItem); + + QTableWidgetItem *typeItem = new QTableWidgetItem( + t.type == TransactionType::Actual ? "Actual" : "Estimated"); + 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); + 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 Sunday (7) + int daysUntilSunday = 7 - date.dayOfWeek(); + return date.addDays(daysUntilSunday); + } + 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 Monday (1) + int daysFromMonday = date.dayOfWeek() - 1; + return date.addDays(-daysFromMonday); + } + 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 actual transactions from database + QList actualTransactions = database->getTransactions(startDate, endDate); + + // Generate projected transactions from recurring rules + QList projectedTransactions = generateProjectedTransactions(); + + // Combine + QList allTransactions = actualTransactions + projectedTransactions; + + // 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 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; + if (a.sortOrder != b.sortOrder) return a.sortOrder < b.sortOrder; + // Credits (positive amounts) before debits (negative amounts) + return a.amount > b.amount; + }); + + return allTransactions; +} + +QList CashFlow::generateProjectedTransactions() { + QList projections; + QList rules = database->getAllRecurringRules(); + + QDate startDate = ui->dateFromEdit->date(); + QDate endDate = ui->dateToEdit->date(); + + 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; + } + + 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; + + 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() { + 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(); + + // If it's a projected transaction (id = -1), don't load it for editing + if (id == -1) { + ui->entryStatusLabel->setText("(Projected - cannot edit)"); + currentTransactionId = -1; + return; + } + + // Load from database + QList 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)); + return; + } + } +} + +void CashFlow::onSaveTransaction() { + 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(); + t.type = ui->entryTypeCombo->currentText() == "Actual" ? TransactionType::Actual : TransactionType::Estimated; + t.recurringId = -1; // Manual entries don't have recurring ID + + 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) { + ui->entryStatusLabel->setText("Saved!"); + refreshView(); + } else { + QMessageBox::critical(this, "Error", "Failed to save: " + database->lastError()); + } +} + +void CashFlow::onTransactionFieldChanged() { + // Auto-save if we're editing an existing transaction or have data entered + if (currentTransactionId != -1 || + !ui->entryAccountCombo->currentText().isEmpty() || + ui->entryAmountSpin->value() != 0.0) { + onSaveTransaction(); + } +} + +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)) { + clearTransactionEntry(); + refreshView(); + } else { + QMessageBox::critical(this, "Error", "Failed to delete: " + 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); + } else { + success = database->updateRecurringRule(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?") == QMessageBox::Yes) { + 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->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->entryDateEdit->setDate(t.date); + ui->entryAmountSpin->setValue(t.amount); + ui->entryAccountCombo->setCurrentText(t.account); + ui->entryCategoryCombo->setCurrentText(t.category); + ui->entryDescriptionEdit->setText(t.description); + ui->entryTypeCombo->setCurrentIndex(t.type == TransactionType::Actual ? 1 : 0); + + ui->entryDateEdit->blockSignals(false); + ui->entryAmountSpin->blockSignals(false); + ui->entryAccountCombo->blockSignals(false); + ui->entryCategoryCombo->blockSignals(false); + ui->entryDescriptionEdit->blockSignals(false); + ui->entryTypeCombo->blockSignals(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 currency = database->getSetting("currency_symbol", "$"); + 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(); // 2 = Monthly + bool showAccountBalances = database->getSetting("show_account_balances", "0").toInt(); + + // Set settings UI + ui->currencyEdit->setText(currency); + currentAmountFont = QFont(fontFamily, fontSize); + ui->amountFontBtn->setText(QString("%1, %2pt").arg(fontFamily).arg(fontSize)); + ui->defaultPeriodCombo->setCurrentIndex(defaultPeriod); + ui->defaultShowAccountBalancesCheck->setChecked(showAccountBalances); + + // Apply defaults to main UI (only on initial load) + ui->periodCombo->setCurrentIndex(defaultPeriod); + ui->showAccountBalancesCheck->setChecked(showAccountBalances); +} + +void CashFlow::applySettings() { + // Currency symbol is now part of the formatted text in line edits + // Font is already stored in currentAmountFont + + // Refresh to apply font changes + refreshView(); +} + +void CashFlow::onSaveSettings() { + // Save settings to database + database->setSetting("currency_symbol", ui->currencyEdit->text()); + database->setSetting("amount_font", currentAmountFont.family()); + database->setSetting("amount_font_size", QString::number(currentAmountFont.pointSize())); + database->setSetting("default_period", QString::number(ui->defaultPeriodCombo->currentIndex())); + database->setSetting("show_account_balances", QString::number(ui->defaultShowAccountBalancesCheck->isChecked() ? 1 : 0)); + + // Apply settings + applySettings(); + + QMessageBox::information(this, "Settings Saved", "Settings have been saved successfully."); +} + +void CashFlow::onChooseAmountFont() { + bool ok; + QFont selectedFont = QFontDialog::getFont(&ok, currentAmountFont, this, "Choose Amount Font", QFontDialog::MonospacedFonts); + + if (ok) { + currentAmountFont = selectedFont; + ui->amountFontBtn->setText(QString("%1, %2pt").arg(selectedFont.family()).arg(selectedFont.pointSize())); + } +} + +QString CashFlow::formatCurrency(double amount) const { + QLocale locale; + return locale.toString(amount, 'f', 2); +} + + diff --git a/cashflow.h b/cashflow.h new file mode 100644 index 0000000..9de01ae --- /dev/null +++ b/cashflow.h @@ -0,0 +1,72 @@ +#ifndef CASHFLOW_H +#define CASHFLOW_H + +#include +#include +#include +#include "database.h" +#include "transaction.h" + +QT_BEGIN_NAMESPACE +namespace Ui { class CashFlow; } +QT_END_NAMESPACE + +class CashFlow : public QMainWindow +{ + Q_OBJECT + +public: + CashFlow(QWidget *parent = nullptr); + ~CashFlow(); + +private slots: + void onDateRangeChanged(); + void onTransactionSelected(); + void onSaveTransaction(); + void onTransactionFieldChanged(); + void onNewTransaction(); + void onDeleteTransaction(); + void onRecurringSelected(); + void onSaveRecurring(); + void onNewRecurring(); + void onDeleteRecurring(); + void onPeriodChanged(); + +private: + Ui::CashFlow *ui; + Database *database; + int currentTransactionId; + int currentRecurringId; + double startingBalance; + QFont currentAmountFont; + + enum PeriodType { + Daily, + Weekly, + Monthly, + Quarterly + }; + + void setupConnections(); + void refreshView(); + void refreshTransactionTable(); + void refreshRecurringTable(); + void calculateAndDisplayBalance(); + QList getAllTransactionsInRange(); + QList generateProjectedTransactions(); + void clearTransactionEntry(); + void loadTransactionToEntry(const Transaction &t); + void clearRecurringEntry(); + void loadRecurringToEntry(const RecurringRule &r); + QDate getPeriodEnd(const QDate &date, PeriodType periodType); + QDate getPeriodStart(const QDate &date, PeriodType periodType); + QString getPeriodLabel(const QDate &date, PeriodType periodType, int count); + void insertPeriodEndRow(const QString &label, double balance, const QMap &accountBalances); + void updateAmountColors(); + void loadSettings(); + void applySettings(); + void onSaveSettings(); + void onChooseAmountFont(); + QString formatCurrency(double amount) const; +}; +#endif // CASHFLOW_H diff --git a/cashflow.ui b/cashflow.ui new file mode 100644 index 0000000..6592c0d --- /dev/null +++ b/cashflow.ui @@ -0,0 +1,619 @@ + + + CashFlow + + + + 0 + 0 + 1200 + 700 + + + + CashFlo - Cash Flow Projection + + + + + + + Date Range + + + + + + From: + + + + + + + true + + + + + + + To: + + + + + + + true + + + + + + + Period: + + + + + + + + Daily + + + + + Weekly + + + + + Monthly + + + + + Quarterly + + + + + + + + Account: + + + + + + + + All Accounts + + + + + + + + Show Account Balances + + + false + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Starting Balance: $0.00 + + + font-weight: bold; font-size: 12pt; + + + + + + + Ending Balance: $0.00 + + + font-weight: bold; font-size: 12pt; color: blue; + + + + + + + + + + 0 + + + + Transactions + + + + + + QAbstractItemView::SelectRows + + + false + + + + + + + Transaction Entry + + + + + + Date: + + + + + + + true + + + + + + + Amount: + + + + + + + QAbstractSpinBox::NoButtons + + + $ + + + 2 + + + -999999.990000000000000 + + + 999999.990000000000000 + + + + + + + Account: + + + + + + + true + + + + + + + Category: + + + + + + + true + + + + + + + + + Description: + + + + + + + + + + + + Type: + + + + + + + + Estimated + + + + + Actual + + + + + + + + + + Save + + + + + + + New + + + + + + + Delete + + + + + + + + + + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + + Recurring Rules + + + + + + QAbstractItemView::SelectRows + + + + + + + Recurring Rule Entry + + + + + + Name: + + + + + + + + + + Frequency: + + + + + + + + Daily + + + + + Weekly + + + + + Bi-Weekly + + + + + Monthly + + + + + Yearly + + + + + + + + Start Date: + + + + + + + true + + + + + + + Amount: + + + + + + + QAbstractSpinBox::NoButtons + + + $ + + + 2 + + + -999999.990000000000000 + + + 999999.990000000000000 + + + + + + + Account: + + + + + + + true + + + + + + + Category: + + + + + + + true + + + + + + + Description: + + + + + + + + + + + + Save + + + + + + + New + + + + + + + Delete + + + + + + + + + + + + Recurring rules automatically generate estimated transactions in the date range. Edit dates to see projections update. + + + true + + + + + + + + Settings + + + + + + Application Settings + + + + + + Currency Symbol: + + + + + + + 3 + + + $ + + + + + + + Amount Font: + + + + + + + Choose Font... + + + + + + + Default Period: + + + + + + + + Daily + + + + + Weekly + + + + + Monthly + + + + + Quarterly + + + + + + + + Show Account Balances by Default + + + + + + + Save Settings + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + 0 + 0 + 1200 + 22 + + + + + + + + diff --git a/database.cpp b/database.cpp new file mode 100644 index 0000000..5f9d99c --- /dev/null +++ b/database.cpp @@ -0,0 +1,364 @@ +#include "database.h" +#include +#include +#include +#include + +Database::Database() { + db = QSqlDatabase::addDatabase("QSQLITE"); +} + +Database::~Database() { + if (db.isOpen()) { + db.close(); + } +} + +bool Database::open(const QString &path) { + db.setDatabaseName(path); + if (!db.open()) { + errorMsg = db.lastError().text(); + return false; + } + return createTables(); +} + +bool Database::createTables() { + QSqlQuery query; + + // Create transactions table + QString createTransactions = R"( + CREATE TABLE IF NOT EXISTS transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + amount REAL NOT NULL, + account TEXT NOT NULL, + category TEXT, + description TEXT, + type TEXT NOT NULL, + recurring_id INTEGER DEFAULT -1, + sort_order INTEGER DEFAULT 0 + ) + )"; + + if (!query.exec(createTransactions)) { + errorMsg = query.lastError().text(); + return false; + } + + // Create recurring_rules table + QString createRecurring = R"( + CREATE TABLE IF NOT EXISTS recurring_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + frequency TEXT NOT NULL, + start_date TEXT NOT NULL, + end_date TEXT, + amount REAL NOT NULL, + account TEXT NOT NULL, + category TEXT, + description TEXT, + day_of_week INTEGER DEFAULT -1, + day_of_month INTEGER DEFAULT -1, + occurrences INTEGER DEFAULT -1, + sort_order INTEGER DEFAULT 0 + ) + )"; + + if (!query.exec(createRecurring)) { + errorMsg = query.lastError().text(); + return false; + } + + // Create settings table + QString createSettings = R"( + CREATE TABLE IF NOT EXISTS settings ( + setting_name TEXT PRIMARY KEY, + setting_value TEXT + ) + )"; + + if (!query.exec(createSettings)) { + errorMsg = query.lastError().text(); + return false; + } + + // Migrate existing tables - add category column if missing + query.exec("ALTER TABLE transactions ADD COLUMN category TEXT"); + query.exec("ALTER TABLE recurring_rules ADD COLUMN category TEXT"); + + // Migrate existing tables - add sort_order column if missing + query.exec("ALTER TABLE transactions ADD COLUMN sort_order INTEGER DEFAULT 0"); + query.exec("ALTER TABLE recurring_rules ADD COLUMN sort_order INTEGER DEFAULT 0"); + + return true; +} + +bool Database::addTransaction(const Transaction &transaction) { + QSqlQuery query; + query.prepare("INSERT INTO transactions (date, amount, account, category, description, type, recurring_id, sort_order) " + "VALUES (:date, :amount, :account, :category, :description, :type, :recurring_id, :sort_order)"); + 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"); + query.bindValue(":recurring_id", transaction.recurringId); + query.bindValue(":sort_order", transaction.sortOrder); + + if (!query.exec()) { + errorMsg = query.lastError().text(); + return false; + } + return true; +} + +bool Database::updateTransaction(const Transaction &transaction) { + QSqlQuery query; + 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 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"); + query.bindValue(":recurring_id", transaction.recurringId); + query.bindValue(":sort_order", transaction.sortOrder); + + if (!query.exec()) { + errorMsg = query.lastError().text(); + return false; + } + return true; +} + +bool Database::deleteTransaction(int id) { + QSqlQuery query; + query.prepare("DELETE FROM transactions WHERE id=:id"); + query.bindValue(":id", id); + + if (!query.exec()) { + errorMsg = query.lastError().text(); + return false; + } + return true; +} + +QList Database::getTransactions(const QDate &startDate, const QDate &endDate) { + QList transactions; + QSqlQuery query; + query.prepare("SELECT * FROM transactions WHERE date >= :start AND date <= :end ORDER BY date"); + query.bindValue(":start", startDate.toString(Qt::ISODate)); + query.bindValue(":end", endDate.toString(Qt::ISODate)); + + if (!query.exec()) { + errorMsg = query.lastError().text(); + return transactions; + } + + while (query.next()) { + transactions.append(queryToTransaction(query)); + } + return transactions; +} + +QList Database::getAllTransactions() { + QList transactions; + QSqlQuery query("SELECT * FROM transactions ORDER BY date"); + + if (!query.exec()) { + errorMsg = query.lastError().text(); + return transactions; + } + + while (query.next()) { + transactions.append(queryToTransaction(query)); + } + return transactions; +} + +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) " + "VALUES (:name, :frequency, :start_date, :end_date, :amount, :account, " + ":category, :description, :day_of_week, :day_of_month, :occurrences, :sort_order)"); + query.bindValue(":name", rule.name); + + QString freq; + switch (rule.frequency) { + case RecurrenceFrequency::Daily: freq = "daily"; break; + case RecurrenceFrequency::Weekly: freq = "weekly"; break; + case RecurrenceFrequency::BiWeekly: freq = "biweekly"; break; + case RecurrenceFrequency::Monthly: freq = "monthly"; break; + case RecurrenceFrequency::Yearly: freq = "yearly"; break; + default: freq = "none"; break; + } + query.bindValue(":frequency", freq); + query.bindValue(":start_date", rule.startDate.toString(Qt::ISODate)); + query.bindValue(":end_date", rule.endDate.isValid() ? rule.endDate.toString(Qt::ISODate) : QVariant()); + query.bindValue(":amount", rule.amount); + query.bindValue(":account", rule.account); + query.bindValue(":category", rule.category); + query.bindValue(":description", rule.description); + query.bindValue(":day_of_week", rule.dayOfWeek); + query.bindValue(":day_of_month", rule.dayOfMonth); + query.bindValue(":occurrences", rule.occurrences); + query.bindValue(":sort_order", rule.sortOrder); + + if (!query.exec()) { + errorMsg = query.lastError().text(); + return false; + } + return true; +} + +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"); + query.bindValue(":id", rule.id); + query.bindValue(":name", rule.name); + + QString freq; + switch (rule.frequency) { + case RecurrenceFrequency::Daily: freq = "daily"; break; + case RecurrenceFrequency::Weekly: freq = "weekly"; break; + case RecurrenceFrequency::BiWeekly: freq = "biweekly"; break; + case RecurrenceFrequency::Monthly: freq = "monthly"; break; + case RecurrenceFrequency::Yearly: freq = "yearly"; break; + default: freq = "none"; break; + } + query.bindValue(":frequency", freq); + query.bindValue(":start_date", rule.startDate.toString(Qt::ISODate)); + query.bindValue(":end_date", rule.endDate.isValid() ? rule.endDate.toString(Qt::ISODate) : QVariant()); + query.bindValue(":amount", rule.amount); + query.bindValue(":account", rule.account); + query.bindValue(":category", rule.category); + query.bindValue(":description", rule.description); + query.bindValue(":day_of_week", rule.dayOfWeek); + query.bindValue(":day_of_month", rule.dayOfMonth); + query.bindValue(":occurrences", rule.occurrences); + query.bindValue(":sort_order", rule.sortOrder); + + if (!query.exec()) { + errorMsg = query.lastError().text(); + return false; + } + return true; +} + +bool Database::deleteRecurringRule(int id) { + QSqlQuery query; + query.prepare("DELETE FROM recurring_rules WHERE id=:id"); + query.bindValue(":id", id); + + if (!query.exec()) { + errorMsg = query.lastError().text(); + return false; + } + return true; +} + +QList Database::getAllRecurringRules() { + QList rules; + QSqlQuery query("SELECT * FROM recurring_rules"); + + if (!query.exec()) { + errorMsg = query.lastError().text(); + return rules; + } + + while (query.next()) { + rules.append(queryToRecurringRule(query)); + } + return rules; +} + +bool Database::convertToActual(int transactionId, double actualAmount) { + QSqlQuery query; + query.prepare("UPDATE transactions SET type='actual', amount=:amount WHERE id=:id"); + query.bindValue(":amount", actualAmount); + query.bindValue(":id", transactionId); + + if (!query.exec()) { + errorMsg = query.lastError().text(); + return false; + } + return true; +} + +QString Database::lastError() const { + return errorMsg; +} + +Transaction Database::queryToTransaction(QSqlQuery &query) { + Transaction t; + t.id = query.value("id").toInt(); + t.date = QDate::fromString(query.value("date").toString(), Qt::ISODate); + t.amount = query.value("amount").toDouble(); + 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; + t.recurringId = query.value("recurring_id").toInt(); + t.sortOrder = query.value("sort_order").toInt(); + return t; +} + +RecurringRule Database::queryToRecurringRule(QSqlQuery &query) { + RecurringRule r; + r.id = query.value("id").toInt(); + r.name = query.value("name").toString(); + + QString freq = query.value("frequency").toString(); + if (freq == "daily") r.frequency = RecurrenceFrequency::Daily; + else if (freq == "weekly") r.frequency = RecurrenceFrequency::Weekly; + else if (freq == "biweekly") r.frequency = RecurrenceFrequency::BiWeekly; + else if (freq == "monthly") r.frequency = RecurrenceFrequency::Monthly; + else if (freq == "yearly") r.frequency = RecurrenceFrequency::Yearly; + else r.frequency = RecurrenceFrequency::None; + + r.startDate = QDate::fromString(query.value("start_date").toString(), Qt::ISODate); + QString endDateStr = query.value("end_date").toString(); + if (!endDateStr.isEmpty()) { + r.endDate = QDate::fromString(endDateStr, Qt::ISODate); + } + r.amount = query.value("amount").toDouble(); + r.account = query.value("account").toString(); + r.category = query.value("category").toString(); + r.description = query.value("description").toString(); + r.dayOfWeek = query.value("day_of_week").toInt(); + r.dayOfMonth = query.value("day_of_month").toInt(); + r.occurrences = query.value("occurrences").toInt(); + r.sortOrder = query.value("sort_order").toInt(); + return r; +} + +QString Database::getSetting(const QString &name, const QString &defaultValue) { + QSqlQuery query; + query.prepare("SELECT setting_value FROM settings WHERE setting_name = :name"); + query.bindValue(":name", name); + + if (query.exec() && query.next()) { + return query.value(0).toString(); + } + return defaultValue; +} + +bool Database::setSetting(const QString &name, const QString &value) { + QSqlQuery query; + query.prepare("INSERT OR REPLACE INTO settings (setting_name, setting_value) VALUES (:name, :value)"); + query.bindValue(":name", name); + query.bindValue(":value", value); + + if (!query.exec()) { + errorMsg = query.lastError().text(); + return false; + } + return true; +} diff --git a/database.h b/database.h new file mode 100644 index 0000000..cdde765 --- /dev/null +++ b/database.h @@ -0,0 +1,50 @@ +#ifndef DATABASE_H +#define DATABASE_H + +#include +#include +#include +#include +#include +#include +#include "transaction.h" + +class Database { +public: + Database(); + ~Database(); + + bool open(const QString &path); + bool createTables(); + + // Transaction operations + bool addTransaction(const Transaction &transaction); + bool updateTransaction(const Transaction &transaction); + bool deleteTransaction(int id); + QList getTransactions(const QDate &startDate, const QDate &endDate); + QList getAllTransactions(); + + // Recurring rule operations + bool addRecurringRule(const RecurringRule &rule); + bool updateRecurringRule(const RecurringRule &rule); + bool deleteRecurringRule(int id); + QList getAllRecurringRules(); + + // Reconciliation + bool convertToActual(int transactionId, double actualAmount); + + // Settings + QString getSetting(const QString &name, const QString &defaultValue = QString()); + bool setSetting(const QString &name, const QString &value); + + QString lastError() const; + +private: + QSqlDatabase db; + QString errorMsg; + + Transaction queryToTransaction(QSqlQuery &query); + RecurringRule queryToRecurringRule(QSqlQuery &query); +}; + +#endif // DATABASE_H diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..9553f70 --- /dev/null +++ b/main.cpp @@ -0,0 +1,11 @@ +#include "cashflow.h" + +#include + +int main(int argc, char *argv[]) +{ + QApplication a(argc, argv); + CashFlow w; + w.show(); + return a.exec(); +} diff --git a/transaction.h b/transaction.h new file mode 100644 index 0000000..8aa0a06 --- /dev/null +++ b/transaction.h @@ -0,0 +1,56 @@ +#ifndef TRANSACTION_H +#define TRANSACTION_H + +#include +#include + +enum class TransactionType { + Estimated, + Actual +}; + +enum class RecurrenceFrequency { + None, + Daily, + Weekly, + BiWeekly, + Monthly, + Yearly +}; + +struct Transaction { + int id; + QDate date; + double amount; + QString account; + QString category; + QString description; + TransactionType type; + int recurringId; // -1 if not part of recurring series + int sortOrder; // For ordering on same date + + Transaction() : id(-1), amount(0.0), type(TransactionType::Estimated), recurringId(-1), sortOrder(0) {} + + double getBalance() const { return amount; } +}; + +struct RecurringRule { + int id; + QString name; + RecurrenceFrequency frequency; + QDate startDate; + QDate endDate; // Can be null for indefinite + double amount; + QString account; + QString category; + QString description; + int dayOfWeek; // 1-7 for weekly (1=Monday), -1 if not applicable + 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 + + RecurringRule() : id(-1), frequency(RecurrenceFrequency::None), + amount(0.0), dayOfWeek(-1), dayOfMonth(-1), occurrences(-1), sortOrder(0) {} +}; + +#endif // TRANSACTION_H -- cgit v1.2.3