aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cashflow.cpp206
-rw-r--r--cashflow.h6
-rw-r--r--cashflow.ui34
-rw-r--r--database.cpp48
-rw-r--r--transaction.h14
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();
+ }
+}
+
diff --git a/cashflow.h b/cashflow.h
index adb0494..b75d62b 100644
--- a/cashflow.h
+++ b/cashflow.h
@@ -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