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); } Page!TransactionDraftListItem findAllTemplates(in PageRequest pr) { return findAllInternal(pr, DraftType.TEMPLATE); } private static enum DraftType { DRAFT, TEMPLATE } private Page!TransactionDraftListItem findAllInternal(in PageRequest pr, DraftType type) { QueryBuilder qb = getBuilderForDraftsList(); addSelectsForDraftsList(qb); qb.groupBy("draft.id"); if (type == 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.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; } ); // 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, PeekMode.slice)(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() ); } } }