516 lines
18 KiB
D
516 lines
18 KiB
D
module account.data_impl_sqlite;
|
|
|
|
import std.datetime;
|
|
|
|
import d2sqlite3;
|
|
import handy_http_primitives : Optional;
|
|
import slf4d;
|
|
|
|
import account.data;
|
|
import account.model;
|
|
import account.dto;
|
|
import util.sqlite;
|
|
import util.money;
|
|
import util.pagination;
|
|
|
|
class SqliteAccountRepository : AccountRepository {
|
|
private Database db;
|
|
this(Database db) {
|
|
this.db = db;
|
|
}
|
|
|
|
Optional!Account findById(ulong id) {
|
|
return findOne(db, "SELECT * FROM account WHERE id = ?", &parseAccount, id);
|
|
}
|
|
|
|
bool existsById(ulong id) {
|
|
return util.sqlite.exists(db, "SELECT id FROM account WHERE id = ?", id);
|
|
}
|
|
|
|
Account insert(AccountType type, string numberSuffix, string name, Currency currency, string description) {
|
|
util.sqlite.update(
|
|
db,
|
|
"INSERT INTO account
|
|
(created_at, type, number_suffix, name, currency, description)
|
|
VALUES (?, ?, ?, ?, ?, ?)",
|
|
Clock.currTime(UTC()).toISOExtString(),
|
|
type.id,
|
|
numberSuffix,
|
|
name,
|
|
currency.code,
|
|
description
|
|
);
|
|
ulong accountId = db.lastInsertRowid();
|
|
return findById(accountId).orElseThrow("Couldn't find account!");
|
|
}
|
|
|
|
void setArchived(ulong id, bool archived) {
|
|
util.sqlite.update(
|
|
db,
|
|
"UPDATE account SET archived = ? WHERE id = ?",
|
|
archived ? 1 : 0,
|
|
id
|
|
);
|
|
}
|
|
|
|
Account update(ulong id, in Account newData) {
|
|
return doTransaction(db, () {
|
|
Account oldAccount = this.findById(id).orElseThrow("Account doesn't exist.");
|
|
bool typeDiff = oldAccount.type != newData.type;
|
|
bool numberSuffixDiff = oldAccount.numberSuffix != newData.numberSuffix;
|
|
bool nameDiff = oldAccount.name != newData.name;
|
|
bool currencyDiff = oldAccount.currency != newData.currency;
|
|
bool descriptionDiff = oldAccount.description != newData.description;
|
|
util.sqlite.update(
|
|
db,
|
|
q"SQL
|
|
UPDATE account
|
|
SET type = ?,
|
|
number_suffix = ?,
|
|
name = ?,
|
|
currency = ?,
|
|
description = ?
|
|
WHERE id = ?
|
|
SQL",
|
|
newData.type.id,
|
|
newData.numberSuffix,
|
|
newData.name,
|
|
newData.currency.code,
|
|
newData.description,
|
|
id
|
|
);
|
|
return this.findById(id).orElseThrow("Account doesn't exist");
|
|
});
|
|
}
|
|
|
|
void deleteById(ulong id) {
|
|
doTransaction(db, () {
|
|
// Delete all associated transactions.
|
|
util.sqlite.update(
|
|
db,
|
|
"DELETE FROM \"transaction\" WHERE id IN " ~
|
|
"(SELECT transaction_id FROM account_journal_entry WHERE account_id = ?)",
|
|
id
|
|
);
|
|
// Delete the account itself (and all cascaded entities, like journal entries).
|
|
util.sqlite.update(db, "DELETE FROM account WHERE id = ?", id);
|
|
/* Delete all orphaned history entries for journal entries referencing deleted transactions.
|
|
This is needed because even though the above `DELETE` cascades to
|
|
delete all history items for this account, there are some history
|
|
items linked to other accounts which aren't deleted automatically.
|
|
*/
|
|
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
|
|
)"
|
|
);
|
|
});
|
|
}
|
|
|
|
Account[] findAll() {
|
|
return util.sqlite.findAll(db, "SELECT * FROM account", &parseAccount);
|
|
}
|
|
|
|
AccountCreditCardProperties getCreditCardProperties(ulong id) {
|
|
Account account = findById(id).orElseThrow("Account doesn't exist.");
|
|
if (account.type != AccountTypes.CREDIT_CARD) throw new Exception("Account is not credit card.");
|
|
auto optionalProps = findOne(
|
|
db,
|
|
"SELECT * FROM account_credit_card_properties WHERE account_id = ?",
|
|
&parseCreditCardProperties,
|
|
id
|
|
);
|
|
if (!optionalProps.isNull) return optionalProps.value;
|
|
// No properties exist, so set them and return the new data.
|
|
const props = AccountCreditCardProperties(account.id, -1);
|
|
util.sqlite.update(
|
|
db,
|
|
"INSERT INTO account_credit_card_properties (account_id, credit_limit) VALUES (?, ?)",
|
|
props.account_id,
|
|
props.creditLimit
|
|
);
|
|
return props;
|
|
}
|
|
|
|
void setCreditCardProperties(ulong id, in AccountCreditCardProperties props) {
|
|
bool hasProps = exists(db, "SELECT * FROM account_credit_card_properties WHERE account_id = ?", id);
|
|
if (hasProps) {
|
|
util.sqlite.update(
|
|
db,
|
|
"UPDATE account_credit_card_properties SET credit_limit = ? WHERE account_id = ?",
|
|
props.creditLimit,
|
|
id
|
|
);
|
|
} else {
|
|
util.sqlite.update(
|
|
db,
|
|
"INSERT INTO account_credit_card_properties (account_id, credit_limit) VALUES (?, ?)",
|
|
id,
|
|
props.creditLimit
|
|
);
|
|
}
|
|
}
|
|
|
|
private static struct BaseHistoryItem {
|
|
ulong id;
|
|
string timestamp;
|
|
string type;
|
|
}
|
|
|
|
Page!AccountHistoryItemResponse getHistory(ulong accountId, in PageRequest pagination) {
|
|
ulong count = util.sqlite.count(
|
|
db,
|
|
"SELECT COUNT(id) FROM account_history_item WHERE account_id = ?",
|
|
accountId
|
|
);
|
|
const baseQuery = "SELECT id, timestamp, type " ~
|
|
"FROM account_history_item " ~
|
|
"WHERE account_id = ?";
|
|
string query = baseQuery ~ " " ~ pagination.toSql();
|
|
// First fetch the basic information about each item.
|
|
BaseHistoryItem[] items = util.sqlite.findAll(
|
|
db,
|
|
query,
|
|
(row) {
|
|
return BaseHistoryItem(
|
|
row.peek!ulong(0),
|
|
row.peek!string(1),
|
|
row.peek!string(2)
|
|
);
|
|
},
|
|
accountId
|
|
);
|
|
// Then we'll do another query based on the type of history item.
|
|
// This is fine for paginated responses.
|
|
AccountHistoryItemResponse[] results;
|
|
foreach (BaseHistoryItem item; items) {
|
|
if (item.type == AccountHistoryItemType.ValueRecord) {
|
|
results ~= fetchValueRecordHistoryItem(item);
|
|
} else if (item.type == AccountHistoryItemType.JournalEntry) {
|
|
results ~= fetchJournalEntryHistoryItem(item);
|
|
} else {
|
|
AccountHistoryItemResponse base = new AccountHistoryItemResponse();
|
|
base.timestamp = item.timestamp;
|
|
base.type = item.type;
|
|
results ~= base;
|
|
}
|
|
}
|
|
return Page!AccountHistoryItemResponse.of(results, pagination, count);
|
|
}
|
|
|
|
private AccountHistoryValueRecordItemResponse fetchValueRecordHistoryItem(in BaseHistoryItem item) {
|
|
return util.sqlite.findOne(
|
|
db,
|
|
"SELECT vr.id, vr.type, vr.value, vr.currency FROM history_item_linked_value_record h " ~
|
|
"LEFT JOIN account_value_record vr ON vr.id = h.value_record_id " ~
|
|
"WHERE h.item_id = ?",
|
|
(row) {
|
|
auto obj = new AccountHistoryValueRecordItemResponse();
|
|
obj.timestamp = item.timestamp;
|
|
obj.type = item.type;
|
|
obj.valueRecordId = row.peek!ulong(0);
|
|
obj.valueRecordType = row.peek!string(1);
|
|
obj.value = row.peek!long(2);
|
|
obj.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(3));
|
|
return obj;
|
|
},
|
|
item.id
|
|
).orElseThrow();
|
|
}
|
|
|
|
private AccountHistoryJournalEntryItemResponse fetchJournalEntryHistoryItem(in BaseHistoryItem item) {
|
|
return util.sqlite.findOne(
|
|
db,
|
|
"SELECT je.type, je.amount, je.currency, tx.id, tx.description FROM history_item_linked_journal_entry h " ~
|
|
"LEFT JOIN account_journal_entry je ON je.id = h.journal_entry_id " ~
|
|
"LEFT JOIN \"transaction\" tx ON tx.id = je.transaction_id " ~
|
|
"WHERE h.item_id = ?",
|
|
(row) {
|
|
auto obj = new AccountHistoryJournalEntryItemResponse();
|
|
obj.timestamp = item.timestamp;
|
|
obj.type = item.type;
|
|
obj.journalEntryType = row.peek!string(0);
|
|
obj.amount = row.peek!ulong(1);
|
|
obj.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(2));
|
|
obj.transactionId = row.peek!ulong(3);
|
|
obj.transactionDescription = row.peek!string(4);
|
|
return obj;
|
|
},
|
|
item.id
|
|
).orElseThrow();
|
|
}
|
|
|
|
static Account parseAccount(Row row) {
|
|
return Account(
|
|
row.peek!ulong(0),
|
|
SysTime.fromISOExtString(row.peek!string(1)),
|
|
row.peek!bool(2),
|
|
AccountType.fromId(row.peek!string(3)),
|
|
row.peek!string(4),
|
|
row.peek!string(5),
|
|
Currency.ofCode(row.peek!string(6)),
|
|
row.peek!string(7)
|
|
);
|
|
}
|
|
|
|
static AccountCreditCardProperties parseCreditCardProperties(Row row) {
|
|
import std.typecons : Nullable;
|
|
ulong accountId = row.peek!ulong(0);
|
|
Nullable!ulong creditLimit = row.peek!ulong(1);
|
|
return AccountCreditCardProperties(
|
|
accountId,
|
|
creditLimit.isNull ? -1 : creditLimit.get()
|
|
);
|
|
}
|
|
}
|
|
|
|
class SqliteAccountJournalEntryRepository : AccountJournalEntryRepository {
|
|
private Database db;
|
|
this(Database db) {
|
|
this.db = db;
|
|
}
|
|
|
|
Optional!AccountJournalEntry findById(ulong id) {
|
|
return util.sqlite.findById(db, "account_journal_entry", &parseEntry, id);
|
|
}
|
|
|
|
AccountJournalEntry insert(
|
|
SysTime timestamp,
|
|
ulong accountId,
|
|
ulong transactionId,
|
|
ulong amount,
|
|
AccountJournalEntryType type,
|
|
Currency currency
|
|
) {
|
|
util.sqlite.update(
|
|
db,
|
|
"INSERT INTO account_journal_entry
|
|
(timestamp, account_id, transaction_id, amount, type, currency)
|
|
VALUES (?, ?, ?, ?, ?, ?)",
|
|
timestamp.toISOExtString(),
|
|
accountId,
|
|
transactionId,
|
|
amount,
|
|
type,
|
|
currency.code
|
|
);
|
|
ulong journalEntryId = db.lastInsertRowid();
|
|
// Insert a history item that links to the journal entry.
|
|
ulong historyItemId = insertNewAccountHistoryItem(
|
|
db, timestamp, accountId, AccountHistoryItemType.JournalEntry);
|
|
util.sqlite.update(
|
|
db,
|
|
"INSERT INTO history_item_linked_journal_entry (item_id, journal_entry_id) VALUES (?, ?)",
|
|
historyItemId,
|
|
journalEntryId
|
|
);
|
|
return findById(journalEntryId).orElseThrow();
|
|
}
|
|
|
|
void deleteById(ulong id) {
|
|
util.sqlite.update(
|
|
db,
|
|
"DELETE FROM account_history_item WHERE id IN " ~
|
|
"(SELECT item_id FROM history_item_linked_journal_entry WHERE journal_entry_id = ?)",
|
|
id
|
|
);
|
|
util.sqlite.deleteById(db, "account_journal_entry", id);
|
|
}
|
|
|
|
void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId) {
|
|
util.sqlite.update(
|
|
db,
|
|
"DELETE FROM account_history_item WHERE id IN " ~
|
|
"(SELECT j.item_id FROM history_item_linked_journal_entry j " ~
|
|
" LEFT JOIN account_journal_entry je ON je.id = j.journal_entry_id " ~
|
|
"WHERE je.account_id = ? AND je.transaction_id = ?)",
|
|
accountId, transactionId
|
|
);
|
|
util.sqlite.update(
|
|
db,
|
|
"DELETE FROM account_journal_entry WHERE account_id = ? AND transaction_id = ?",
|
|
accountId, transactionId
|
|
);
|
|
}
|
|
|
|
AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl, ulong accountId) {
|
|
const query = "SELECT * FROM account_journal_entry " ~
|
|
"WHERE timestamp >= ? AND timestamp <= ? AND account_id = ? " ~
|
|
"ORDER BY timestamp ASC";
|
|
return util.sqlite.findAll(
|
|
db,
|
|
query,
|
|
&parseEntry,
|
|
startIncl.toISOExtString(), endIncl.toISOExtString(), accountId
|
|
);
|
|
}
|
|
|
|
static AccountJournalEntry parseEntry(Row row) {
|
|
string typeStr = row.peek!(string, PeekMode.slice)(5);
|
|
AccountJournalEntryType type;
|
|
if (typeStr == "CREDIT") {
|
|
type = AccountJournalEntryType.CREDIT;
|
|
} else if (typeStr == "DEBIT") {
|
|
type = AccountJournalEntryType.DEBIT;
|
|
} else {
|
|
throw new Exception("Invalid account journal entry type: " ~ typeStr);
|
|
}
|
|
return AccountJournalEntry(
|
|
row.peek!ulong(0),
|
|
SysTime.fromISOExtString(row.peek!string(1)),
|
|
row.peek!ulong(2),
|
|
row.peek!ulong(3),
|
|
row.peek!ulong(4),
|
|
type,
|
|
Currency.ofCode(row.peek!(string, PeekMode.slice)(6))
|
|
);
|
|
}
|
|
}
|
|
|
|
class SqliteAccountValueRecordRepository : AccountValueRecordRepository {
|
|
private Database db;
|
|
this(Database db) {
|
|
this.db = db;
|
|
}
|
|
|
|
Optional!AccountValueRecord findById(ulong accountId, ulong id) {
|
|
return util.sqlite.findOne(
|
|
db,
|
|
"SELECT * FROM account_value_record WHERE account_id = ? AND id = ?",
|
|
&parseValueRecord,
|
|
accountId, id
|
|
);
|
|
}
|
|
|
|
Page!AccountValueRecord findAllByAccountId(ulong accountId, in PageRequest pr) {
|
|
const baseQuery = "SELECT * FROM account_value_record WHERE account_id = ?";
|
|
string query = baseQuery ~ " " ~ pr.toSql();
|
|
AccountValueRecord[] records = util.sqlite.findAll(
|
|
db,
|
|
query,
|
|
&parseValueRecord,
|
|
accountId
|
|
);
|
|
ulong count = util.sqlite.count(
|
|
db,
|
|
"SELECT COUNT(id) FROM account_value_record WHERE account_id = ?",
|
|
accountId
|
|
);
|
|
return Page!AccountValueRecord.of(records, pr, count);
|
|
}
|
|
|
|
AccountValueRecord insert(
|
|
SysTime timestamp,
|
|
ulong accountId,
|
|
AccountValueRecordType type,
|
|
long value,
|
|
Currency currency
|
|
) {
|
|
util.sqlite.update(
|
|
db,
|
|
"INSERT INTO account_value_record " ~
|
|
"(timestamp, account_id, type, value, currency) " ~
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
timestamp.toISOExtString(),
|
|
accountId,
|
|
type,
|
|
value,
|
|
currency.code
|
|
);
|
|
ulong valueRecordId = db.lastInsertRowid();
|
|
// Insert a history item that links to this value record.
|
|
ulong historyItemId = insertNewAccountHistoryItem(
|
|
db, timestamp, accountId, AccountHistoryItemType.ValueRecord);
|
|
util.sqlite.update(
|
|
db,
|
|
"INSERT INTO history_item_linked_value_record (item_id, value_record_id) VALUES (?, ?)",
|
|
historyItemId,
|
|
valueRecordId
|
|
);
|
|
return findById(accountId, valueRecordId).orElseThrow();
|
|
}
|
|
|
|
void linkAttachment(ulong valueRecordId, ulong attachmentId) {
|
|
util.sqlite.update(
|
|
db,
|
|
"INSERT INTO account_value_record_attachment (value_record_id, attachment_id) VALUES (?, ?)",
|
|
valueRecordId,
|
|
attachmentId
|
|
);
|
|
}
|
|
|
|
void deleteById(ulong accountId, ulong id) {
|
|
// First delete any associated history items:
|
|
util.sqlite.update(
|
|
db,
|
|
"DELETE FROM account_history_item WHERE id IN " ~
|
|
"(SELECT item_id FROM history_item_linked_value_record WHERE value_record_id = ?)",
|
|
id
|
|
);
|
|
util.sqlite.update(
|
|
db,
|
|
"DELETE FROM account_value_record WHERE account_id = ? AND id = ?",
|
|
accountId, id
|
|
);
|
|
}
|
|
|
|
Optional!AccountValueRecord findNearestByAccountIdBefore(ulong accountId, SysTime timestamp) {
|
|
const query = "SELECT * FROM account_value_record " ~
|
|
"WHERE account_id = ? AND timestamp < ? " ~
|
|
"ORDER BY timestamp DESC LIMIT 1";
|
|
return util.sqlite.findOne(
|
|
db,
|
|
query,
|
|
&parseValueRecord,
|
|
accountId, timestamp.toISOExtString()
|
|
);
|
|
}
|
|
|
|
Optional!AccountValueRecord findNearestByAccountIdAfter(ulong accountId, SysTime timestamp) {
|
|
const query = "SELECT * FROM account_value_record " ~
|
|
"WHERE account_id = ? AND timestamp > ? " ~
|
|
"ORDER BY timestamp ASC LIMIT 1";
|
|
return util.sqlite.findOne(
|
|
db,
|
|
query,
|
|
&parseValueRecord,
|
|
accountId, timestamp.toISOExtString()
|
|
);
|
|
}
|
|
|
|
static AccountValueRecord parseValueRecord(Row row) {
|
|
string typeStr = row.peek!(string, PeekMode.slice)(3);
|
|
AccountValueRecordType type;
|
|
if (typeStr == "BALANCE") {
|
|
type = AccountValueRecordType.BALANCE;
|
|
} else {
|
|
throw new Exception("Invalid account value record type: " ~ typeStr);
|
|
}
|
|
return AccountValueRecord(
|
|
row.peek!ulong(0),
|
|
parseISOTimestamp(row, 1),
|
|
row.peek!ulong(2),
|
|
type,
|
|
row.peek!long(4),
|
|
Currency.ofCode(row.peek!(string, PeekMode.slice)(5))
|
|
);
|
|
}
|
|
}
|
|
|
|
private ulong insertNewAccountHistoryItem(
|
|
ref Database db,
|
|
SysTime timestamp,
|
|
ulong accountId,
|
|
AccountHistoryItemType type
|
|
) {
|
|
util.sqlite.update(
|
|
db,
|
|
"INSERT INTO account_history_item (account_id, timestamp, type) VALUES (?, ?, ?)",
|
|
accountId,
|
|
timestamp.toISOExtString(),
|
|
type
|
|
);
|
|
return db.lastInsertRowid();
|
|
}
|