diff options
| -rw-r--r-- | cashflow.cpp | 206 | ||||
| -rw-r--r-- | cashflow.h | 6 | ||||
| -rw-r--r-- | cashflow.ui | 34 | ||||
| -rw-r--r-- | database.cpp | 48 | ||||
| -rw-r--r-- | transaction.h | 14 |
5 files changed, 286 insertions, 22 deletions
diff --git a/cashflow.cpp b/cashflow.cpp index 8eb46a4..f302110 100644 --- a/cashflow.cpp +++ b/cashflow.cpp @@ -71,6 +71,10 @@ void CashFlow::setupConnections() { connect(ui->newBtn, &QPushButton::clicked, this, &CashFlow::onNewTransaction); connect(ui->deleteBtn, &QPushButton::clicked, this, &CashFlow::onDeleteTransaction); + // Transaction entry recurring rule linking + connect(ui->entryRecurringCombo, QOverload<int>::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); @@ -131,6 +135,9 @@ void CashFlow::refreshView() { 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(); @@ -200,6 +207,10 @@ void CashFlow::refreshTransactionTable() { // Store ID in first column's data for retrieval later QTableWidgetItem *dateItem = new QTableWidgetItem(t.date.toString("MM/dd/yy")); dateItem->setData(Qt::UserRole, t.id); + // Store full transaction for projected items (id == -1) + if (t.id == -1) { + dateItem->setData(Qt::UserRole + 1, QVariant::fromValue(t)); + } dateItem->setFlags(dateItem->flags() & ~Qt::ItemIsEditable); ui->transactionTable->setItem(row, 0, dateItem); @@ -484,6 +495,15 @@ QList<Transaction> CashFlow::generateProjectedTransactions() { QDate startDate = ui->dateFromEdit->date(); QDate endDate = ui->dateToEdit->date(); + // Load all actual transactions with recurring_id (converted projections) + QList<Transaction> actualTransactions = database->getAllTransactions(); + QSet<QString> existingOccurrences; // recurring_id:occurrence_key pairs + for (const Transaction &t : actualTransactions) { + if (t.recurringId != -1 && !t.occurrenceKey.isEmpty()) { + existingOccurrences.insert(QString("%1:%2").arg(t.recurringId).arg(t.occurrenceKey)); + } + } + for (const RecurringRule &rule : rules) { QDate currentDate = rule.startDate > startDate ? rule.startDate : startDate; @@ -514,17 +534,37 @@ QList<Transaction> CashFlow::generateProjectedTransactions() { 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; + // Generate occurrence key based on frequency + QString occurrenceKey; + if (rule.frequency == RecurrenceFrequency::Daily) { + occurrenceKey = currentDate.toString("yyyy-MM-dd"); + } else if (rule.frequency == RecurrenceFrequency::Weekly || rule.frequency == RecurrenceFrequency::BiWeekly) { + occurrenceKey = QString("%1-W%2").arg(currentDate.year()).arg(currentDate.weekNumber(), 2, 10, QChar('0')); + } else if (rule.frequency == RecurrenceFrequency::Monthly) { + occurrenceKey = currentDate.toString("yyyy-MM"); + } else if (rule.frequency == RecurrenceFrequency::Yearly) { + occurrenceKey = QString::number(currentDate.year()); + } - projections.append(t); + // Skip if actual already exists for this occurrence + QString occurrenceCheck = QString("%1:%2").arg(rule.id).arg(occurrenceKey); + if (existingOccurrences.contains(occurrenceCheck)) { + // Actual exists, skip this projection + } else { + Transaction t; + t.id = -1; // Projected transactions have no ID + t.date = currentDate; + t.amount = rule.amount; + t.account = rule.account; + t.category = rule.category; + t.description = rule.description + " (projected)"; + t.type = TransactionType::Estimated; + t.recurringId = rule.id; + t.occurrenceKey = occurrenceKey; + t.reconciled = false; + + projections.append(t); + } count++; // Calculate next occurrence @@ -574,10 +614,16 @@ void CashFlow::onTransactionSelected() { int row = selected[0]->row(); int id = ui->transactionTable->item(row, 0)->data(Qt::UserRole).toInt(); - // If it's a projected transaction (id = -1), don't load it for editing + // If it's a projected transaction (id = -1), load it for editing if (id == -1) { - ui->entryStatusLabel->setText("(Projected - cannot edit)"); - currentTransactionId = -1; + QVariant projectedData = ui->transactionTable->item(row, 0)->data(Qt::UserRole + 1); + if (projectedData.canConvert<Transaction>()) { + Transaction t = projectedData.value<Transaction>(); + currentTransactionId = -1; // Will create new actual when saved + currentProjectedTransaction = t; // Store for conversion + loadTransactionToEntry(t); + ui->entryStatusLabel->setText("(Converting Projection to Actual)"); + } return; } @@ -619,7 +665,51 @@ void CashFlow::onSaveTransaction() { 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 + + // Check if user manually linked to a recurring rule + int manualRuleId = ui->entryRecurringCombo->currentData().toInt(); + QString manualOccurrenceKey = ui->entryOccurrenceEdit->text().trimmed(); + + // Check if we're converting a projection to actual + if (currentTransactionId == -1 && currentProjectedTransaction.recurringId != -1) { + // Converting projection - keep recurring link and store expected values + t.recurringId = currentProjectedTransaction.recurringId; + t.occurrenceKey = currentProjectedTransaction.occurrenceKey; + t.expectedAmount = currentProjectedTransaction.amount; + t.expectedDate = currentProjectedTransaction.date; + t.reconciled = true; + t.type = TransactionType::Actual; // Force to actual when converting + } else if (manualRuleId != -1 && !manualOccurrenceKey.isEmpty()) { + // User manually linked to recurring rule + t.recurringId = manualRuleId; + t.occurrenceKey = manualOccurrenceKey; + t.reconciled = true; + // Try to get expected amount from the rule + QList<RecurringRule> rules = database->getAllRecurringRules(); + for (const RecurringRule &rule : rules) { + if (rule.id == manualRuleId) { + t.expectedAmount = rule.amount; + break; + } + } + } else if (currentTransactionId != -1) { + // Editing existing transaction - load from database to preserve reconciliation fields + QList<Transaction> 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; + break; + } + } + } else { + // New manual transaction with no recurring link + t.recurringId = -1; + t.reconciled = false; + } bool success; if (currentTransactionId == -1) { @@ -647,6 +737,7 @@ void CashFlow::onSaveTransaction() { void CashFlow::onNewTransaction() { clearTransactionEntry(); + currentProjectedTransaction = Transaction(); // Reset projected transaction ui->entryDateEdit->setDate(QDate::currentDate()); ui->entryDateEdit->setFocus(); } @@ -771,6 +862,9 @@ void CashFlow::clearTransactionEntry() { 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(); } @@ -783,6 +877,8 @@ void CashFlow::loadTransactionToEntry(const Transaction &t) { 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); ui->entryAmountSpin->setValue(t.amount); @@ -791,12 +887,31 @@ void CashFlow::loadTransactionToEntry(const Transaction &t) { ui->entryDescriptionEdit->setText(t.description); ui->entryTypeCombo->setCurrentIndex(t.type == TransactionType::Actual ? 1 : 0); + // 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); updateAmountColors(); } @@ -966,3 +1081,66 @@ void CashFlow::onPreferences() { refreshView(); } } + +void CashFlow::populateRecurringRulesCombo() { + ui->entryRecurringCombo->clear(); + ui->entryRecurringCombo->addItem("(None)", -1); + + QList<RecurringRule> 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<RecurringRule> 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(); + } +} + @@ -34,6 +34,8 @@ private slots: void onOpenFile(); void onQuit(); void onPreferences(); + void onRecurringRuleChanged(); + void onTransactionDateChanged(); private: Ui::CashFlow *ui; @@ -44,6 +46,7 @@ private: QFont currentAmountFont; int weekStartDay; QString currentFilePath; + Transaction currentProjectedTransaction; // For converting projections to actuals enum PeriodType { Daily, @@ -71,5 +74,8 @@ private: void loadSettings(); QString formatCurrency(double amount) const; bool openDatabase(const QString &filePath); + void populateRecurringRulesCombo(); + void updateOccurrenceKey(); + QString generateOccurrenceKey(const QDate &date, RecurrenceFrequency frequency) const; }; #endif // CASHFLOW_H diff --git a/cashflow.ui b/cashflow.ui index 5d58574..4f34590 100644 --- a/cashflow.ui +++ b/cashflow.ui @@ -271,7 +271,37 @@ </item> </widget> </item> - <item row="2" column="0" colspan="3"> + <item row="2" column="0"> + <widget class="QLabel" name="label_17"> + <property name="text"> + <string>Recurring Rule:</string> + </property> + </widget> + </item> + <item row="2" column="1"> + <widget class="QComboBox" name="entryRecurringCombo"> + <item> + <property name="text"> + <string>(None)</string> + </property> + </item> + </widget> + </item> + <item row="2" column="2"> + <widget class="QLabel" name="label_18"> + <property name="text"> + <string>Occurrence:</string> + </property> + </widget> + </item> + <item row="2" column="3"> + <widget class="QLineEdit" name="entryOccurrenceEdit"> + <property name="placeholderText"> + <string>Auto (e.g., 2025-12)</string> + </property> + </widget> + </item> + <item row="3" column="0" colspan="3"> <layout class="QHBoxLayout" name="horizontalLayout_2"> <item> <widget class="QPushButton" name="saveBtn"> @@ -296,7 +326,7 @@ </item> </layout> </item> - <item row="2" column="4" colspan="2"> + <item row="3" column="4" colspan="2"> <widget class="QLabel" name="entryStatusLabel"> <property name="text"> <string/> diff --git a/database.cpp b/database.cpp index 5f9d99c..4041344 100644 --- a/database.cpp +++ b/database.cpp @@ -91,13 +91,37 @@ bool Database::createTables() { 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"); + // Migrate existing tables - add reconciliation columns + query.exec("ALTER TABLE transactions ADD COLUMN reconciled INTEGER DEFAULT 0"); + query.exec("ALTER TABLE transactions ADD COLUMN occurrence_key TEXT"); + query.exec("ALTER TABLE transactions ADD COLUMN expected_amount REAL"); + query.exec("ALTER TABLE transactions ADD COLUMN expected_date TEXT"); + + // Create reconciliation_checkpoints table + QString createCheckpoints = R"( + CREATE TABLE IF NOT EXISTS reconciliation_checkpoints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account TEXT NOT NULL, + reconciled_through_date TEXT NOT NULL, + reconciled_at TEXT DEFAULT CURRENT_TIMESTAMP, + notes TEXT + ) + )"; + + if (!query.exec(createCheckpoints)) { + errorMsg = query.lastError().text(); + return false; + } + 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.prepare("INSERT INTO transactions (date, amount, account, category, description, type, recurring_id, sort_order, " + "reconciled, occurrence_key, expected_amount, expected_date) " + "VALUES (:date, :amount, :account, :category, :description, :type, :recurring_id, :sort_order, " + ":reconciled, :occurrence_key, :expected_amount, :expected_date)"); query.bindValue(":date", transaction.date.toString(Qt::ISODate)); query.bindValue(":amount", transaction.amount); query.bindValue(":account", transaction.account); @@ -106,6 +130,10 @@ bool Database::addTransaction(const Transaction &transaction) { query.bindValue(":type", transaction.type == TransactionType::Actual ? "actual" : "estimated"); query.bindValue(":recurring_id", transaction.recurringId); query.bindValue(":sort_order", transaction.sortOrder); + query.bindValue(":reconciled", transaction.reconciled ? 1 : 0); + query.bindValue(":occurrence_key", transaction.occurrenceKey); + query.bindValue(":expected_amount", transaction.expectedAmount > 0 ? transaction.expectedAmount : QVariant()); + query.bindValue(":expected_date", transaction.expectedDate.isValid() ? transaction.expectedDate.toString(Qt::ISODate) : QVariant()); if (!query.exec()) { errorMsg = query.lastError().text(); @@ -117,7 +145,9 @@ bool Database::addTransaction(const Transaction &transaction) { 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"); + "description=:description, type=:type, recurring_id=:recurring_id, sort_order=:sort_order, " + "reconciled=:reconciled, occurrence_key=:occurrence_key, expected_amount=:expected_amount, " + "expected_date=:expected_date WHERE id=:id"); query.bindValue(":id", transaction.id); query.bindValue(":date", transaction.date.toString(Qt::ISODate)); query.bindValue(":amount", transaction.amount); @@ -127,6 +157,11 @@ bool Database::updateTransaction(const Transaction &transaction) { query.bindValue(":type", transaction.type == TransactionType::Actual ? "actual" : "estimated"); query.bindValue(":recurring_id", transaction.recurringId); query.bindValue(":sort_order", transaction.sortOrder); + query.bindValue(":reconciled", transaction.reconciled ? 1 : 0); + query.bindValue(":occurrence_key", transaction.occurrenceKey); + query.bindValue(":expected_amount", transaction.expectedAmount > 0 ? transaction.expectedAmount : QVariant()); + query.bindValue(":expected_date", transaction.expectedDate.isValid() ? transaction.expectedDate.toString(Qt::ISODate) : QVariant()); + query.bindValue(":sort_order", transaction.sortOrder); if (!query.exec()) { errorMsg = query.lastError().text(); @@ -307,6 +342,13 @@ Transaction Database::queryToTransaction(QSqlQuery &query) { 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(); + t.reconciled = query.value("reconciled").toInt() == 1; + t.occurrenceKey = query.value("occurrence_key").toString(); + t.expectedAmount = query.value("expected_amount").toDouble(); + QString expectedDateStr = query.value("expected_date").toString(); + if (!expectedDateStr.isEmpty()) { + t.expectedDate = QDate::fromString(expectedDateStr, Qt::ISODate); + } return t; } diff --git a/transaction.h b/transaction.h index 8aa0a06..9628a4e 100644 --- a/transaction.h +++ b/transaction.h @@ -3,6 +3,7 @@ #include <QString> #include <QDate> +#include <QMetaType> enum class TransactionType { Estimated, @@ -28,8 +29,12 @@ struct Transaction { TransactionType type; int recurringId; // -1 if not part of recurring series int sortOrder; // For ordering on same date + bool reconciled; // true if converted from projection to actual + QString occurrenceKey; // e.g. "2025-01" for monthly, "2025-W03" for weekly + double expectedAmount; // original projected amount (for variance tracking) + QDate expectedDate; // original projected date (for variance tracking) - Transaction() : id(-1), amount(0.0), type(TransactionType::Estimated), recurringId(-1), sortOrder(0) {} + Transaction() : id(-1), amount(0.0), type(TransactionType::Estimated), recurringId(-1), sortOrder(0), reconciled(false), expectedAmount(0.0) {} double getBalance() const { return amount; } }; @@ -50,7 +55,10 @@ struct RecurringRule { 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) {} + amount(0.0), dayOfWeek(-1), dayOfMonth(-1), occurrences(-1), sortOrder(0) {} }; -#endif // TRANSACTION_H +// Register Transaction for use in QVariant +Q_DECLARE_METATYPE(Transaction) + +#endif // TRANSACTION_H
\ No newline at end of file |
