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

916 lines
34 KiB
D

module transaction.data_impl_sqlite;
import handy_http_primitives : Optional, StringMultiValueMap, mapIfPresent, toOptional;
import util.data;
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;
import account.model;
import account.dto;
import attachment.dto;
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, Optional!ulong parentId) {
util.sqlite.update(
db,
"UPDATE transaction_category
SET name = ?, description = ?, color = ?, parent_id = ?
WHERE id = ?",
name, description, color, toNullable!ulong(parentId), id
);
return findById(id).orElseThrow();
}
TransactionCategoryBalance[] getBalance(
ulong id,
bool includeChildren,
Optional!SysTime afterTimestamp,
Optional!SysTime beforeTimestamp
) {
import std.algorithm : map;
import std.conv : to;
import std.string : join;
// First collect the list of IDs to include in the query.
ulong[] idsForQuery = [id];
if (includeChildren) {
import std.range : front, popFront;
ulong[] idQueue = [id];
while (idQueue.length > 0) {
ulong nextId = idQueue.front;
idQueue.popFront;
auto children = findAllByParentId(Optional!ulong.of(nextId));
foreach (child; children) {
idsForQuery ~= child.id;
idQueue ~= child.id;
}
}
}
const categoryIdsString = idsForQuery.map!(id => id.to!string).join(",");
// Now build the query, taking into account the optional timestamp constraints.
QueryBuilder qb = QueryBuilder("\"transaction\" txn")
.join("LEFT JOIN account_journal_entry je ON je.transaction_id = txn.id")
.select("je.currency")
.select("je.type")
.select("SUM(je.amount)")
.where("txn.category_id IN (" ~ categoryIdsString ~ ")");
if (!afterTimestamp.isNull) {
qb.where("txn.timestamp > ?")
.withArgBinding((ref stmt, ref idx) {
stmt.bind(idx++, afterTimestamp.value.toISOExtString());
});
}
if (!beforeTimestamp.isNull) {
qb.where("txn.timestamp < ?")
.withArgBinding((ref stmt, ref idx) {
stmt.bind(idx++, beforeTimestamp.value.toISOExtString());
});
}
string query = qb.build() ~ " GROUP BY je.currency, je.type ORDER BY je.currency ASC, je.type ASC";
Statement stmt = db.prepare(query);
qb.applyArgBindings(stmt);
ResultRange result = stmt.execute();
// Process the results into a set of category balances for each currency.
TransactionCategoryBalance[ushort] balancesGroupedByCurrency;
foreach (row; result) {
Currency currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(0));
string journalEntryType = row.peek!(string, PeekMode.slice)(1);
ulong amountSum = row.peek!ulong(2);
TransactionCategoryBalance balance;
if (currency.numericCode in balancesGroupedByCurrency) {
balance = balancesGroupedByCurrency[currency.numericCode];
} else {
balance = TransactionCategoryBalance(0, 0, 0, currency);
}
if (journalEntryType == AccountJournalEntryType.CREDIT) {
balance.credits = amountSum;
} else if (journalEntryType == AccountJournalEntryType.DEBIT) {
balance.debits = amountSum;
}
balancesGroupedByCurrency[currency.numericCode] = balance;
}
// Post-process into a list of balances for returning:
TransactionCategoryBalance[] balances = balancesGroupedByCurrency.values;
foreach (ref bal; balances) {
bal.balance = cast(long) bal.debits - cast(long) bal.credits;
}
return balances;
}
private static TransactionCategory parseCategory(Row row) {
import std.typecons;
return TransactionCategory(
row.peek!ulong(0),
row.parseOptional!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) {
string query = import("sql/query/get_transactions.sql") ~ "\n" ~ pr.toSql();
TransactionsListItem[] results = util.sqlite.findAll(db, query, &parseListItem);
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 StringMultiValueMap searchParams) {
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, searchParams, new SqliteTransactionCategoryRepository(db));
string countQuery = qb.build();
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.groupBy("txn.id");
qb.where("txn.id IN (" ~ idsStr ~ ")");
string query = qb.build() ~ "\n" ~ pr.toOrderClause();
TransactionsListItem[] results = util.sqlite.findAll(db, query, &parseListItem);
return Page!TransactionsListItem.of(results, pr, count);
}
AggregateTransactionData getAggregateData(in StringMultiValueMap searchParams) {
import transaction.search_filters;
// Start by using the standard transactions-list query builder to apply filters.
QueryBuilder qb = getBuilderForTransactionsList();
qb.select("txn.currency AS currency, j_credit.amount AS credits, j_debit.amount AS debits");
applyFilters(qb, searchParams, new SqliteTransactionCategoryRepository(db));
string baseQuery = qb.build() ~ "\nGROUP BY txn.id";
// Now wrap that in a separate query that aggregates credits & debits for each transaction.
string aggregateQuery = QueryBuilder("(" ~ baseQuery ~ ") AS base")
.select("base.currency")
.select("SUM(base.credits)")
.select("SUM(base.debits)")
.build() ~ " GROUP BY base.currency";
Statement stmt = db.prepare(aggregateQuery);
// Use the base query's argument bindings to apply to the DB statement.
qb.applyArgBindings(stmt);
ResultRange result = stmt.execute();
// Collect the results for each currency into
AggregateTransactionData aggregateData;
while (!result.empty()) {
AggregateTransactionData.CurrencyData currencyData;
currencyData.credits = result.front.peek!ulong(1);
currencyData.debits = result.front.peek!ulong(2);
currencyData.balance = currencyData.debits - currencyData.credits;
currencyData.currency = Currency.ofCode(result.front.peek!(string, PeekMode.slice)(0));
aggregateData.currencies ~= currencyData;
result.popFront();
}
return aggregateData;
}
Optional!TransactionDetail findById(ulong id) {
Optional!TransactionDetail item = util.sqlite.findOne(
db,
import("sql/query/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!TransactionVendor.of(TransactionVendor(
vendorId.get,
row.peek!string(8),
row.peek!string(9)
));
}
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(10);
if (!categoryId.isNull) {
item.category = Optional!TransactionCategory.of(TransactionCategory(
categoryId.get,
row.parseOptional!ulong(11),
row.peek!string(12),
row.peek!string(13),
row.peek!string(14)
));
}
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(15);
if (!creditedAccountId.isNull) {
item.creditedAccount = Optional!SimpleAccountResponse.of(
SimpleAccountResponse(
creditedAccountId.get,
row.peek!string(16),
row.peek!string(17),
row.peek!string(18)
));
}
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(19);
if (!debitedAccountId.isNull) {
item.debitedAccount = Optional!SimpleAccountResponse.of(
SimpleAccountResponse(
debitedAccountId.get,
row.peek!string(20),
row.peek!string(21),
row.peek!string(22)
));
}
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/query/get_line_items.sql"),
(row) {
TransactionLineItemResponse 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);
Optional!ulong categoryId = row.parseOptional!ulong(4);
if (categoryId) {
li.category = Optional!TransactionCategory.of(
TransactionCategory(
categoryId.value,
row.parseOptional!ulong(5),
row.peek!string(6),
row.peek!string(7),
row.peek!string(8)
));
}
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.toNullable(),
data.categoryId.toNullable()
);
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.toNullable(),
data.categoryId.toNullable(),
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
)"
);
}
private static TransactionsListItem parseListItem(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);
item.internalTransfer = row.peek!bool(6);
Optional!ulong vendorId = row.parseOptional!ulong(7);
if (vendorId) {
item.vendor = SimpleVendorResponse(
vendorId.value,
row.peek!string(8)
).toOptional;
}
Optional!ulong categoryId = row.parseOptional!ulong(9);
if (categoryId) {
item.category = SimpleCategoryResponse(
categoryId.value,
row.peek!string(10),
row.peek!string(11)
).toOptional;
}
Optional!ulong creditedAccountId = row.parseOptional!ulong(12);
if (creditedAccountId) {
item.creditedAccount = SimpleAccountResponse(
creditedAccountId.value,
row.peek!string(13),
row.peek!string(14),
row.peek!string(15)
).toOptional;
}
Optional!ulong debitedAccountId = row.parseOptional!ulong(16);
if (debitedAccountId) {
item.debitedAccount = SimpleAccountResponse(
debitedAccountId.value,
row.peek!string(17),
row.peek!string(18),
row.peek!string(19)
).toOptional;
}
string aggregateTags = row.peek!string(20);
if (aggregateTags !is null) {
import std.string : split;
import std.algorithm : sort;
item.tags = aggregateTags.split(",");
sort(item.tags);
}
return item;
}
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.toNullable()
);
}
}
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("GROUP_CONCAT(tags.tag)");
}
}
class SqliteTransactionDraftRepository : TransactionDraftRepository {
private Database db;
this(Database db) {
this.db = db;
}
Page!TransactionDraftListItem findAllDrafts(in PageRequest pr) {
return findAllInternal(pr, DraftType.DRAFT.toOptional);
}
Page!TransactionDraftListItem findAllTemplates(in PageRequest pr) {
return findAllInternal(pr, DraftType.TEMPLATE.toOptional);
}
Page!TransactionDraftListItem findAll(in PageRequest pr) {
return findAllInternal(pr, Optional!DraftType.empty());
}
private static enum DraftType {
DRAFT,
TEMPLATE
}
private Page!TransactionDraftListItem findAllInternal(in PageRequest pr, Optional!DraftType type) {
QueryBuilder qb = getBuilderForDraftsList();
addSelectsForDraftsList(qb);
qb.groupBy("draft.id");
if (type) {
if (type.value == DraftType.DRAFT) {
qb.where("template_name IS NULL");
} else {
qb.where("template_name IS NOT NULL");
}
}
string query = qb.build() ~ "\n" ~ pr.toSql();
TransactionDraftListItem[] results = util.sqlite.findAll(db, query, &parseDraftListItem);
ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM transaction_draft");
return Page!(TransactionDraftListItem).of(results, pr, totalCount);
}
Optional!TransactionDraftResponse findById(ulong id) {
// First fetch the draft list item (contains all basic properties).
QueryBuilder qb = getBuilderForDraftsList();
addSelectsForDraftsList(qb);
qb.groupBy("draft.id");
qb.where("draft.id = ?");
string query = qb.build();
Optional!TransactionDraftListItem li = util.sqlite.findOne(db, query, &parseDraftListItem, id);
if (li.isNull) return Optional!TransactionDraftResponse.empty();
TransactionDraftListItem draft = li.value;
// Then fetch line items.
TransactionDraftResponse response;
response.id = draft.id;
response.addedAt = draft.addedAt;
response.templateName = draft.templateName;
response.timestamp = draft.timestamp;
response.amount = draft.amount;
response.currency = draft.currency;
response.description = draft.description;
response.internalTransfer = draft.internalTransfer;
response.vendor = draft.vendor;
response.category = draft.category;
response.creditedAccount = draft.creditedAccount;
response.debitedAccount = draft.debitedAccount;
response.tags = li.value.tags;
response.lineItems = util.sqlite.findAll(
db,
import("sql/query/get_line_items_draft.sql"),
(row) {
TransactionLineItemResponse item;
item.idx = row.peek!uint(0);
item.valuePerItem = row.peek!long(1);
item.quantity = row.peek!ulong(2);
item.description = row.peek!string(3);
Optional!ulong categoryId = row.parseOptional!ulong(4);
if (categoryId) {
item.category = Optional!TransactionCategory.of(
TransactionCategory(
categoryId.value,
row.parseOptional!ulong(5),
row.peek!string(6),
row.peek!string(7),
row.peek!string(8)
));
}
return item;
},
draft.id
);
// Return the response, excluding attachments (they are fetched using the attachment repo).
return Optional!TransactionDraftResponse.of(response);
}
TransactionDraftResponse insert(in TransactionDraftPayload data) {
util.sqlite.update(
db,
import("sql/insert_transaction_draft.sql"),
Clock.currTime(UTC()).toISOExtString(),
data.templateName.toNullable(),
data.timestamp.toNullable(),
data.amount.toNullable(),
data.currencyCode.toNullable(),
data.description.toNullable(),
data.internalTransfer.toNullable(),
data.vendorId.toNullable(),
data.categoryId.toNullable(),
data.creditedAccountId.toNullable(),
data.debitedAccountId.toNullable()
);
ulong draftId = db.lastInsertRowid();
insertLineItems(draftId, data);
return findById(draftId).orElseThrow();
}
void linkAttachment(ulong draftId, ulong attachmentId) {
util.sqlite.update(
db,
"INSERT INTO transaction_draft_attachment (draft_id, attachment_id) VALUES (?, ?)",
draftId,
attachmentId
);
}
TransactionDraftResponse update(ulong draftId, in TransactionDraftPayload data) {
util.sqlite.update(
db,
import("sql/update_transaction_draft.sql"),
data.templateName.toNullable(),
data.timestamp.toNullable(),
data.amount.toNullable(),
data.currencyCode.toNullable(),
data.description.toNullable(),
data.internalTransfer.toNullable(),
data.vendorId.toNullable(),
data.categoryId.toNullable(),
data.creditedAccountId.toNullable(),
data.debitedAccountId.toNullable(),
draftId
);
// Re-write all line items:
util.sqlite.update(
db,
"DELETE FROM transaction_draft_line_item WHERE draft_id = ?",
draftId
);
insertLineItems(draftId, data);
return findById(draftId).orElseThrow();
}
void deleteById(ulong id) {
util.sqlite.update(
db,
"DELETE FROM attachment WHERE id IN (
SELECT attachment_id FROM transaction_draft_attachment WHERE draft_id = ?
)",
id
);
util.sqlite.deleteById(db, "transaction_draft", id);
}
void updateTags(ulong draftId, in string[] tags) {
util.sqlite.update(
db,
"DELETE FROM transaction_draft_tag WHERE draft_id = ?",
draftId
);
foreach (tag; tags) {
util.sqlite.update(
db,
"INSERT INTO transaction_draft_tag (draft_id, tag) VALUES (?, ?)",
draftId, tag
);
}
}
string[] findAllTags() {
return util.sqlite.findAll(
db,
"SELECT DISTINCT tag FROM transaction_draft_tag ORDER BY tag",
r => r.peek!string(0)
);
}
private QueryBuilder getBuilderForDraftsList() {
return QueryBuilder("transaction_draft draft")
.join("LEFT JOIN transaction_vendor vendor ON vendor.id = draft.vendor_id")
.join("LEFT JOIN transaction_category category ON category.id = draft.category_id")
.join("LEFT JOIN account account_credit ON account_credit.id = draft.credited_account_id")
.join("LEFT JOIN account account_debit ON account_debit.id = draft.debited_account_id")
.join("LEFT JOIN transaction_draft_tag tags ON tags.draft_id = draft.id");
}
private void addSelectsForDraftsList(ref QueryBuilder qb) {
qb
.select("draft.id")
.select("draft.added_at")
.select("draft.template_name")
.select("draft.timestamp")
.select("draft.amount")// 5
.select("draft.currency")
.select("draft.description")
.select("draft.internal_transfer")
.select("vendor.id")
.select("vendor.name")// 10
.select("category.id")
.select("category.name")
.select("category.color")
.select("account_credit.id")
.select("account_credit.name")// 15
.select("account_credit.type")
.select("account_credit.number_suffix")
.select("account_debit.id")
.select("account_debit.name")
.select("account_debit.type")// 20
.select("account_debit.number_suffix")
.select("group_concat(tags.tag)");
}
private static TransactionDraftListItem parseDraftListItem(Row row) {
TransactionDraftListItem item;
item.id = row.peek!ulong(0);
item.addedAt = row.peek!string(1);
item.templateName = row.parseOptional!string(2);
item.timestamp = row.parseOptional!string(3);
item.amount = row.parseOptional!ulong(4);
item.currency = row.parseOptional!(string, PeekMode.slice)(5)
.mapIfPresent!(s => Currency.ofCode(s));
item.description = row.parseOptional!string(6);
item.internalTransfer = row.parseOptional!bool(7);
Optional!ulong vendorId = row.parseOptional!ulong(8);
if (vendorId) {
item.vendor = SimpleVendorResponse(
vendorId.value,
row.peek!string(9)
).toOptional;
}
Optional!ulong categoryId = row.parseOptional!ulong(10);
if (categoryId) {
item.category = SimpleCategoryResponse(
categoryId.value,
row.peek!string(11),
row.peek!string(12),
).toOptional;
}
Optional!ulong creditedAccountId = row.parseOptional!ulong(13);
if (creditedAccountId) {
item.creditedAccount = SimpleAccountResponse(
creditedAccountId.value,
row.peek!string(14),
row.peek!string(15),
row.peek!string(16)
).toOptional;
}
Optional!ulong debitedAccountId = row.parseOptional!ulong(17);
if (debitedAccountId) {
item.debitedAccount = SimpleAccountResponse(
debitedAccountId.value,
row.peek!string(18),
row.peek!string(19),
row.peek!string(20)
).toOptional;
}
string aggregateTags = row.peek!string(21);
if (aggregateTags !is null) {
import std.string : split;
item.tags = aggregateTags.split(",");
}
import std.algorithm : sort;
sort(item.tags);
return item;
}
private void insertLineItems(ulong draftId, in TransactionDraftPayload payload) {
foreach (size_t idx, lineItem; payload.lineItems) {
util.sqlite.update(
db,
import("sql/insert_line_item_draft.sql"),
draftId,
idx,
lineItem.valuePerItem,
lineItem.quantity,
lineItem.description,
lineItem.categoryId.toNullable()
);
}
}
}