finnow/finnow-api/source/transaction/data_impl_sqlite.d

559 lines
21 KiB
D

module transaction.data_impl_sqlite;
import handy_http_primitives : Optional, ServerHttpRequest;
import std.datetime;
import std.typecons;
import d2sqlite3;
import transaction.model;
import transaction.data;
import transaction.dto;
import util.sqlite;
import util.money;
import util.pagination;
import util.data;
class SqliteTransactionVendorRepository : TransactionVendorRepository {
private Database db;
this(Database db) {
this.db = db;
}
Optional!TransactionVendor findById(ulong id) {
return util.sqlite.findById(db, "transaction_vendor", &parseVendor, id);
}
TransactionVendor[] findAll() {
return util.sqlite.findAll(
db,
"SELECT * FROM transaction_vendor ORDER BY name ASC",
&parseVendor
);
}
bool existsByName(string name) {
return util.sqlite.exists(db, "SELECT id FROM transaction_vendor WHERE name = ?", name);
}
bool existsById(ulong id) {
return util.sqlite.exists(db, "SELECT id FROM transaction_vendor WHERE id = ?", id);
}
TransactionVendor insert(string name, string description) {
util.sqlite.update(
db,
"INSERT INTO transaction_vendor (name, description) VALUES (?, ?)",
name, description
);
ulong id = db.lastInsertRowid();
return findById(id).orElseThrow();
}
void deleteById(ulong id) {
util.sqlite.deleteById(db, "transaction_vendor", id);
}
TransactionVendor updateById(ulong id, string name, string description) {
util.sqlite.update(
db,
"UPDATE transaction_vendor SET name = ?, description = ? WHERE id = ?",
name, description, id
);
return findById(id).orElseThrow();
}
private static TransactionVendor parseVendor(Row row) {
return TransactionVendor(
row.peek!ulong(0),
row.peek!string(1),
row.peek!string(2)
);
}
}
class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
private Database db;
this(Database db) {
this.db = db;
}
Optional!TransactionCategory findById(ulong id) {
return util.sqlite.findById(db, "transaction_category", &parseCategory, id);
}
Optional!TransactionCategory findByName(string name) {
return util.sqlite.findOne(db, "SELECT * FROM transaction_category WHERE name = ?", &parseCategory, name);
}
bool existsById(ulong id) {
return util.sqlite.exists(db, "SELECT id FROM transaction_category WHERE id = ?", id);
}
bool existsByName(string name) {
return util.sqlite.exists(db, "SELECT id FROM transaction_category WHERE name = ?", name);
}
TransactionCategory[] findAll() {
return util.sqlite.findAll(
db,
"SELECT * FROM transaction_category ORDER BY parent_id, name",
&parseCategory
);
}
TransactionCategory[] findAllByParentId(Optional!ulong parentId) {
if (parentId) {
return util.sqlite.findAll(
db,
"SELECT * FROM transaction_category WHERE parent_id = ? ORDER BY name ASC",
&parseCategory,
parentId.value
);
}
return util.sqlite.findAll(
db,
"SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC",
&parseCategory
);
}
TransactionCategory insert(Optional!ulong parentId, string name, string description, string color) {
util.sqlite.update(
db,
"INSERT INTO transaction_category
(parent_id, name, description, color)
VALUES (?, ?, ?, ?)",
toNullable(parentId), name, description, color
);
ulong id = db.lastInsertRowid();
return findById(id).orElseThrow();
}
void deleteById(ulong id) {
util.sqlite.deleteById(db, "transaction_category", id);
}
TransactionCategory updateById(ulong id, string name, string description, string color) {
util.sqlite.update(
db,
"UPDATE transaction_category
SET name = ?, description = ?, color = ?
WHERE id = ?",
name, description, color, id
);
return findById(id).orElseThrow();
}
private static TransactionCategory parseCategory(Row row) {
import std.typecons;
return TransactionCategory(
row.peek!ulong(0),
toOptional(row.peek!(Nullable!ulong)(1)),
row.peek!string(2),
row.peek!string(3),
row.peek!string(4)
);
}
}
class SqliteTransactionTagRepository : TransactionTagRepository {
private Database db;
this(Database db) {
this.db = db;
}
string[] findAllByTransactionId(ulong transactionId) {
return util.sqlite.findAll(
db,
"SELECT tag FROM transaction_tag WHERE transaction_id = ? ORDER BY tag",
r => r.peek!string(0),
transactionId
);
}
void updateTags(ulong transactionId, in string[] tags) {
util.sqlite.update(
db,
"DELETE FROM transaction_tag WHERE transaction_id = ?",
transactionId
);
foreach (tag; tags) {
util.sqlite.update(
db,
"INSERT INTO transaction_tag (transaction_id, tag) VALUES (?, ?)",
transactionId, tag
);
}
}
string[] findAll() {
return util.sqlite.findAll(
db,
"SELECT DISTINCT tag FROM transaction_tag ORDER BY tag",
r => r.peek!string(0)
);
}
}
class SqliteTransactionRepository : TransactionRepository {
private const TABLE_NAME = "\"transaction\"";
private Database db;
this(Database db) {
this.db = db;
}
Page!TransactionsListItem findAll(in PageRequest pr) {
const pageIdsQuery = "SELECT DISTINCT txn.id FROM \"transaction\" txn " ~ pr.toSql();
QueryBuilder qb = getBuilderForTransactionsList();
addSelectsForTransactionsList(qb);
qb.where("txn.id IN (" ~ pageIdsQuery ~ ")");
string query = qb.build() ~ "\n" ~ pr.toOrderClause();
TransactionsListItem[] results = util.sqlite.findAllDirect(db, query, &parseListItems);
ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM \"transaction\"");
return Page!(TransactionsListItem).of(results, pr, totalCount);
}
Page!TransactionsListItem search(in PageRequest pr, in ServerHttpRequest request) {
import std.algorithm;
import std.conv;
import std.string : join;
import transaction.search_filters;
import std.stdio;
QueryBuilder qb = getBuilderForTransactionsList();
// 1. Get the total count of transactions that match the search filters.
qb.select("COUNT (DISTINCT txn.id)");
applyFilters(qb, request);
string countQuery = qb.build();
// writeln(countQuery);
Statement countStmt = db.prepare(countQuery);
qb.applyArgBindings(countStmt);
auto countResult = countStmt.execute();
ulong count = countResult.empty ? 0 : countResult.front.peek!ulong(0);
if (count == 0) return Page!TransactionsListItem.of([], pr, 0);
// 2. Select the ordered list of IDs of transactions to include in the page of results.
// This is because one transaction may be split over multiple db rows.
qb.selections = [];
qb.select("DISTINCT txn.id");
string idsQuery = qb.build() ~ "\n" ~ pr.toSql();
Statement idsStmt = db.prepare(idsQuery);
qb.applyArgBindings(idsStmt);
string idsStr = idsStmt.execute()
.map!(r => r.peek!ulong(0))
.map!(id => id.to!string)
.join(",");
// 3. Select the list of transactions for the results based on the IDs found in step 2.
qb.selections = [];
qb.conditions = [];
qb.argBinders = [];
addSelectsForTransactionsList(qb);
qb.where("txn.id IN (" ~ idsStr ~ ")");
string query = qb.build() ~ "\n" ~ pr.toOrderClause();
Statement stmt = db.prepare(query);
auto results = parseListItems(stmt.execute());
return Page!TransactionsListItem.of(results, pr, count);
}
Optional!TransactionDetail findById(ulong id) {
Optional!TransactionDetail item = util.sqlite.findOne(
db,
import("sql/get_transaction.sql"),
(row) {
TransactionDetail 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);
item.internalTransfer = row.peek!bool(6);
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7);
if (!vendorId.isNull) {
item.vendor = Optional!(TransactionDetail.Vendor).of(
TransactionDetail.Vendor(
vendorId.get,
row.peek!string(8),
row.peek!string(9)
)).toNullable;
}
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(10);
if (!categoryId.isNull) {
item.category = Optional!(TransactionDetail.Category).of(
TransactionDetail.Category(
categoryId.get,
row.peek!(Nullable!ulong)(11),
row.peek!string(12),
row.peek!string(13),
row.peek!string(14)
)).toNullable;
}
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(15);
if (!creditedAccountId.isNull) {
item.creditedAccount = Optional!(TransactionDetail.Account).of(
TransactionDetail.Account(
creditedAccountId.get,
row.peek!string(16),
row.peek!string(17),
row.peek!string(18)
)).toNullable;
}
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(19);
if (!debitedAccountId.isNull) {
item.debitedAccount = Optional!(TransactionDetail.Account).of(
TransactionDetail.Account(
debitedAccountId.get,
row.peek!string(20),
row.peek!string(21),
row.peek!string(22)
)).toNullable;
}
string tagsStr = row.peek!string(23);
if (tagsStr !is null && tagsStr.length > 0) {
import std.string : split;
item.tags = tagsStr.split(",");
} else {
item.tags = [];
}
return item;
},
id
);
if (item.isNull) return item;
item.value.lineItems = util.sqlite.findAll(
db,
import("sql/get_line_items.sql"),
(row) {
TransactionDetail.LineItem li;
li.idx = row.peek!uint(0);
li.valuePerItem = row.peek!long(1);
li.quantity = row.peek!ulong(2);
li.description = row.peek!string(3);
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(4);
if (!categoryId.isNull) {
li.category = Optional!(TransactionDetail.Category).of(
TransactionDetail.Category(
categoryId.get,
row.peek!(Nullable!ulong)(5),
row.peek!string(6),
row.peek!string(7),
row.peek!string(8)
)).toNullable;
}
return li;
},
id
);
return item;
}
TransactionDetail insert(in AddTransactionPayload data) {
util.sqlite.update(
db,
import("sql/insert_transaction.sql"),
data.timestamp,
Clock.currTime(UTC()).toISOExtString(),
data.amount,
data.currencyCode,
data.description,
data.internalTransfer,
data.vendorId,
data.categoryId
);
ulong transactionId = db.lastInsertRowid();
insertLineItems(transactionId, data);
return findById(transactionId).orElseThrow();
}
void linkAttachment(ulong transactionId, ulong attachmentId) {
util.sqlite.update(
db,
"INSERT INTO transaction_attachment (transaction_id, attachment_id) VALUES (?, ?)",
transactionId,
attachmentId
);
}
TransactionDetail update(ulong transactionId, in AddTransactionPayload data) {
util.sqlite.update(
db,
import("sql/update_transaction.sql"),
data.timestamp,
data.amount,
data.currencyCode,
data.description,
data.internalTransfer,
data.vendorId,
data.categoryId,
transactionId
);
// Re-write all line items:
util.sqlite.update(
db,
"DELETE FROM transaction_line_item WHERE transaction_id = ?",
transactionId
);
insertLineItems(transactionId, data);
return findById(transactionId).orElseThrow();
}
void deleteById(ulong id) {
util.sqlite.deleteById(db, TABLE_NAME, id);
// Delete all history items for journal entries that have been removed by the deletion.
util.sqlite.update(
db,
"DELETE FROM account_history_item WHERE type LIKE 'JOURNAL_ENTRY' AND id NOT IN (
SELECT item_id FROM history_item_linked_journal_entry
)"
);
}
/**
* Function to parse a list of transaction list items as obtained from the
* `get_transactions.sql` query. Because there are possibly multiple rows
* per transaction, we have to deal with the result range as a whole.
* Params:
* r = The result range to read.
* Returns: The list of transaction list items.
*/
private static TransactionsListItem[] parseListItems(ResultRange r) {
import std.array : appender;
auto app = appender!(TransactionsListItem[]);
TransactionsListItem item;
/// Helper function that appends the current item to the list, and resets state.
void appendItem() {
import std.algorithm : sort;
sort(item.tags);
app ~= item;
item.id = 0;
item.tags = [];
item.vendor = Optional!(TransactionsListItem.Vendor).empty();
item.category = Optional!(TransactionsListItem.Category).empty();
item.creditedAccount = Optional!(TransactionsListItem.Account).empty();
item.debitedAccount = Optional!(TransactionsListItem.Account).empty();
}
size_t rowCount = 0;
foreach (Row row; r) {
rowCount++;
ulong txnId = row.peek!ulong(0);
if (item.id != txnId) {
// We're parsing a new item. First, append the current one if there is one.
if (item.id != 0) {
appendItem();
}
item.id = txnId;
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);
item.internalTransfer = row.peek!bool(6);
// Read the nullable Vendor information.
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7);
if (!vendorId.isNull) {
string vendorName = row.peek!string(8);
item.vendor = Optional!(TransactionsListItem.Vendor).of(
TransactionsListItem.Vendor(vendorId.get, vendorName));
}
// Read the nullable Category information.
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(9);
if (!categoryId.isNull) {
string categoryName = row.peek!string(10);
string categoryColor = row.peek!string(11);
item.category = Optional!(TransactionsListItem.Category).of(
TransactionsListItem.Category(categoryId.get, categoryName, categoryColor));
}
// Read the nullable creditedAccount.
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(12);
if (!creditedAccountId.isNull) {
ulong id = creditedAccountId.get;
string name = row.peek!string(13);
string type = row.peek!string(14);
string suffix = row.peek!string(15);
item.creditedAccount = Optional!(TransactionsListItem.Account).of(
TransactionsListItem.Account(id, name, type, suffix));
}
// Read the nullable debitedAccount.
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(16);
if (!debitedAccountId.isNull) {
ulong id = debitedAccountId.get;
string name = row.peek!string(17);
string type = row.peek!string(18);
string suffix = row.peek!string(19);
item.debitedAccount = Optional!(TransactionsListItem.Account).of(
TransactionsListItem.Account(id, name, type, suffix));
}
}
// Read multi-row properties, like tags, to the current item.
string tag = row.peek!string(20);
if (tag !is null) {
item.tags ~= tag;
}
}
// If there's one last cached item, append it to the list.
if (item.id != 0) {
appendItem();
}
return app[];
}
private void insertLineItems(ulong transactionId, in AddTransactionPayload data) {
foreach (size_t idx, lineItem; data.lineItems) {
util.sqlite.update(
db,
import("sql/insert_line_item.sql"),
transactionId,
idx,
lineItem.valuePerItem,
lineItem.quantity,
lineItem.description,
lineItem.categoryId
);
}
}
private QueryBuilder getBuilderForTransactionsList() {
return QueryBuilder("\"transaction\" txn")
.join("LEFT JOIN transaction_vendor vendor ON vendor.id = txn.vendor_id")
.join("LEFT JOIN transaction_category category ON category.id = txn.category_id")
.join("LEFT JOIN account_journal_entry j_credit " ~
"ON j_credit.transaction_id = txn.id AND UPPER(j_credit.type) = 'CREDIT'")
.join("LEFT JOIN account account_credit ON account_credit.id = j_credit.account_id")
.join("LEFT JOIN account_journal_entry j_debit " ~
"ON j_debit.transaction_id = txn.id AND UPPER(j_debit.type) = 'DEBIT'")
.join("LEFT JOIN account account_debit ON account_debit.id = j_debit.account_id")
.join("LEFT JOIN transaction_tag tags ON tags.transaction_id = txn.id");
}
private void addSelectsForTransactionsList(ref QueryBuilder qb) {
qb
.select("txn.id")
.select("txn.timestamp")
.select("txn.added_at")
.select("txn.amount")
.select("txn.currency")
.select("txn.description")
.select("txn.internal_transfer")
.select("txn.vendor_id")
.select("vendor.name")
.select("txn.category_id")
.select("category.name")
.select("category.color")
.select("account_credit.id")
.select("account_credit.name")
.select("account_credit.type")
.select("account_credit.number_suffix")
.select("account_debit.id")
.select("account_debit.name")
.select("account_debit.type")
.select("account_debit.number_suffix")
.select("tags.tag");
}
}