WIP: Add Drafts, Templates, and Recurring Transactions #45
|
|
@ -4,10 +4,10 @@
|
|||
"asdf": "0.8.0",
|
||||
"d2sqlite3": "1.0.0",
|
||||
"dxml": "0.4.5",
|
||||
"handy-http-data": "1.3.0",
|
||||
"handy-http-data": "1.3.2",
|
||||
"handy-http-handlers": "1.3.0",
|
||||
"handy-http-primitives": "1.8.1",
|
||||
"handy-http-starter": "1.7.0",
|
||||
"handy-http-primitives": "1.10.0",
|
||||
"handy-http-starter": "1.7.1",
|
||||
"handy-http-transport": "1.10.1",
|
||||
"handy-http-websockets": "1.2.0",
|
||||
"jwt4d": "0.0.2",
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
"mir-core": "1.7.4",
|
||||
"openssl": "3.3.4",
|
||||
"path-matcher": "1.2.0",
|
||||
"photon": "0.18.11",
|
||||
"photon": "0.18.12",
|
||||
"scheduled": "1.4.0",
|
||||
"secured": "3.0.0",
|
||||
"sharded-map": "2.7.0",
|
||||
|
|
|
|||
|
|
@ -5,12 +5,9 @@ import account.model;
|
|||
import attachment.data;
|
||||
import attachment.dto;
|
||||
import util.money;
|
||||
import util.data : serializeOptional;
|
||||
|
||||
/// The data the API provides for an Account entity.
|
||||
struct AccountResponse {
|
||||
import asdf : serdeTransformOut;
|
||||
|
||||
ulong id;
|
||||
string createdAt;
|
||||
bool archived;
|
||||
|
|
@ -19,7 +16,6 @@ struct AccountResponse {
|
|||
string name;
|
||||
Currency currency;
|
||||
string description;
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!long currentBalance;
|
||||
|
||||
static AccountResponse of(in Account account, Optional!long currentBalance) {
|
||||
|
|
@ -37,6 +33,14 @@ struct AccountResponse {
|
|||
}
|
||||
}
|
||||
|
||||
/// A limited set of account data, usually for use in other responses.
|
||||
struct SimpleAccountResponse {
|
||||
ulong id;
|
||||
string name;
|
||||
string type;
|
||||
string numberSuffix;
|
||||
}
|
||||
|
||||
// The data provided by a user to create a new account.
|
||||
struct AccountCreationPayload {
|
||||
string type;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ module analytics.data;
|
|||
|
||||
import std.datetime;
|
||||
import handy_http_primitives : Optional;
|
||||
import asdf : serdeTransformOut;
|
||||
|
||||
import util.money;
|
||||
import util.data;
|
||||
|
|
@ -36,7 +35,6 @@ struct CategorySpendData {
|
|||
ulong categoryId;
|
||||
string categoryName;
|
||||
string categoryColor;
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!ulong parentCategoryId;
|
||||
long amount;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
|
|||
publicHandler.registerHandlers!(auth.api_public);
|
||||
|
||||
// Dev endpoint for sample data: REMOVE BEFORE DEPLOYING!!!
|
||||
// h.map(HttpMethod.POST, "/sample-data", &sampleDataEndpoint);
|
||||
publicHandler.addMapping(HttpMethod.POST, "/api/sample-data", HttpRequestHandler.of(&sampleDataEndpoint));
|
||||
|
||||
// Authenticated endpoints:
|
||||
import auth.api;
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ interface ProfileDataSource {
|
|||
TransactionCategoryRepository getTransactionCategoryRepository();
|
||||
TransactionTagRepository getTransactionTagRepository();
|
||||
TransactionRepository getTransactionRepository();
|
||||
TransactionDraftRepository getTransactionDraftRepository();
|
||||
|
||||
AnalyticsRepository getAnalyticsRepository();
|
||||
|
||||
|
|
@ -93,6 +94,9 @@ version(unittest) {
|
|||
TransactionRepository getTransactionRepository() {
|
||||
throw new Exception("Not implemented");
|
||||
}
|
||||
TransactionDraftRepository getTransactionDraftRepository() {
|
||||
throw new Exception("Not implemented");
|
||||
}
|
||||
AnalyticsRepository getAnalyticsRepository() {
|
||||
throw new Exception("Not implemented");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ class SqlitePropertiesRepository : PropertiesRepository {
|
|||
}
|
||||
|
||||
private const SCHEMA = import("sql/schema.sql");
|
||||
private const uint SCHEMA_VERSION = 1;
|
||||
private const uint SCHEMA_VERSION = 2;
|
||||
private const SCHEMA_VERSION_PROPERTY = "database-schema-version";
|
||||
|
||||
/**
|
||||
|
|
@ -215,6 +215,7 @@ class SqliteProfileDataSource : ProfileDataSource {
|
|||
TransactionCategoryRepository transactionCategoryRepo;
|
||||
TransactionTagRepository transactionTagRepo;
|
||||
TransactionRepository transactionRepo;
|
||||
TransactionDraftRepository transactionDraftRepo;
|
||||
AnalyticsRepository analyticsRepo;
|
||||
|
||||
this(string path) {
|
||||
|
|
@ -297,6 +298,13 @@ class SqliteProfileDataSource : ProfileDataSource {
|
|||
return transactionRepo;
|
||||
}
|
||||
|
||||
TransactionDraftRepository getTransactionDraftRepository() {
|
||||
if (transactionDraftRepo is null) {
|
||||
transactionDraftRepo = new SqliteTransactionDraftRepository(db);
|
||||
}
|
||||
return transactionDraftRepo;
|
||||
}
|
||||
|
||||
AnalyticsRepository getAnalyticsRepository() {
|
||||
if (analyticsRepo is null) {
|
||||
analyticsRepo = new SqliteAnalyticsRepository(db);
|
||||
|
|
@ -322,7 +330,8 @@ class SqliteProfileDataSource : ProfileDataSource {
|
|||
if (currentVersion == SCHEMA_VERSION) return;
|
||||
|
||||
static const migrations = [
|
||||
import("sql/migrations/1.sql")
|
||||
import("sql/migrations/1.sql"),
|
||||
import("sql/migrations/2.sql")
|
||||
];
|
||||
static if (migrations.length != SCHEMA_VERSION) {
|
||||
static assert(false, "Schema version doesn't match the list of defined migrations.");
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ struct CategoryPayload {
|
|||
string name;
|
||||
string description;
|
||||
string color;
|
||||
Nullable!ulong parentId;
|
||||
Optional!ulong parentId;
|
||||
}
|
||||
|
||||
@PostMapping(PROFILE_PATH ~ "/categories")
|
||||
|
|
@ -215,3 +215,37 @@ void handleDeleteCategory(ref ServerHttpRequest request, ref ServerHttpResponse
|
|||
private ulong getCategoryId(in ServerHttpRequest request) {
|
||||
return getPathParamOrThrow!ulong(request, "categoryId");
|
||||
}
|
||||
|
||||
// Drafts & Templates
|
||||
|
||||
immutable DEFAULT_DRAFT_PAGE = PageRequest(1, 10, [Sort("txn.id", SortDir.DESC)]);
|
||||
|
||||
@GetMapping(PROFILE_PATH ~ "/transaction-drafts")
|
||||
void handleGetDrafts(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
PageRequest pr = PageRequest.parse(request, DEFAULT_DRAFT_PAGE);
|
||||
Page!TransactionDraftListItem page = getDrafts(ds, pr);
|
||||
writeJsonBody(response, page);
|
||||
}
|
||||
|
||||
@GetMapping(PROFILE_PATH ~ "/transaction-templates")
|
||||
void handleGetTemplates(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
PageRequest pr = PageRequest.parse(request, DEFAULT_DRAFT_PAGE);
|
||||
Page!TransactionDraftListItem page = getTemplates(ds, pr);
|
||||
writeJsonBody(response, page);
|
||||
}
|
||||
|
||||
@GetMapping(PROFILE_PATH ~ "/transaction-drafts/:draftId:ulong")
|
||||
@GetMapping(PROFILE_PATH ~ "/transaction-templates/:draftId:ulong")
|
||||
void handleGetDraft(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
TransactionDraftResponse draft = getDraft(ds, getDraftId(request));
|
||||
import asdf : serializeToJson;
|
||||
string jsonStr = serializeToJson(draft);
|
||||
response.writeBodyString(jsonStr, "application/json");
|
||||
}
|
||||
|
||||
private ulong getDraftId(in ServerHttpRequest request) {
|
||||
return getPathParamOrThrow!ulong(request, "draftId");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,3 +52,13 @@ interface TransactionRepository {
|
|||
TransactionDetail update(ulong transactionId, in AddTransactionPayload data);
|
||||
void deleteById(ulong id);
|
||||
}
|
||||
|
||||
interface TransactionDraftRepository {
|
||||
Page!TransactionDraftListItem findAllDrafts(in PageRequest pr);
|
||||
Page!TransactionDraftListItem findAllTemplates(in PageRequest pr);
|
||||
Optional!TransactionDraftResponse findById(ulong id);
|
||||
TransactionDraftResponse insert(in TransactionDraftPayload data);
|
||||
void linkAttachment(ulong draftId, ulong attachmentId);
|
||||
TransactionDraftResponse update(ulong draftId, in TransactionDraftPayload data);
|
||||
void deleteById(ulong id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
module transaction.data_impl_sqlite;
|
||||
|
||||
import handy_http_primitives : Optional, StringMultiValueMap;
|
||||
import handy_http_primitives : Optional, StringMultiValueMap, mapIfPresent, toOptional;
|
||||
import util.data;
|
||||
import std.datetime;
|
||||
import std.typecons;
|
||||
import d2sqlite3;
|
||||
|
|
@ -13,6 +14,8 @@ import util.money;
|
|||
import util.pagination;
|
||||
import util.data;
|
||||
import account.model;
|
||||
import account.dto;
|
||||
import attachment.dto;
|
||||
|
||||
class SqliteTransactionVendorRepository : TransactionVendorRepository {
|
||||
private Database db;
|
||||
|
|
@ -228,7 +231,7 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
|
|||
import std.typecons;
|
||||
return TransactionCategory(
|
||||
row.peek!ulong(0),
|
||||
toOptional(row.peek!(Nullable!ulong)(1)),
|
||||
row.parseOptional!ulong(1),
|
||||
row.peek!string(2),
|
||||
row.peek!string(3),
|
||||
row.peek!string(4)
|
||||
|
|
@ -283,12 +286,8 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
|
@ -328,10 +327,10 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
qb.conditions = [];
|
||||
qb.argBinders = [];
|
||||
addSelectsForTransactionsList(qb);
|
||||
qb.groupBy("txn.id");
|
||||
qb.where("txn.id IN (" ~ idsStr ~ ")");
|
||||
string query = qb.build() ~ "\n" ~ pr.toOrderClause();
|
||||
Statement stmt = db.prepare(query);
|
||||
auto results = parseListItems(stmt.execute());
|
||||
TransactionsListItem[] results = util.sqlite.findAll(db, query, &parseListItem);
|
||||
return Page!TransactionsListItem.of(results, pr, count);
|
||||
}
|
||||
|
||||
|
|
@ -370,7 +369,7 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
Optional!TransactionDetail findById(ulong id) {
|
||||
Optional!TransactionDetail item = util.sqlite.findOne(
|
||||
db,
|
||||
import("sql/get_transaction.sql"),
|
||||
import("sql/query/get_transaction.sql"),
|
||||
(row) {
|
||||
TransactionDetail item;
|
||||
item.id = row.peek!ulong(0);
|
||||
|
|
@ -383,43 +382,41 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
|
||||
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7);
|
||||
if (!vendorId.isNull) {
|
||||
item.vendor = Optional!(TransactionDetail.Vendor).of(
|
||||
TransactionDetail.Vendor(
|
||||
item.vendor = Optional!TransactionVendor.of(TransactionVendor(
|
||||
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(
|
||||
item.category = Optional!TransactionCategory.of(TransactionCategory(
|
||||
categoryId.get,
|
||||
row.peek!(Nullable!ulong)(11),
|
||||
row.parseOptional!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(
|
||||
item.creditedAccount = Optional!SimpleAccountResponse.of(
|
||||
SimpleAccountResponse(
|
||||
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(
|
||||
item.debitedAccount = Optional!SimpleAccountResponse.of(
|
||||
SimpleAccountResponse(
|
||||
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) {
|
||||
|
|
@ -435,23 +432,23 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
if (item.isNull) return item;
|
||||
item.value.lineItems = util.sqlite.findAll(
|
||||
db,
|
||||
import("sql/get_line_items.sql"),
|
||||
import("sql/query/get_line_items.sql"),
|
||||
(row) {
|
||||
TransactionDetail.LineItem li;
|
||||
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);
|
||||
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),
|
||||
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)
|
||||
)).toNullable;
|
||||
));
|
||||
}
|
||||
return li;
|
||||
},
|
||||
|
|
@ -470,8 +467,8 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
data.currencyCode,
|
||||
data.description,
|
||||
data.internalTransfer,
|
||||
data.vendorId,
|
||||
data.categoryId
|
||||
data.vendorId.toNullable(),
|
||||
data.categoryId.toNullable()
|
||||
);
|
||||
ulong transactionId = db.lastInsertRowid();
|
||||
insertLineItems(transactionId, data);
|
||||
|
|
@ -496,8 +493,8 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
data.currencyCode,
|
||||
data.description,
|
||||
data.internalTransfer,
|
||||
data.vendorId,
|
||||
data.categoryId,
|
||||
data.vendorId.toNullable(),
|
||||
data.categoryId.toNullable(),
|
||||
transactionId
|
||||
);
|
||||
// Re-write all line items:
|
||||
|
|
@ -521,97 +518,56 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[]);
|
||||
private static TransactionsListItem parseListItem(Row row) {
|
||||
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.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);
|
||||
// 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));
|
||||
Optional!ulong vendorId = row.parseOptional!ulong(7);
|
||||
if (vendorId) {
|
||||
item.vendor = SimpleVendorResponse(
|
||||
vendorId.value,
|
||||
row.peek!string(8)
|
||||
).toOptional;
|
||||
}
|
||||
// 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));
|
||||
Optional!ulong categoryId = row.parseOptional!ulong(9);
|
||||
if (categoryId) {
|
||||
item.category = SimpleCategoryResponse(
|
||||
categoryId.value,
|
||||
row.peek!string(10),
|
||||
row.peek!string(11)
|
||||
).toOptional;
|
||||
}
|
||||
// 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));
|
||||
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;
|
||||
}
|
||||
// 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));
|
||||
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);
|
||||
}
|
||||
|
||||
// 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[];
|
||||
return item;
|
||||
}
|
||||
|
||||
private void insertLineItems(ulong transactionId, in AddTransactionPayload data) {
|
||||
|
|
@ -624,7 +580,7 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
lineItem.valuePerItem,
|
||||
lineItem.quantity,
|
||||
lineItem.description,
|
||||
lineItem.categoryId
|
||||
lineItem.categoryId.toNullable()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -664,6 +620,168 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
.select("account_debit.name")
|
||||
.select("account_debit.type")
|
||||
.select("account_debit.number_suffix")
|
||||
.select("tags.tag");
|
||||
.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) {
|
||||
QueryBuilder qb = getBuilderForDraftsList();
|
||||
addSelectsForDraftsList(qb);
|
||||
qb.where("draft.id = ?");
|
||||
string query = qb.build();
|
||||
// return util.sqlite.findOne(db, query, &parseDraft, id);
|
||||
// TODO!
|
||||
return Optional!TransactionDraftResponse.empty();
|
||||
}
|
||||
|
||||
TransactionDraftResponse insert(in TransactionDraftPayload data) {
|
||||
// TODO
|
||||
return TransactionDraftResponse.init;
|
||||
}
|
||||
|
||||
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) {
|
||||
// TODO
|
||||
return TransactionDraftResponse.init;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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("string_agg(tags.tag, ',' ORDER BY tags.tag ASC)");
|
||||
}
|
||||
|
||||
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(",");
|
||||
}
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,36 @@
|
|||
module transaction.dto;
|
||||
|
||||
import handy_http_primitives : Optional;
|
||||
import asdf : serdeTransformOut;
|
||||
import std.typecons;
|
||||
|
||||
import transaction.model : TransactionCategory;
|
||||
import transaction.model : TransactionCategory, TransactionVendor;
|
||||
import attachment.dto;
|
||||
import account.dto;
|
||||
import util.data;
|
||||
import util.money;
|
||||
|
||||
/// A simple selection of vendor data to be included in other responses.
|
||||
struct SimpleVendorResponse {
|
||||
ulong id;
|
||||
string name;
|
||||
}
|
||||
|
||||
/// A simple selection of category data to be included in other responses.
|
||||
struct SimpleCategoryResponse {
|
||||
ulong id;
|
||||
string name;
|
||||
string color;
|
||||
}
|
||||
|
||||
/// Response data for a transaction's line item.
|
||||
struct TransactionLineItemResponse {
|
||||
uint idx;
|
||||
long valuePerItem;
|
||||
ulong quantity;
|
||||
string description;
|
||||
Optional!TransactionCategory category;
|
||||
}
|
||||
|
||||
/// The transaction data provided when a list of transactions is requested.
|
||||
struct TransactionsListItem {
|
||||
ulong id;
|
||||
|
|
@ -18,33 +40,11 @@ struct TransactionsListItem {
|
|||
Currency currency;
|
||||
string description;
|
||||
bool internalTransfer;
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!Vendor vendor;
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!Category category;
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!Account creditedAccount;
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!Account debitedAccount;
|
||||
Optional!SimpleVendorResponse vendor;
|
||||
Optional!SimpleCategoryResponse category;
|
||||
Optional!SimpleAccountResponse creditedAccount;
|
||||
Optional!SimpleAccountResponse debitedAccount;
|
||||
string[] tags;
|
||||
|
||||
static struct Account {
|
||||
ulong id;
|
||||
string name;
|
||||
string type;
|
||||
string numberSuffix;
|
||||
}
|
||||
|
||||
static struct Vendor {
|
||||
ulong id;
|
||||
string name;
|
||||
}
|
||||
|
||||
static struct Category {
|
||||
ulong id;
|
||||
string name;
|
||||
string color;
|
||||
}
|
||||
}
|
||||
|
||||
/// Transaction data provided when fetching a single transaction.
|
||||
|
|
@ -56,42 +56,13 @@ struct TransactionDetail {
|
|||
Currency currency;
|
||||
string description;
|
||||
bool internalTransfer;
|
||||
Nullable!Vendor vendor;
|
||||
Nullable!Category category;
|
||||
Nullable!Account creditedAccount;
|
||||
Nullable!Account debitedAccount;
|
||||
Optional!TransactionVendor vendor;
|
||||
Optional!TransactionCategory category;
|
||||
Optional!SimpleAccountResponse creditedAccount;
|
||||
Optional!SimpleAccountResponse debitedAccount;
|
||||
string[] tags;
|
||||
LineItem[] lineItems;
|
||||
TransactionLineItemResponse[] lineItems;
|
||||
AttachmentResponse[] attachments;
|
||||
|
||||
static struct Vendor {
|
||||
ulong id;
|
||||
string name;
|
||||
string description;
|
||||
}
|
||||
|
||||
static struct Category {
|
||||
ulong id;
|
||||
Nullable!ulong parentId;
|
||||
string name;
|
||||
string description;
|
||||
string color;
|
||||
}
|
||||
|
||||
static struct LineItem {
|
||||
uint idx;
|
||||
long valuePerItem;
|
||||
ulong quantity;
|
||||
string description;
|
||||
Nullable!Category category;
|
||||
}
|
||||
|
||||
static struct Account {
|
||||
ulong id;
|
||||
string name;
|
||||
string type;
|
||||
string numberSuffix;
|
||||
}
|
||||
}
|
||||
|
||||
/// Data provided when a new transaction is added by a user.
|
||||
|
|
@ -101,26 +72,25 @@ struct AddTransactionPayload {
|
|||
string currencyCode;
|
||||
string description;
|
||||
bool internalTransfer;
|
||||
Nullable!ulong vendorId;
|
||||
Nullable!ulong categoryId;
|
||||
Nullable!ulong creditedAccountId;
|
||||
Nullable!ulong debitedAccountId;
|
||||
Optional!ulong vendorId;
|
||||
Optional!ulong categoryId;
|
||||
Optional!ulong creditedAccountId;
|
||||
Optional!ulong debitedAccountId;
|
||||
string[] tags;
|
||||
LineItem[] lineItems;
|
||||
LineItemPayload[] lineItems;
|
||||
ulong[] attachmentIdsToRemove;
|
||||
|
||||
static struct LineItem {
|
||||
static struct LineItemPayload {
|
||||
long valuePerItem;
|
||||
ulong quantity;
|
||||
string description;
|
||||
Nullable!ulong categoryId;
|
||||
Optional!ulong categoryId;
|
||||
}
|
||||
}
|
||||
|
||||
/// Structure for depicting an entire hierarchical tree structure of categories.
|
||||
struct TransactionCategoryTree {
|
||||
ulong id;
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!ulong parentId;
|
||||
string name;
|
||||
string description;
|
||||
|
|
@ -131,7 +101,6 @@ struct TransactionCategoryTree {
|
|||
|
||||
struct TransactionCategoryResponse {
|
||||
ulong id;
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!ulong parentId;
|
||||
string name;
|
||||
string description;
|
||||
|
|
@ -170,3 +139,48 @@ struct AggregateTransactionData {
|
|||
}
|
||||
CurrencyData[] currencies;
|
||||
}
|
||||
|
||||
/// Response data for drafts as they'd appear in a list, with less data.
|
||||
struct TransactionDraftListItem {
|
||||
ulong id;
|
||||
string addedAt;
|
||||
Optional!string templateName;
|
||||
Optional!string timestamp;
|
||||
Optional!ulong amount;
|
||||
Optional!Currency currency;
|
||||
Optional!string description;
|
||||
Optional!bool internalTransfer;
|
||||
|
||||
Optional!SimpleVendorResponse vendor;
|
||||
Optional!SimpleCategoryResponse category;
|
||||
Optional!SimpleAccountResponse creditedAccount;
|
||||
Optional!SimpleAccountResponse debitedAccount;
|
||||
|
||||
string[] tags;
|
||||
}
|
||||
|
||||
/// Data representing a draft (or template).
|
||||
struct TransactionDraftResponse {
|
||||
ulong id;
|
||||
string addedAt;
|
||||
Optional!string templateName;
|
||||
Optional!string timestamp;
|
||||
Optional!ulong amount;
|
||||
Optional!Currency currency;
|
||||
Optional!string description;
|
||||
Optional!bool internalTransfer;
|
||||
|
||||
Optional!SimpleVendorResponse vendor;
|
||||
Optional!SimpleCategoryResponse category;
|
||||
Optional!SimpleAccountResponse creditedAccount;
|
||||
Optional!SimpleAccountResponse debitedAccount;
|
||||
|
||||
string[] tags;
|
||||
TransactionLineItemResponse[] lineItems;
|
||||
AttachmentResponse[] attachments;
|
||||
}
|
||||
|
||||
/// Data provided by users when creating or updating drafts.
|
||||
struct TransactionDraftPayload {
|
||||
// TODO.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import std.datetime;
|
|||
import util.money;
|
||||
|
||||
struct TransactionVendor {
|
||||
immutable ulong id;
|
||||
immutable string name;
|
||||
immutable string description;
|
||||
ulong id;
|
||||
string name;
|
||||
string description;
|
||||
}
|
||||
|
||||
struct TransactionCategory {
|
||||
|
|
@ -20,23 +20,23 @@ struct TransactionCategory {
|
|||
}
|
||||
|
||||
struct Transaction {
|
||||
immutable ulong id;
|
||||
ulong id;
|
||||
/// The time at which the transaction happened.
|
||||
immutable SysTime timestamp;
|
||||
SysTime timestamp;
|
||||
/// The time at which the transaction entity was saved.
|
||||
immutable SysTime addedAt;
|
||||
immutable ulong amount;
|
||||
immutable Currency currency;
|
||||
immutable string description;
|
||||
immutable Optional!ulong vendorId;
|
||||
immutable Optional!ulong categoryId;
|
||||
SysTime addedAt;
|
||||
ulong amount;
|
||||
Currency currency;
|
||||
string description;
|
||||
Optional!ulong vendorId;
|
||||
Optional!ulong categoryId;
|
||||
}
|
||||
|
||||
struct TransactionLineItem {
|
||||
immutable ulong transactionId;
|
||||
immutable uint idx;
|
||||
immutable long valuePerItem;
|
||||
immutable ulong quantity;
|
||||
immutable string description;
|
||||
immutable Optional!ulong categoryId;
|
||||
ulong transactionId;
|
||||
uint idx;
|
||||
long valuePerItem;
|
||||
ulong quantity;
|
||||
string description;
|
||||
Optional!ulong categoryId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,9 +22,8 @@ import attachment.dto;
|
|||
// Transactions Services
|
||||
|
||||
Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest pageRequest) {
|
||||
Page!TransactionsListItem page = ds.getTransactionRepository()
|
||||
return ds.getTransactionRepository()
|
||||
.findAll(pageRequest);
|
||||
return page;
|
||||
}
|
||||
|
||||
TransactionDetail getTransaction(ProfileDataSource ds, ulong transactionId) {
|
||||
|
|
@ -53,20 +52,20 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload
|
|||
ds.doTransaction(() {
|
||||
TransactionDetail txn = txnRepo.insert(payload);
|
||||
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
||||
if (!payload.creditedAccountId.isNull) {
|
||||
if (payload.creditedAccountId) {
|
||||
jeRepo.insert(
|
||||
timestamp,
|
||||
payload.creditedAccountId.get,
|
||||
payload.creditedAccountId.value,
|
||||
txn.id,
|
||||
txn.amount,
|
||||
AccountJournalEntryType.CREDIT,
|
||||
txn.currency
|
||||
);
|
||||
}
|
||||
if (!payload.debitedAccountId.isNull) {
|
||||
if (payload.debitedAccountId) {
|
||||
jeRepo.insert(
|
||||
timestamp,
|
||||
payload.debitedAccountId.get,
|
||||
payload.debitedAccountId.value,
|
||||
txn.id,
|
||||
txn.amount,
|
||||
AccountJournalEntryType.DEBIT,
|
||||
|
|
@ -120,45 +119,45 @@ private void updateLinkedAccountJournalEntries(
|
|||
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
||||
const bool amountOrCurrencyChanged = prev.amount != curr.amount || prev.currency.code != curr.currency.code;
|
||||
const bool updateCreditEntry = amountOrCurrencyChanged || (
|
||||
(prev.creditedAccount.isNull && !payload.creditedAccountId.isNull) ||
|
||||
(!prev.creditedAccount.isNull && payload.creditedAccountId.isNull) ||
|
||||
(!prev.creditedAccount && payload.creditedAccountId) ||
|
||||
(prev.creditedAccount && !payload.creditedAccountId) ||
|
||||
(
|
||||
!prev.creditedAccount.isNull &&
|
||||
!payload.creditedAccountId.isNull &&
|
||||
prev.creditedAccount.get.id != payload.creditedAccountId.get
|
||||
prev.creditedAccount &&
|
||||
payload.creditedAccountId &&
|
||||
prev.creditedAccount.value.id != payload.creditedAccountId.value
|
||||
)
|
||||
);
|
||||
const bool updateDebitEntry = amountOrCurrencyChanged || (
|
||||
(prev.debitedAccount.isNull && !payload.creditedAccountId.isNull) ||
|
||||
(!prev.debitedAccount.isNull && payload.debitedAccountId.isNull) ||
|
||||
(!prev.debitedAccount && payload.creditedAccountId) ||
|
||||
(prev.debitedAccount && !payload.debitedAccountId) ||
|
||||
(
|
||||
!prev.debitedAccount.isNull &&
|
||||
!payload.debitedAccountId.isNull &&
|
||||
prev.debitedAccount.get.id != payload.debitedAccountId.get
|
||||
prev.debitedAccount &&
|
||||
payload.debitedAccountId &&
|
||||
prev.debitedAccount.value.id != payload.debitedAccountId.value
|
||||
)
|
||||
);
|
||||
|
||||
// Update journal entries if necessary:
|
||||
if (updateCreditEntry && !prev.creditedAccount.isNull) {
|
||||
jeRepo.deleteByAccountIdAndTransactionId(prev.creditedAccount.get.id, prev.id);
|
||||
if (updateCreditEntry && prev.creditedAccount) {
|
||||
jeRepo.deleteByAccountIdAndTransactionId(prev.creditedAccount.value.id, prev.id);
|
||||
}
|
||||
if (updateCreditEntry && !payload.creditedAccountId.isNull) {
|
||||
if (updateCreditEntry && payload.creditedAccountId) {
|
||||
jeRepo.insert(
|
||||
timestamp,
|
||||
payload.creditedAccountId.get,
|
||||
payload.creditedAccountId.value,
|
||||
curr.id,
|
||||
curr.amount,
|
||||
AccountJournalEntryType.CREDIT,
|
||||
curr.currency
|
||||
);
|
||||
}
|
||||
if (updateDebitEntry && !prev.debitedAccount.isNull) {
|
||||
jeRepo.deleteByAccountIdAndTransactionId(prev.debitedAccount.get.id, prev.id);
|
||||
if (updateDebitEntry && prev.debitedAccount) {
|
||||
jeRepo.deleteByAccountIdAndTransactionId(prev.debitedAccount.value.id, prev.id);
|
||||
}
|
||||
if (updateDebitEntry && !payload.debitedAccountId.isNull) {
|
||||
if (updateDebitEntry && payload.debitedAccountId) {
|
||||
jeRepo.insert(
|
||||
timestamp,
|
||||
payload.debitedAccountId.get,
|
||||
payload.debitedAccountId.value,
|
||||
curr.id,
|
||||
curr.amount,
|
||||
AccountJournalEntryType.DEBIT,
|
||||
|
|
@ -187,13 +186,13 @@ private void validateTransactionPayload(
|
|||
AccountRepository accountRepo,
|
||||
in AddTransactionPayload payload
|
||||
) {
|
||||
if (payload.creditedAccountId.isNull && payload.debitedAccountId.isNull) {
|
||||
if (!payload.creditedAccountId && !payload.debitedAccountId) {
|
||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "At least one account must be linked.");
|
||||
}
|
||||
if (
|
||||
!payload.creditedAccountId.isNull &&
|
||||
!payload.debitedAccountId.isNull &&
|
||||
payload.creditedAccountId.get == payload.debitedAccountId.get
|
||||
payload.creditedAccountId &&
|
||||
payload.debitedAccountId &&
|
||||
payload.creditedAccountId.value == payload.debitedAccountId.value
|
||||
) {
|
||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot link the same account as both credit and debit.");
|
||||
}
|
||||
|
|
@ -210,16 +209,16 @@ private void validateTransactionPayload(
|
|||
if (timestamp > now) {
|
||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot create transaction in the future.");
|
||||
}
|
||||
if (!payload.vendorId.isNull && !vendorRepo.existsById(payload.vendorId.get)) {
|
||||
if (payload.vendorId && !vendorRepo.existsById(payload.vendorId.value)) {
|
||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Vendor doesn't exist.");
|
||||
}
|
||||
if (!payload.categoryId.isNull && !categoryRepo.existsById(payload.categoryId.get)) {
|
||||
if (payload.categoryId && !categoryRepo.existsById(payload.categoryId.value)) {
|
||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Category doesn't exist.");
|
||||
}
|
||||
if (!payload.creditedAccountId.isNull && !accountRepo.existsById(payload.creditedAccountId.get)) {
|
||||
if (payload.creditedAccountId && !accountRepo.existsById(payload.creditedAccountId.value)) {
|
||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Credited account doesn't exist.");
|
||||
}
|
||||
if (!payload.debitedAccountId.isNull && !accountRepo.existsById(payload.debitedAccountId.get)) {
|
||||
if (payload.debitedAccountId && !accountRepo.existsById(payload.debitedAccountId.value)) {
|
||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Debited account doesn't exist.");
|
||||
}
|
||||
foreach (tag; payload.tags) {
|
||||
|
|
@ -232,7 +231,7 @@ private void validateTransactionPayload(
|
|||
if (payload.lineItems.length > 0) {
|
||||
long lineItemsTotal = 0;
|
||||
foreach (lineItem; payload.lineItems) {
|
||||
if (!lineItem.categoryId.isNull && !categoryRepo.existsById(lineItem.categoryId.get)) {
|
||||
if (lineItem.categoryId && !categoryRepo.existsById(lineItem.categoryId.value)) {
|
||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's category doesn't exist.");
|
||||
}
|
||||
if (lineItem.quantity == 0) {
|
||||
|
|
@ -454,7 +453,7 @@ TransactionCategoryResponse createCategory(ProfileDataSource ds, in CategoryPayl
|
|||
if (repo.existsByName(payload.name)) {
|
||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Name already in use.");
|
||||
}
|
||||
if (!payload.parentId.isNull && !repo.existsById(payload.parentId.get)) {
|
||||
if (payload.parentId && !repo.existsById(payload.parentId.value)) {
|
||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id.");
|
||||
}
|
||||
import std.regex;
|
||||
|
|
@ -463,7 +462,7 @@ TransactionCategoryResponse createCategory(ProfileDataSource ds, in CategoryPayl
|
|||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid color hex string.");
|
||||
}
|
||||
auto category = repo.insert(
|
||||
toOptional(payload.parentId),
|
||||
payload.parentId,
|
||||
payload.name,
|
||||
payload.description,
|
||||
payload.color
|
||||
|
|
@ -481,7 +480,7 @@ TransactionCategoryResponse updateCategory(ProfileDataSource ds, ulong categoryI
|
|||
if (payload.name != prev.name && repo.existsByName(payload.name)) {
|
||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Name already in use.");
|
||||
}
|
||||
if (!payload.parentId.isNull && !repo.existsById(payload.parentId.get)) {
|
||||
if (payload.parentId && !repo.existsById(payload.parentId.value)) {
|
||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id.");
|
||||
}
|
||||
TransactionCategory curr = repo.updateById(
|
||||
|
|
@ -489,7 +488,7 @@ TransactionCategoryResponse updateCategory(ProfileDataSource ds, ulong categoryI
|
|||
payload.name,
|
||||
payload.description,
|
||||
payload.color,
|
||||
toOptional!ulong(payload.parentId)
|
||||
payload.parentId
|
||||
);
|
||||
return TransactionCategoryResponse.of(curr);
|
||||
}
|
||||
|
|
@ -497,3 +496,21 @@ TransactionCategoryResponse updateCategory(ProfileDataSource ds, ulong categoryI
|
|||
void deleteCategory(ProfileDataSource ds, ulong categoryId) {
|
||||
ds.getTransactionCategoryRepository().deleteById(categoryId);
|
||||
}
|
||||
|
||||
// Draft services
|
||||
|
||||
Page!TransactionDraftListItem getDrafts(ProfileDataSource ds, in PageRequest pr) {
|
||||
return ds.getTransactionDraftRepository()
|
||||
.findAllDrafts(pr);
|
||||
}
|
||||
|
||||
Page!TransactionDraftListItem getTemplates(ProfileDataSource ds, in PageRequest pr) {
|
||||
return ds.getTransactionDraftRepository()
|
||||
.findAllTemplates(pr);
|
||||
}
|
||||
|
||||
TransactionDraftResponse getDraft(ProfileDataSource ds, ulong draftId) {
|
||||
return ds.getTransactionDraftRepository()
|
||||
.findById(draftId)
|
||||
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,13 +21,6 @@ Nullable!T toNullable(T)(Optional!T value) {
|
|||
}
|
||||
}
|
||||
|
||||
auto serializeOptional(T)(Optional!T value) {
|
||||
if (value.isNull) {
|
||||
return Nullable!T();
|
||||
}
|
||||
return Nullable!T(value.value);
|
||||
}
|
||||
|
||||
T getPathParamOrThrow(T)(in ServerHttpRequest req, string name) {
|
||||
import handy_http_handlers.path_handler;
|
||||
import std.conv : to, ConvException;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
module util.sample_data;
|
||||
|
||||
import slf4d;
|
||||
import handy_http_primitives : Optional, mapIfPresent;
|
||||
import handy_http_primitives : Optional, mapIfPresent, toOptional;
|
||||
|
||||
import auth;
|
||||
import profile;
|
||||
|
|
@ -143,10 +143,10 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
|||
AddTransactionPayload data;
|
||||
data.timestamp = timestamp.toISOExtString();
|
||||
if (uniform01() < 0.7) {
|
||||
data.vendorId = Optional!ulong.of(choice(vendors).id).toNullable;
|
||||
data.vendorId = Optional!ulong.of(choice(vendors).id);
|
||||
}
|
||||
if (uniform01() < 0.8) {
|
||||
data.categoryId = Optional!ulong.of(choice(categories).id).toNullable;
|
||||
data.categoryId = Optional!ulong.of(choice(categories).id);
|
||||
}
|
||||
|
||||
// Randomly choose an account to credit / debit the transaction to.
|
||||
|
|
@ -162,11 +162,11 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
|||
}
|
||||
}
|
||||
if (uniform01() < 0.5) {
|
||||
data.creditedAccountId = Optional!ulong.of(primaryAccount.id).toNullable;
|
||||
if (secondaryAccountId) data.debitedAccountId = secondaryAccountId.toNullable;
|
||||
data.creditedAccountId = Optional!ulong.of(primaryAccount.id);
|
||||
if (secondaryAccountId) data.debitedAccountId = secondaryAccountId;
|
||||
} else {
|
||||
data.debitedAccountId = Optional!ulong.of(primaryAccount.id).toNullable;
|
||||
if (secondaryAccountId) data.creditedAccountId = secondaryAccountId.toNullable;
|
||||
data.debitedAccountId = Optional!ulong.of(primaryAccount.id);
|
||||
if (secondaryAccountId) data.creditedAccountId = secondaryAccountId;
|
||||
}
|
||||
|
||||
// Randomly choose some tags to add.
|
||||
|
|
@ -185,25 +185,25 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
|||
if (uniform01 < 0.5) {
|
||||
long lineItemTotal = 0;
|
||||
foreach (n; 1..uniform(1, 20)) {
|
||||
AddTransactionPayload.LineItem item;
|
||||
AddTransactionPayload.LineItemPayload item;
|
||||
item.valuePerItem = uniform(1, 10_000);
|
||||
item.quantity = uniform(1, 5);
|
||||
lineItemTotal += item.quantity * item.valuePerItem;
|
||||
item.description = "Sample item " ~ n.to!string;
|
||||
if (uniform01 < 0.5) {
|
||||
TransactionCategory category = choice(categories);
|
||||
item.categoryId = category.id;
|
||||
item.categoryId = Optional!ulong.of(category.id);
|
||||
}
|
||||
data.lineItems ~= item;
|
||||
}
|
||||
long diff = data.amount - lineItemTotal;
|
||||
// Add one final line item that adds up to the transaction total.
|
||||
if (diff != 0) {
|
||||
data.lineItems ~= AddTransactionPayload.LineItem(
|
||||
data.lineItems ~= AddTransactionPayload.LineItemPayload(
|
||||
diff,
|
||||
1,
|
||||
"Last item which reconciles line items total with transaction amount.",
|
||||
Nullable!ulong.init
|
||||
Optional!ulong.empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,11 +192,35 @@ immutable(ubyte[]) parseBlob(Row row, size_t idx) {
|
|||
return row.peek!(ubyte[], PeekMode.slice)(idx).idup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an Optional primitive value from a result row.
|
||||
* Params:
|
||||
* row = The row to read from.
|
||||
* idx = The column index in the row to read.
|
||||
* Returns: An optional containing the data, if present.
|
||||
*/
|
||||
Optional!T parseOptional(T, PeekMode mode = PeekMode.copy)(Row row, size_t idx) {
|
||||
import std.typecons : Nullable;
|
||||
import std.traits : isSomeString;
|
||||
static if (isSomeString!T) {
|
||||
Nullable!T n = row.peek!(Nullable!T, mode)(idx);
|
||||
} else {
|
||||
Nullable!T n = row.peek!(Nullable!T)(idx);
|
||||
}
|
||||
if (n.isNull) return Optional!(T).empty();
|
||||
static if (isSomeString!T) {
|
||||
// If the string value is null, return empty as well.
|
||||
if (n.get() is null) return Optional!(T).empty();
|
||||
}
|
||||
return Optional!(T).of(n.get());
|
||||
}
|
||||
|
||||
struct QueryBuilder {
|
||||
string fromTable;
|
||||
string[] selections;
|
||||
string[] joins;
|
||||
string[] conditions;
|
||||
string[] groupings;
|
||||
void delegate(ref Statement, ref int)[] argBinders;
|
||||
|
||||
this(string fromTable) {
|
||||
|
|
@ -223,6 +247,11 @@ struct QueryBuilder {
|
|||
return this;
|
||||
}
|
||||
|
||||
ref groupBy(string grouping) {
|
||||
groupings ~= grouping;
|
||||
return this;
|
||||
}
|
||||
|
||||
string build() const {
|
||||
import std.algorithm : map;
|
||||
import std.string : join;
|
||||
|
|
@ -240,6 +269,10 @@ struct QueryBuilder {
|
|||
app ~= "\nWHERE\n";
|
||||
app ~= conditions.map!(s => " " ~ s).join(" AND\n");
|
||||
}
|
||||
if (groupings.length > 0) {
|
||||
app ~= "\nGROUP BY\n";
|
||||
app ~= groupings.map!(s => " " ~ s).join(",\n");
|
||||
}
|
||||
return app[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
CREATE TABLE transaction_draft (
|
||||
id INTEGER PRIMARY KEY,
|
||||
added_at TEXT NOT NULL,
|
||||
template_name TEXT,
|
||||
timestamp TEXT,
|
||||
amount INTEGER,
|
||||
currency TEXT,
|
||||
description TEXT,
|
||||
internal_transfer BOOLEAN DEFAULT FALSE,
|
||||
vendor_id INTEGER,
|
||||
category_id INTEGER,
|
||||
credited_account_id INTEGER,
|
||||
debited_account_id INTEGER,
|
||||
CONSTRAINT fk_transaction_draft_vendor
|
||||
FOREIGN KEY (vendor_id) REFERENCES transaction_vendor(id)
|
||||
ON UPDATE CASCADE ON DELETE SET NULL,
|
||||
CONSTRAINT fk_transaction_draft_category
|
||||
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
|
||||
ON UPDATE CASCADE ON DELETE SET NULL,
|
||||
CONSTRAINT fk_transaction_draft_credited_account
|
||||
FOREIGN KEY (credited_account_id) REFERENCES account(id)
|
||||
ON UPDATE CASCADE ON DELETE SET NULL,
|
||||
CONSTRAINT fk_transaction_draft_debited_account
|
||||
FOREIGN KEY (debited_account_id) REFERENCES account(id)
|
||||
ON UPDATE CASCADE ON DELETE SET NULL,
|
||||
CONSTRAINT ck_transaction_amount_positive
|
||||
CHECK (amount IS NULL OR amount > 0)
|
||||
);
|
||||
|
||||
CREATE TABLE transaction_draft_tag (
|
||||
draft_id INTEGER NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
CONSTRAINT pk_transaction_draft_tag PRIMARY KEY (draft_id, tag),
|
||||
CONSTRAINT fk_transaction_draft_tag_draft
|
||||
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE transaction_draft_attachment (
|
||||
draft_id INTEGER NOT NULL,
|
||||
attachment_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (draft_id, attachment_id),
|
||||
CONSTRAINT fk_transaction_draft_attachment_transaction
|
||||
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT fk_transaction_draft_attachment_attachment
|
||||
FOREIGN KEY (attachment_id) REFERENCES attachment(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE transaction_draft_line_item (
|
||||
draft_id INTEGER NOT NULL,
|
||||
idx INTEGER NOT NULL DEFAULT 0,
|
||||
value_per_item INTEGER NOT NULL,
|
||||
quantity INTEGER NOT NULL DEFAULT 1,
|
||||
description TEXT NOT NULL,
|
||||
category_id INTEGER,
|
||||
CONSTRAINT pk_transaction_draft_line_item PRIMARY KEY (draft_id, idx),
|
||||
CONSTRAINT fk_transaction_draft_line_item_transaction
|
||||
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT fk_transaction_draft_line_item_category
|
||||
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
|
||||
ON UPDATE CASCADE ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE recurring_transaction (
|
||||
id INTEGER PRIMARY KEY,
|
||||
draft_id INTEGER NOT NULL,
|
||||
schedule_expr TEXT NOT NULL,
|
||||
CONSTRAINT fk_recurring_transaction_draft
|
||||
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
|
@ -3,11 +3,13 @@ i.idx,
|
|||
i.value_per_item,
|
||||
i.quantity,
|
||||
i.description,
|
||||
|
||||
i.category_id,
|
||||
category.parent_id,
|
||||
category.name,
|
||||
category.description,
|
||||
category.color
|
||||
|
||||
FROM transaction_line_item i
|
||||
LEFT JOIN transaction_category category
|
||||
ON category.id = i.category_id
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
SELECT
|
||||
txn.id,
|
||||
txn.timestamp,
|
||||
txn.added_at,
|
||||
txn.amount,
|
||||
txn.currency,
|
||||
txn.description,
|
||||
txn.internal_transfer,
|
||||
|
||||
txn.vendor_id,
|
||||
vendor.name,
|
||||
|
||||
txn.category_id,
|
||||
category.name,
|
||||
category.color,
|
||||
|
||||
account_credit.id,
|
||||
account_credit.name,
|
||||
account_credit.type,
|
||||
account_credit.number_suffix,
|
||||
|
||||
account_debit.id,
|
||||
account_debit.name,
|
||||
account_debit.type,
|
||||
account_debit.number_suffix,
|
||||
|
||||
GROUP_CONCAT(tags.tag)
|
||||
FROM "transaction" txn
|
||||
LEFT JOIN transaction_vendor vendor ON vendor.id = txn.vendor_id
|
||||
LEFT JOIN transaction_category category ON category.id = txn.category_id
|
||||
LEFT JOIN account_journal_entry j_credit
|
||||
ON j_credit.transaction_id = txn.id AND UPPER(j_credit.type) = 'CREDIT'
|
||||
LEFT JOIN account account_credit ON account_credit.id = j_credit.account_id
|
||||
LEFT JOIN account_journal_entry j_debit
|
||||
ON j_debit.transaction_id = txn.id AND UPPER(j_debit.type) = 'DEBIT'
|
||||
LEFT JOIN account account_debit ON account_debit.id = j_debit.account_id
|
||||
LEFT JOIN transaction_tag tags ON tags.transaction_id = txn.id
|
||||
GROUP BY txn.id
|
||||
|
|
@ -1,4 +1,9 @@
|
|||
-- This schema is included at compile-time into source/profile/data_impl_sqlite.d SqliteProfileDataSource
|
||||
-- This schema is included at compile-time into
|
||||
-- source/profile/data_impl_sqlite.d SqliteProfileDataSource
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- This is the full current schema for Finnow's database. Tables and statements
|
||||
-- are defined in the order in which they're executed to build a new database
|
||||
-- for a newly-created profile.
|
||||
|
||||
-- Basic/Utility Entities
|
||||
|
||||
|
|
@ -218,3 +223,80 @@ CREATE TABLE history_item_linked_journal_entry (
|
|||
FOREIGN KEY (journal_entry_id) REFERENCES account_journal_entry(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Drafts / Templates / Recurring Transactions
|
||||
|
||||
CREATE TABLE transaction_draft (
|
||||
id INTEGER PRIMARY KEY,
|
||||
added_at TEXT NOT NULL,
|
||||
template_name TEXT,
|
||||
timestamp TEXT,
|
||||
amount INTEGER,
|
||||
currency TEXT,
|
||||
description TEXT,
|
||||
internal_transfer BOOLEAN DEFAULT FALSE,
|
||||
vendor_id INTEGER,
|
||||
category_id INTEGER,
|
||||
credited_account_id INTEGER,
|
||||
debited_account_id INTEGER,
|
||||
CONSTRAINT fk_transaction_draft_vendor
|
||||
FOREIGN KEY (vendor_id) REFERENCES transaction_vendor(id)
|
||||
ON UPDATE CASCADE ON DELETE SET NULL,
|
||||
CONSTRAINT fk_transaction_draft_category
|
||||
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
|
||||
ON UPDATE CASCADE ON DELETE SET NULL,
|
||||
CONSTRAINT fk_transaction_draft_credited_account
|
||||
FOREIGN KEY (credited_account_id) REFERENCES account(id)
|
||||
ON UPDATE CASCADE ON DELETE SET NULL,
|
||||
CONSTRAINT fk_transaction_draft_debited_account
|
||||
FOREIGN KEY (debited_account_id) REFERENCES account(id)
|
||||
ON UPDATE CASCADE ON DELETE SET NULL,
|
||||
CONSTRAINT ck_transaction_amount_positive
|
||||
CHECK (amount IS NULL OR amount > 0)
|
||||
);
|
||||
|
||||
CREATE TABLE transaction_draft_tag (
|
||||
draft_id INTEGER NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
CONSTRAINT pk_transaction_draft_tag PRIMARY KEY (draft_id, tag),
|
||||
CONSTRAINT fk_transaction_draft_tag_draft
|
||||
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE transaction_draft_attachment (
|
||||
draft_id INTEGER NOT NULL,
|
||||
attachment_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (draft_id, attachment_id),
|
||||
CONSTRAINT fk_transaction_draft_attachment_transaction
|
||||
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT fk_transaction_draft_attachment_attachment
|
||||
FOREIGN KEY (attachment_id) REFERENCES attachment(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE transaction_draft_line_item (
|
||||
draft_id INTEGER NOT NULL,
|
||||
idx INTEGER NOT NULL DEFAULT 0,
|
||||
value_per_item INTEGER NOT NULL,
|
||||
quantity INTEGER NOT NULL DEFAULT 1,
|
||||
description TEXT NOT NULL,
|
||||
category_id INTEGER,
|
||||
CONSTRAINT pk_transaction_draft_line_item PRIMARY KEY (draft_id, idx),
|
||||
CONSTRAINT fk_transaction_draft_line_item_transaction
|
||||
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT fk_transaction_draft_line_item_category
|
||||
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
|
||||
ON UPDATE CASCADE ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE recurring_transaction (
|
||||
id INTEGER PRIMARY KEY,
|
||||
draft_id INTEGER NOT NULL,
|
||||
schedule_expr TEXT NOT NULL,
|
||||
CONSTRAINT fk_recurring_transaction_draft
|
||||
FOREIGN KEY (draft_id) REFERENCES transaction_draft(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue