Added internal_transfer attribute to transactions, migration of schemas automatically.
This commit is contained in:
parent
83db4baa5b
commit
cb690702cc
|
|
@ -39,10 +39,12 @@ class FileSystemProfileRepository : ProfileRepository {
|
||||||
if (!exists(getProfilesDir())) mkdir(getProfilesDir());
|
if (!exists(getProfilesDir())) mkdir(getProfilesDir());
|
||||||
ProfileDataSource ds = new SqliteProfileDataSource(path);
|
ProfileDataSource ds = new SqliteProfileDataSource(path);
|
||||||
import std.datetime;
|
import std.datetime;
|
||||||
|
import std.conv;
|
||||||
auto propsRepo = ds.getPropertiesRepository();
|
auto propsRepo = ds.getPropertiesRepository();
|
||||||
propsRepo.setProperty("name", name);
|
propsRepo.setProperty("name", name);
|
||||||
propsRepo.setProperty("createdAt", Clock.currTime(UTC()).toISOExtString());
|
propsRepo.setProperty("createdAt", Clock.currTime(UTC()).toISOExtString());
|
||||||
propsRepo.setProperty("user", username);
|
propsRepo.setProperty("user", username);
|
||||||
|
propsRepo.setProperty("database-schema-version", SCHEMA_VERSION.to!string());
|
||||||
return new Profile(name);
|
return new Profile(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,6 +135,9 @@ class SqlitePropertiesRepository : PropertiesRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const SCHEMA = import("sql/schema.sql");
|
||||||
|
private const uint SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An SQLite implementation of the ProfileDataSource that uses a single
|
* An SQLite implementation of the ProfileDataSource that uses a single
|
||||||
* database connection to initialize various entity data access objects lazily.
|
* database connection to initialize various entity data access objects lazily.
|
||||||
|
|
@ -145,7 +150,6 @@ class SqliteProfileDataSource : ProfileDataSource {
|
||||||
import attachment.data;
|
import attachment.data;
|
||||||
import attachment.data_impl_sqlite;
|
import attachment.data_impl_sqlite;
|
||||||
|
|
||||||
const SCHEMA = import("sql/schema.sql");
|
|
||||||
private const string dbPath;
|
private const string dbPath;
|
||||||
Database db;
|
Database db;
|
||||||
|
|
||||||
|
|
@ -159,6 +163,7 @@ class SqliteProfileDataSource : ProfileDataSource {
|
||||||
infoF!"Initializing database: %s"(dbPath);
|
infoF!"Initializing database: %s"(dbPath);
|
||||||
db.run(SCHEMA);
|
db.run(SCHEMA);
|
||||||
}
|
}
|
||||||
|
migrateSchema();
|
||||||
}
|
}
|
||||||
|
|
||||||
PropertiesRepository getPropertiesRepository() return scope {
|
PropertiesRepository getPropertiesRepository() return scope {
|
||||||
|
|
@ -200,4 +205,32 @@ class SqliteProfileDataSource : ProfileDataSource {
|
||||||
void doTransaction(void delegate () dg) {
|
void doTransaction(void delegate () dg) {
|
||||||
util.sqlite.doTransaction(db, dg);
|
util.sqlite.doTransaction(db, dg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void migrateSchema() {
|
||||||
|
import std.conv;
|
||||||
|
PropertiesRepository propsRepo = new SqlitePropertiesRepository(this.db);
|
||||||
|
uint currentVersion;
|
||||||
|
try {
|
||||||
|
currentVersion = propsRepo.findProperty("database-schema-version")
|
||||||
|
.mapIfPresent!(s => s.to!uint).orElse(0);
|
||||||
|
} catch (ConvException e) {
|
||||||
|
warn("Failed to parse database-schema-version property.", e);
|
||||||
|
currentVersion = 0;
|
||||||
|
}
|
||||||
|
if (currentVersion == SCHEMA_VERSION) return;
|
||||||
|
|
||||||
|
static const migrations = [
|
||||||
|
import("sql/migrations/1.sql")
|
||||||
|
];
|
||||||
|
static if (migrations.length != SCHEMA_VERSION) {
|
||||||
|
static assert(false, "Schema version doesn't match the list of defined migrations.");
|
||||||
|
}
|
||||||
|
|
||||||
|
while (currentVersion < SCHEMA_VERSION) {
|
||||||
|
infoF!"Migrating schema from version %d to %d."(currentVersion, currentVersion + 1);
|
||||||
|
db.run(migrations[currentVersion]);
|
||||||
|
currentVersion++;
|
||||||
|
propsRepo.setProperty("database-schema-version", currentVersion.to!string);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -264,48 +264,49 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
item.amount = row.peek!ulong(3);
|
item.amount = row.peek!ulong(3);
|
||||||
item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
|
item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
|
||||||
item.description = row.peek!string(5);
|
item.description = row.peek!string(5);
|
||||||
|
item.internalTransfer = row.peek!bool(6);
|
||||||
|
|
||||||
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(6);
|
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7);
|
||||||
if (!vendorId.isNull) {
|
if (!vendorId.isNull) {
|
||||||
item.vendor = Optional!(TransactionDetail.Vendor).of(
|
item.vendor = Optional!(TransactionDetail.Vendor).of(
|
||||||
TransactionDetail.Vendor(
|
TransactionDetail.Vendor(
|
||||||
vendorId.get,
|
vendorId.get,
|
||||||
row.peek!string(7),
|
row.peek!string(8),
|
||||||
row.peek!string(8)
|
row.peek!string(9)
|
||||||
)).toNullable;
|
)).toNullable;
|
||||||
}
|
}
|
||||||
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(9);
|
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(10);
|
||||||
if (!categoryId.isNull) {
|
if (!categoryId.isNull) {
|
||||||
item.category = Optional!(TransactionDetail.Category).of(
|
item.category = Optional!(TransactionDetail.Category).of(
|
||||||
TransactionDetail.Category(
|
TransactionDetail.Category(
|
||||||
categoryId.get,
|
categoryId.get,
|
||||||
row.peek!(Nullable!ulong)(10),
|
row.peek!(Nullable!ulong)(11),
|
||||||
row.peek!string(11),
|
|
||||||
row.peek!string(12),
|
row.peek!string(12),
|
||||||
row.peek!string(13)
|
row.peek!string(13),
|
||||||
|
row.peek!string(14)
|
||||||
)).toNullable;
|
)).toNullable;
|
||||||
}
|
}
|
||||||
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(14);
|
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(15);
|
||||||
if (!creditedAccountId.isNull) {
|
if (!creditedAccountId.isNull) {
|
||||||
item.creditedAccount = Optional!(TransactionDetail.Account).of(
|
item.creditedAccount = Optional!(TransactionDetail.Account).of(
|
||||||
TransactionDetail.Account(
|
TransactionDetail.Account(
|
||||||
creditedAccountId.get,
|
creditedAccountId.get,
|
||||||
row.peek!string(15),
|
|
||||||
row.peek!string(16),
|
row.peek!string(16),
|
||||||
row.peek!string(17)
|
row.peek!string(17),
|
||||||
|
row.peek!string(18)
|
||||||
)).toNullable;
|
)).toNullable;
|
||||||
}
|
}
|
||||||
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(18);
|
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(19);
|
||||||
if (!debitedAccountId.isNull) {
|
if (!debitedAccountId.isNull) {
|
||||||
item.debitedAccount = Optional!(TransactionDetail.Account).of(
|
item.debitedAccount = Optional!(TransactionDetail.Account).of(
|
||||||
TransactionDetail.Account(
|
TransactionDetail.Account(
|
||||||
debitedAccountId.get,
|
debitedAccountId.get,
|
||||||
row.peek!string(19),
|
|
||||||
row.peek!string(20),
|
row.peek!string(20),
|
||||||
row.peek!string(21)
|
row.peek!string(21),
|
||||||
|
row.peek!string(22)
|
||||||
)).toNullable;
|
)).toNullable;
|
||||||
}
|
}
|
||||||
string tagsStr = row.peek!string(22);
|
string tagsStr = row.peek!string(23);
|
||||||
if (tagsStr !is null && tagsStr.length > 0) {
|
if (tagsStr !is null && tagsStr.length > 0) {
|
||||||
import std.string : split;
|
import std.string : split;
|
||||||
item.tags = tagsStr.split(",");
|
item.tags = tagsStr.split(",");
|
||||||
|
|
@ -353,6 +354,7 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
data.amount,
|
data.amount,
|
||||||
data.currencyCode,
|
data.currencyCode,
|
||||||
data.description,
|
data.description,
|
||||||
|
data.internalTransfer,
|
||||||
data.vendorId,
|
data.vendorId,
|
||||||
data.categoryId
|
data.categoryId
|
||||||
);
|
);
|
||||||
|
|
@ -378,6 +380,7 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
data.amount,
|
data.amount,
|
||||||
data.currencyCode,
|
data.currencyCode,
|
||||||
data.description,
|
data.description,
|
||||||
|
data.internalTransfer,
|
||||||
data.vendorId,
|
data.vendorId,
|
||||||
data.categoryId,
|
data.categoryId,
|
||||||
transactionId
|
transactionId
|
||||||
|
|
@ -403,20 +406,6 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Transaction parseTransaction(Row row) {
|
|
||||||
import std.typecons : Nullable;
|
|
||||||
return Transaction(
|
|
||||||
row.peek!ulong(0),
|
|
||||||
SysTime.fromISOExtString(row.peek!string(1)),
|
|
||||||
SysTime.fromISOExtString(row.peek!string(2)),
|
|
||||||
row.peek!ulong(3),
|
|
||||||
Currency.ofCode(row.peek!(string, PeekMode.slice)(4)),
|
|
||||||
row.peek!string(5),
|
|
||||||
toOptional(row.peek!(Nullable!ulong)(6)),
|
|
||||||
toOptional(row.peek!(Nullable!ulong)(7))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function to parse a list of transaction list items as obtained from the
|
* Function to parse a list of transaction list items as obtained from the
|
||||||
* `get_transactions.sql` query. Because there are possibly multiple rows
|
* `get_transactions.sql` query. Because there are possibly multiple rows
|
||||||
|
|
@ -459,45 +448,46 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
item.amount = row.peek!ulong(3);
|
item.amount = row.peek!ulong(3);
|
||||||
item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
|
item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
|
||||||
item.description = row.peek!string(5);
|
item.description = row.peek!string(5);
|
||||||
|
item.internalTransfer = row.peek!bool(6);
|
||||||
// Read the nullable Vendor information.
|
// Read the nullable Vendor information.
|
||||||
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(6);
|
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7);
|
||||||
if (!vendorId.isNull) {
|
if (!vendorId.isNull) {
|
||||||
string vendorName = row.peek!string(7);
|
string vendorName = row.peek!string(8);
|
||||||
item.vendor = Optional!(TransactionsListItem.Vendor).of(
|
item.vendor = Optional!(TransactionsListItem.Vendor).of(
|
||||||
TransactionsListItem.Vendor(vendorId.get, vendorName));
|
TransactionsListItem.Vendor(vendorId.get, vendorName));
|
||||||
}
|
}
|
||||||
// Read the nullable Category information.
|
// Read the nullable Category information.
|
||||||
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(8);
|
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(9);
|
||||||
if (!categoryId.isNull) {
|
if (!categoryId.isNull) {
|
||||||
string categoryName = row.peek!string(9);
|
string categoryName = row.peek!string(10);
|
||||||
string categoryColor = row.peek!string(10);
|
string categoryColor = row.peek!string(11);
|
||||||
item.category = Optional!(TransactionsListItem.Category).of(
|
item.category = Optional!(TransactionsListItem.Category).of(
|
||||||
TransactionsListItem.Category(categoryId.get, categoryName, categoryColor));
|
TransactionsListItem.Category(categoryId.get, categoryName, categoryColor));
|
||||||
}
|
}
|
||||||
// Read the nullable creditedAccount.
|
// Read the nullable creditedAccount.
|
||||||
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(11);
|
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(12);
|
||||||
if (!creditedAccountId.isNull) {
|
if (!creditedAccountId.isNull) {
|
||||||
ulong id = creditedAccountId.get;
|
ulong id = creditedAccountId.get;
|
||||||
string name = row.peek!string(12);
|
string name = row.peek!string(13);
|
||||||
string type = row.peek!string(13);
|
string type = row.peek!string(14);
|
||||||
string suffix = row.peek!string(14);
|
string suffix = row.peek!string(15);
|
||||||
item.creditedAccount = Optional!(TransactionsListItem.Account).of(
|
item.creditedAccount = Optional!(TransactionsListItem.Account).of(
|
||||||
TransactionsListItem.Account(id, name, type, suffix));
|
TransactionsListItem.Account(id, name, type, suffix));
|
||||||
}
|
}
|
||||||
// Read the nullable debitedAccount.
|
// Read the nullable debitedAccount.
|
||||||
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(15);
|
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(16);
|
||||||
if (!debitedAccountId.isNull) {
|
if (!debitedAccountId.isNull) {
|
||||||
ulong id = debitedAccountId.get;
|
ulong id = debitedAccountId.get;
|
||||||
string name = row.peek!string(16);
|
string name = row.peek!string(17);
|
||||||
string type = row.peek!string(17);
|
string type = row.peek!string(18);
|
||||||
string suffix = row.peek!string(18);
|
string suffix = row.peek!string(19);
|
||||||
item.debitedAccount = Optional!(TransactionsListItem.Account).of(
|
item.debitedAccount = Optional!(TransactionsListItem.Account).of(
|
||||||
TransactionsListItem.Account(id, name, type, suffix));
|
TransactionsListItem.Account(id, name, type, suffix));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read multi-row properties, like tags, to the current item.
|
// Read multi-row properties, like tags, to the current item.
|
||||||
string tag = row.peek!string(19);
|
string tag = row.peek!string(20);
|
||||||
if (tag !is null) {
|
if (tag !is null) {
|
||||||
item.tags ~= tag;
|
item.tags ~= tag;
|
||||||
}
|
}
|
||||||
|
|
@ -509,56 +499,6 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
return app[];
|
return app[];
|
||||||
}
|
}
|
||||||
|
|
||||||
static TransactionsListItem parseTransactionsListItem(Row row) {
|
|
||||||
TransactionsListItem item;
|
|
||||||
item.id = row.peek!ulong(0);
|
|
||||||
item.timestamp = row.peek!string(1);
|
|
||||||
item.addedAt = row.peek!string(2);
|
|
||||||
item.amount = row.peek!ulong(3);
|
|
||||||
item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
|
|
||||||
item.description = row.peek!string(5);
|
|
||||||
|
|
||||||
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(6);
|
|
||||||
if (!vendorId.isNull) {
|
|
||||||
string vendorName = row.peek!string(7);
|
|
||||||
item.vendor = Optional!(TransactionsListItem.Vendor).of(
|
|
||||||
TransactionsListItem.Vendor(vendorId.get, vendorName));
|
|
||||||
}
|
|
||||||
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(8);
|
|
||||||
if (!categoryId.isNull) {
|
|
||||||
string categoryName = row.peek!string(9);
|
|
||||||
string categoryColor = row.peek!string(10);
|
|
||||||
item.category = Optional!(TransactionsListItem.Category).of(
|
|
||||||
TransactionsListItem.Category(categoryId.get, categoryName, categoryColor));
|
|
||||||
}
|
|
||||||
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(11);
|
|
||||||
if (!creditedAccountId.isNull) {
|
|
||||||
ulong id = creditedAccountId.get;
|
|
||||||
string name = row.peek!string(12);
|
|
||||||
string type = row.peek!string(13);
|
|
||||||
string suffix = row.peek!string(14);
|
|
||||||
item.creditedAccount = Optional!(TransactionsListItem.Account).of(
|
|
||||||
TransactionsListItem.Account(id, name, type, suffix));
|
|
||||||
}
|
|
||||||
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(15);
|
|
||||||
if (!debitedAccountId.isNull) {
|
|
||||||
ulong id = debitedAccountId.get;
|
|
||||||
string name = row.peek!string(16);
|
|
||||||
string type = row.peek!string(17);
|
|
||||||
string suffix = row.peek!string(18);
|
|
||||||
item.debitedAccount = Optional!(TransactionsListItem.Account).of(
|
|
||||||
TransactionsListItem.Account(id, name, type, suffix));
|
|
||||||
}
|
|
||||||
string tagsStr = row.peek!string(19);
|
|
||||||
if (tagsStr !is null && tagsStr.length > 0) {
|
|
||||||
import std.string : split;
|
|
||||||
item.tags = tagsStr.split(",");
|
|
||||||
} else {
|
|
||||||
item.tags = [];
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void insertLineItems(ulong transactionId, in AddTransactionPayload data) {
|
private void insertLineItems(ulong transactionId, in AddTransactionPayload data) {
|
||||||
foreach (size_t idx, lineItem; data.lineItems) {
|
foreach (size_t idx, lineItem; data.lineItems) {
|
||||||
util.sqlite.update(
|
util.sqlite.update(
|
||||||
|
|
@ -595,6 +535,7 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
.select("txn.amount")
|
.select("txn.amount")
|
||||||
.select("txn.currency")
|
.select("txn.currency")
|
||||||
.select("txn.description")
|
.select("txn.description")
|
||||||
|
.select("txn.internal_transfer")
|
||||||
.select("txn.vendor_id")
|
.select("txn.vendor_id")
|
||||||
.select("vendor.name")
|
.select("vendor.name")
|
||||||
.select("txn.category_id")
|
.select("txn.category_id")
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ struct TransactionsListItem {
|
||||||
ulong amount;
|
ulong amount;
|
||||||
Currency currency;
|
Currency currency;
|
||||||
string description;
|
string description;
|
||||||
|
bool internalTransfer;
|
||||||
@serdeTransformOut!serializeOptional
|
@serdeTransformOut!serializeOptional
|
||||||
Optional!Vendor vendor;
|
Optional!Vendor vendor;
|
||||||
@serdeTransformOut!serializeOptional
|
@serdeTransformOut!serializeOptional
|
||||||
|
|
@ -54,6 +55,7 @@ struct TransactionDetail {
|
||||||
ulong amount;
|
ulong amount;
|
||||||
Currency currency;
|
Currency currency;
|
||||||
string description;
|
string description;
|
||||||
|
bool internalTransfer;
|
||||||
Nullable!Vendor vendor;
|
Nullable!Vendor vendor;
|
||||||
Nullable!Category category;
|
Nullable!Category category;
|
||||||
Nullable!Account creditedAccount;
|
Nullable!Account creditedAccount;
|
||||||
|
|
@ -98,6 +100,7 @@ struct AddTransactionPayload {
|
||||||
ulong amount;
|
ulong amount;
|
||||||
string currencyCode;
|
string currencyCode;
|
||||||
string description;
|
string description;
|
||||||
|
bool internalTransfer;
|
||||||
Nullable!ulong vendorId;
|
Nullable!ulong vendorId;
|
||||||
Nullable!ulong categoryId;
|
Nullable!ulong categoryId;
|
||||||
Nullable!ulong creditedAccountId;
|
Nullable!ulong creditedAccountId;
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,25 @@ void applyFilters(ref QueryBuilder qb, in ServerHttpRequest request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Boolean filter for internal transfer.
|
||||||
|
if (request.hasParam("internal-transfer")) {
|
||||||
|
string value = request.getParamValues("internal-transfer")[0]
|
||||||
|
.strip()
|
||||||
|
.toUpper();
|
||||||
|
Optional!bool internalTransferFilter = Optional!bool.empty();
|
||||||
|
if (value == "Y" || value == "YES" || value == "TRUE" || value == "1") {
|
||||||
|
internalTransferFilter = Optional!bool.of(true);
|
||||||
|
} else if (value == "N" || value == "NO" || value == "FALSE" || value == "0") {
|
||||||
|
internalTransferFilter = Optional!bool.of(false);
|
||||||
|
}
|
||||||
|
if (!internalTransferFilter.isNull) {
|
||||||
|
qb.where("txn.internal_transfer = ?");
|
||||||
|
qb.withArgBinding((ref stmt, ref idx) {
|
||||||
|
stmt.bind(idx++, internalTransferFilter.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Textual search query:
|
// Textual search query:
|
||||||
if (request.hasParam("q")) {
|
if (request.hasParam("q")) {
|
||||||
string searchQuery = request.getParamValues!string("q")[0];
|
string searchQuery = request.getParamValues!string("q")[0];
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ txn.added_at AS added_at,
|
||||||
txn.amount AS amount,
|
txn.amount AS amount,
|
||||||
txn.currency AS currency,
|
txn.currency AS currency,
|
||||||
txn.description AS description,
|
txn.description AS description,
|
||||||
|
txn.internal_transfer AS internal_transfer,
|
||||||
|
|
||||||
txn.vendor_id AS vendor_id,
|
txn.vendor_id AS vendor_id,
|
||||||
vendor.name AS vendor_name,
|
vendor.name AS vendor_name,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ INSERT INTO "transaction" (
|
||||||
amount,
|
amount,
|
||||||
currency,
|
currency,
|
||||||
description,
|
description,
|
||||||
|
internal_transfer,
|
||||||
vendor_id,
|
vendor_id,
|
||||||
category_id
|
category_id
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE "transaction"
|
||||||
|
ADD COLUMN internal_transfer BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
@ -63,6 +63,7 @@ CREATE TABLE "transaction" (
|
||||||
amount INTEGER NOT NULL,
|
amount INTEGER NOT NULL,
|
||||||
currency TEXT NOT NULL,
|
currency TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
|
internal_transfer BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
vendor_id INTEGER,
|
vendor_id INTEGER,
|
||||||
category_id INTEGER,
|
category_id INTEGER,
|
||||||
CONSTRAINT fk_transaction_vendor
|
CONSTRAINT fk_transaction_vendor
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ SET
|
||||||
amount = ?,
|
amount = ?,
|
||||||
currency = ?,
|
currency = ?,
|
||||||
description = ?,
|
description = ?,
|
||||||
|
internal_transfer = ?,
|
||||||
vendor_id = ?,
|
vendor_id = ?,
|
||||||
category_id = ?
|
category_id = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
|
|
@ -32,17 +32,6 @@ export interface TransactionCategoryTree {
|
||||||
depth: number
|
depth: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Transaction {
|
|
||||||
id: number
|
|
||||||
timestamp: string
|
|
||||||
addedAt: string
|
|
||||||
amount: number
|
|
||||||
currency: string
|
|
||||||
description: string
|
|
||||||
vendorId: number | null
|
|
||||||
categoryId: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TransactionsListItem {
|
export interface TransactionsListItem {
|
||||||
id: number
|
id: number
|
||||||
timestamp: string
|
timestamp: string
|
||||||
|
|
@ -50,6 +39,7 @@ export interface TransactionsListItem {
|
||||||
amount: number
|
amount: number
|
||||||
currency: Currency
|
currency: Currency
|
||||||
description: string
|
description: string
|
||||||
|
internalTransfer: boolean
|
||||||
vendor: TransactionsListItemVendor | null
|
vendor: TransactionsListItemVendor | null
|
||||||
category: TransactionsListItemCategory | null
|
category: TransactionsListItemCategory | null
|
||||||
creditedAccount: TransactionsListItemAccount | null
|
creditedAccount: TransactionsListItemAccount | null
|
||||||
|
|
@ -82,6 +72,7 @@ export interface TransactionDetail {
|
||||||
amount: number
|
amount: number
|
||||||
currency: Currency
|
currency: Currency
|
||||||
description: string
|
description: string
|
||||||
|
internalTransfer: boolean
|
||||||
vendor: TransactionVendor | null
|
vendor: TransactionVendor | null
|
||||||
category: TransactionCategory | null
|
category: TransactionCategory | null
|
||||||
creditedAccount: TransactionDetailAccount | null
|
creditedAccount: TransactionDetailAccount | null
|
||||||
|
|
@ -111,6 +102,7 @@ export interface AddTransactionPayload {
|
||||||
amount: number
|
amount: number
|
||||||
currencyCode: string
|
currencyCode: string
|
||||||
description: string
|
description: string
|
||||||
|
internalTransfer: boolean
|
||||||
vendorId: number | null
|
vendorId: number | null
|
||||||
categoryId: number | null
|
categoryId: number | null
|
||||||
creditedAccountId: number | null
|
creditedAccountId: number | null
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,14 @@ import { useTemplateRef, ref, computed } from 'vue'
|
||||||
import ModalWrapper from './common/ModalWrapper.vue'
|
import ModalWrapper from './common/ModalWrapper.vue'
|
||||||
import AppButton from './common/AppButton.vue'
|
import AppButton from './common/AppButton.vue'
|
||||||
|
|
||||||
const timeoutModal = useTemplateRef("timeoutModal")
|
const timeoutModal = useTemplateRef('timeoutModal')
|
||||||
const secondsUntilLogout = ref(30)
|
const secondsUntilLogout = ref(30)
|
||||||
const timeoutTimerId = ref<number | undefined>()
|
const timeoutTimerId = ref<number | undefined>()
|
||||||
const timePhrase = computed(() => {
|
const timePhrase = computed(() => {
|
||||||
if (secondsUntilLogout.value !== 1) {
|
if (secondsUntilLogout.value !== 1) {
|
||||||
return secondsUntilLogout.value + " seconds"
|
return secondsUntilLogout.value + ' seconds'
|
||||||
}
|
}
|
||||||
return secondsUntilLogout.value + " second"
|
return secondsUntilLogout.value + ' second'
|
||||||
})
|
})
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
@ -40,8 +40,8 @@ defineExpose({ start })
|
||||||
<ModalWrapper ref="timeoutModal">
|
<ModalWrapper ref="timeoutModal">
|
||||||
<template v-slot:default>
|
<template v-slot:default>
|
||||||
<p>
|
<p>
|
||||||
You've been inactive for a while, so to ensure the safety of your
|
You've been inactive for a while, so to ensure the safety of your personal information, you
|
||||||
personal information, you will be logged out in
|
will be logged out in
|
||||||
<strong>{{ timePhrase }}</strong>
|
<strong>{{ timePhrase }}</strong>
|
||||||
unless you click below to remain logged in.
|
unless you click below to remain logged in.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,20 @@ defineExpose({ show, close, isOpen })
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<dialog ref="dialog" class="app-modal-dialog" :id="id">
|
<dialog
|
||||||
|
ref="dialog"
|
||||||
|
class="app-modal-dialog"
|
||||||
|
:id="id"
|
||||||
|
>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
|
|
||||||
<div class="app-modal-dialog-actions">
|
<div class="app-modal-dialog-actions">
|
||||||
<slot name="buttons">
|
<slot name="buttons">
|
||||||
<AppButton button-style="secondary" @click="close()">Close</AppButton>
|
<AppButton
|
||||||
|
button-style="secondary"
|
||||||
|
@click="close()"
|
||||||
|
>Close</AppButton
|
||||||
|
>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
|
||||||
|
|
@ -51,4 +51,8 @@ defineProps<{
|
||||||
font-family: 'OpenSans', sans-serif;
|
font-family: 'OpenSans', sans-serif;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-form-control > label input[type='checkbox'] {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,10 @@ const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const IDLE_TIMEOUT_SECONDS = 300
|
const IDLE_TIMEOUT_SECONDS = 300
|
||||||
const idleTimeoutModal = useTemplateRef("idleTimeoutModal")
|
const idleTimeoutModal = useTemplateRef('idleTimeoutModal')
|
||||||
useIdleObserver({
|
useIdleObserver({
|
||||||
timeout: IDLE_TIMEOUT_SECONDS * 1000,
|
timeout: IDLE_TIMEOUT_SECONDS * 1000,
|
||||||
onIdle: () => idleTimeoutModal.value?.start()
|
onIdle: () => idleTimeoutModal.value?.start(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const authCheckTimer: Ref<number | undefined> = ref(undefined)
|
const authCheckTimer: Ref<number | undefined> = ref(undefined)
|
||||||
|
|
@ -70,16 +70,25 @@ async function checkAuth() {
|
||||||
<div>
|
<div>
|
||||||
<header class="app-header-bar">
|
<header class="app-header-bar">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="app-header-text" @click="onHeaderClicked()">
|
<h1
|
||||||
|
class="app-header-text"
|
||||||
|
@click="onHeaderClicked()"
|
||||||
|
>
|
||||||
Finnow
|
Finnow
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="app-user-widget" @click="router.push('/me')">
|
<span
|
||||||
|
class="app-user-widget"
|
||||||
|
@click="router.push('/me')"
|
||||||
|
>
|
||||||
<font-awesome-icon icon="fa-user"></font-awesome-icon>
|
<font-awesome-icon icon="fa-user"></font-awesome-icon>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="app-logout-button" @click="authStore.onUserLoggedOut()">
|
<span
|
||||||
|
class="app-logout-button"
|
||||||
|
@click="authStore.onUserLoggedOut()"
|
||||||
|
>
|
||||||
<font-awesome-icon icon="fa-solid fa-arrow-right-from-bracket"></font-awesome-icon>
|
<font-awesome-icon icon="fa-solid fa-arrow-right-from-bracket"></font-awesome-icon>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ const unsavedEdits = computed(() => {
|
||||||
amount.value * Math.pow(10, currency.value?.fractionalDigits ?? 0) !== tx.amount
|
amount.value * Math.pow(10, currency.value?.fractionalDigits ?? 0) !== tx.amount
|
||||||
const currencyChanged = currency.value?.code !== tx.currency.code
|
const currencyChanged = currency.value?.code !== tx.currency.code
|
||||||
const descriptionChanged = description.value !== tx.description
|
const descriptionChanged = description.value !== tx.description
|
||||||
|
const internalTransferChanged = internalTransfer.value !== tx.internalTransfer
|
||||||
const vendorChanged = vendor.value?.id !== tx.vendor?.id
|
const vendorChanged = vendor.value?.id !== tx.vendor?.id
|
||||||
const categoryChanged = categoryId.value !== (tx.category?.id ?? null)
|
const categoryChanged = categoryId.value !== (tx.category?.id ?? null)
|
||||||
const creditedAccountChanged = creditedAccountId.value !== (tx.creditedAccount?.id ?? null)
|
const creditedAccountChanged = creditedAccountId.value !== (tx.creditedAccount?.id ?? null)
|
||||||
|
|
@ -94,6 +95,7 @@ const unsavedEdits = computed(() => {
|
||||||
Amount changed: ${amountChanged}
|
Amount changed: ${amountChanged}
|
||||||
Currency changed: ${currencyChanged}
|
Currency changed: ${currencyChanged}
|
||||||
Description changed: ${descriptionChanged}
|
Description changed: ${descriptionChanged}
|
||||||
|
Internal Transfer changed: ${internalTransferChanged}
|
||||||
Vendor changed: ${vendorChanged}
|
Vendor changed: ${vendorChanged}
|
||||||
Category changed: ${categoryChanged}
|
Category changed: ${categoryChanged}
|
||||||
Credited account changed: ${creditedAccountChanged}
|
Credited account changed: ${creditedAccountChanged}
|
||||||
|
|
@ -108,6 +110,7 @@ const unsavedEdits = computed(() => {
|
||||||
amountChanged ||
|
amountChanged ||
|
||||||
currencyChanged ||
|
currencyChanged ||
|
||||||
descriptionChanged ||
|
descriptionChanged ||
|
||||||
|
internalTransferChanged ||
|
||||||
vendorChanged ||
|
vendorChanged ||
|
||||||
categoryChanged ||
|
categoryChanged ||
|
||||||
creditedAccountChanged ||
|
creditedAccountChanged ||
|
||||||
|
|
@ -136,6 +139,7 @@ const timestamp = ref('')
|
||||||
const amount = ref(0)
|
const amount = ref(0)
|
||||||
const currency: Ref<Currency | null> = ref(null)
|
const currency: Ref<Currency | null> = ref(null)
|
||||||
const description = ref('')
|
const description = ref('')
|
||||||
|
const internalTransfer = ref(false)
|
||||||
const vendor: Ref<TransactionVendor | null> = ref(null)
|
const vendor: Ref<TransactionVendor | null> = ref(null)
|
||||||
const categoryId: Ref<number | null> = ref(null)
|
const categoryId: Ref<number | null> = ref(null)
|
||||||
const creditedAccountId: Ref<number | null> = ref(null)
|
const creditedAccountId: Ref<number | null> = ref(null)
|
||||||
|
|
@ -208,6 +212,7 @@ async function doSubmit() {
|
||||||
amount: floatMoneyToInteger(amount.value, currency.value),
|
amount: floatMoneyToInteger(amount.value, currency.value),
|
||||||
currencyCode: currency.value?.code ?? '',
|
currencyCode: currency.value?.code ?? '',
|
||||||
description: description.value,
|
description: description.value,
|
||||||
|
internalTransfer: internalTransfer.value,
|
||||||
vendorId: vendorId,
|
vendorId: vendorId,
|
||||||
categoryId: categoryId.value,
|
categoryId: categoryId.value,
|
||||||
creditedAccountId: creditedAccountId.value,
|
creditedAccountId: creditedAccountId.value,
|
||||||
|
|
@ -260,6 +265,7 @@ function loadValuesFromExistingTransaction(t: TransactionDetail) {
|
||||||
amount.value = t.amount / Math.pow(10, t.currency.fractionalDigits)
|
amount.value = t.amount / Math.pow(10, t.currency.fractionalDigits)
|
||||||
currency.value = t.currency
|
currency.value = t.currency
|
||||||
description.value = t.description
|
description.value = t.description
|
||||||
|
internalTransfer.value = t.internalTransfer
|
||||||
vendor.value = t.vendor ?? null
|
vendor.value = t.vendor ?? null
|
||||||
categoryId.value = t.category?.id ?? null
|
categoryId.value = t.category?.id ?? null
|
||||||
creditedAccountId.value = t.creditedAccount?.id ?? null
|
creditedAccountId.value = t.creditedAccount?.id ?? null
|
||||||
|
|
@ -391,6 +397,20 @@ function getLocalDateTimeStringFromUTCTimestamp(timestamp: string) {
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
|
<!-- One last group for less-often used fields: -->
|
||||||
|
<FormGroup>
|
||||||
|
<FormControl
|
||||||
|
label="Internal Transfer"
|
||||||
|
hint="Mark this transaction as an internal transfer to ignore it in analytics. Useful for things like credit card payments."
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="internalTransfer"
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
<FormActions
|
<FormActions
|
||||||
@cancel="doCancel()"
|
@cancel="doCancel()"
|
||||||
:disabled="loading || !formValid || !unsavedEdits"
|
:disabled="loading || !formValid || !unsavedEdits"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue