WIP: Add Drafts, Templates, and Recurring Transactions #45
|
|
@ -4,10 +4,10 @@
|
||||||
"asdf": "0.8.0",
|
"asdf": "0.8.0",
|
||||||
"d2sqlite3": "1.0.0",
|
"d2sqlite3": "1.0.0",
|
||||||
"dxml": "0.4.5",
|
"dxml": "0.4.5",
|
||||||
"handy-http-data": "1.3.0",
|
"handy-http-data": "1.3.2",
|
||||||
"handy-http-handlers": "1.3.0",
|
"handy-http-handlers": "1.3.0",
|
||||||
"handy-http-primitives": "1.8.1",
|
"handy-http-primitives": "1.10.0",
|
||||||
"handy-http-starter": "1.7.0",
|
"handy-http-starter": "1.7.1",
|
||||||
"handy-http-transport": "1.10.1",
|
"handy-http-transport": "1.10.1",
|
||||||
"handy-http-websockets": "1.2.0",
|
"handy-http-websockets": "1.2.0",
|
||||||
"jwt4d": "0.0.2",
|
"jwt4d": "0.0.2",
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
"mir-core": "1.7.4",
|
"mir-core": "1.7.4",
|
||||||
"openssl": "3.3.4",
|
"openssl": "3.3.4",
|
||||||
"path-matcher": "1.2.0",
|
"path-matcher": "1.2.0",
|
||||||
"photon": "0.18.11",
|
"photon": "0.18.12",
|
||||||
"scheduled": "1.4.0",
|
"scheduled": "1.4.0",
|
||||||
"secured": "3.0.0",
|
"secured": "3.0.0",
|
||||||
"sharded-map": "2.7.0",
|
"sharded-map": "2.7.0",
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,9 @@ import account.model;
|
||||||
import attachment.data;
|
import attachment.data;
|
||||||
import attachment.dto;
|
import attachment.dto;
|
||||||
import util.money;
|
import util.money;
|
||||||
import util.data : serializeOptional;
|
|
||||||
|
|
||||||
/// The data the API provides for an Account entity.
|
/// The data the API provides for an Account entity.
|
||||||
struct AccountResponse {
|
struct AccountResponse {
|
||||||
import asdf : serdeTransformOut;
|
|
||||||
|
|
||||||
ulong id;
|
ulong id;
|
||||||
string createdAt;
|
string createdAt;
|
||||||
bool archived;
|
bool archived;
|
||||||
|
|
@ -19,7 +16,6 @@ struct AccountResponse {
|
||||||
string name;
|
string name;
|
||||||
Currency currency;
|
Currency currency;
|
||||||
string description;
|
string description;
|
||||||
@serdeTransformOut!serializeOptional
|
|
||||||
Optional!long currentBalance;
|
Optional!long currentBalance;
|
||||||
|
|
||||||
static AccountResponse of(in Account account, 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.
|
// The data provided by a user to create a new account.
|
||||||
struct AccountCreationPayload {
|
struct AccountCreationPayload {
|
||||||
string type;
|
string type;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ module analytics.data;
|
||||||
|
|
||||||
import std.datetime;
|
import std.datetime;
|
||||||
import handy_http_primitives : Optional;
|
import handy_http_primitives : Optional;
|
||||||
import asdf : serdeTransformOut;
|
|
||||||
|
|
||||||
import util.money;
|
import util.money;
|
||||||
import util.data;
|
import util.data;
|
||||||
|
|
@ -36,7 +35,6 @@ struct CategorySpendData {
|
||||||
ulong categoryId;
|
ulong categoryId;
|
||||||
string categoryName;
|
string categoryName;
|
||||||
string categoryColor;
|
string categoryColor;
|
||||||
@serdeTransformOut!serializeOptional
|
|
||||||
Optional!ulong parentCategoryId;
|
Optional!ulong parentCategoryId;
|
||||||
long amount;
|
long amount;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
|
||||||
publicHandler.registerHandlers!(auth.api_public);
|
publicHandler.registerHandlers!(auth.api_public);
|
||||||
|
|
||||||
// Dev endpoint for sample data: REMOVE BEFORE DEPLOYING!!!
|
// 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:
|
// Authenticated endpoints:
|
||||||
import auth.api;
|
import auth.api;
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ interface ProfileDataSource {
|
||||||
TransactionCategoryRepository getTransactionCategoryRepository();
|
TransactionCategoryRepository getTransactionCategoryRepository();
|
||||||
TransactionTagRepository getTransactionTagRepository();
|
TransactionTagRepository getTransactionTagRepository();
|
||||||
TransactionRepository getTransactionRepository();
|
TransactionRepository getTransactionRepository();
|
||||||
|
TransactionDraftRepository getTransactionDraftRepository();
|
||||||
|
|
||||||
AnalyticsRepository getAnalyticsRepository();
|
AnalyticsRepository getAnalyticsRepository();
|
||||||
|
|
||||||
|
|
@ -93,6 +94,9 @@ version(unittest) {
|
||||||
TransactionRepository getTransactionRepository() {
|
TransactionRepository getTransactionRepository() {
|
||||||
throw new Exception("Not implemented");
|
throw new Exception("Not implemented");
|
||||||
}
|
}
|
||||||
|
TransactionDraftRepository getTransactionDraftRepository() {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
AnalyticsRepository getAnalyticsRepository() {
|
AnalyticsRepository getAnalyticsRepository() {
|
||||||
throw new Exception("Not implemented");
|
throw new Exception("Not implemented");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,7 @@ class SqlitePropertiesRepository : PropertiesRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
private const SCHEMA = import("sql/schema.sql");
|
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";
|
private const SCHEMA_VERSION_PROPERTY = "database-schema-version";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -215,6 +215,7 @@ class SqliteProfileDataSource : ProfileDataSource {
|
||||||
TransactionCategoryRepository transactionCategoryRepo;
|
TransactionCategoryRepository transactionCategoryRepo;
|
||||||
TransactionTagRepository transactionTagRepo;
|
TransactionTagRepository transactionTagRepo;
|
||||||
TransactionRepository transactionRepo;
|
TransactionRepository transactionRepo;
|
||||||
|
TransactionDraftRepository transactionDraftRepo;
|
||||||
AnalyticsRepository analyticsRepo;
|
AnalyticsRepository analyticsRepo;
|
||||||
|
|
||||||
this(string path) {
|
this(string path) {
|
||||||
|
|
@ -297,6 +298,13 @@ class SqliteProfileDataSource : ProfileDataSource {
|
||||||
return transactionRepo;
|
return transactionRepo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TransactionDraftRepository getTransactionDraftRepository() {
|
||||||
|
if (transactionDraftRepo is null) {
|
||||||
|
transactionDraftRepo = new SqliteTransactionDraftRepository(db);
|
||||||
|
}
|
||||||
|
return transactionDraftRepo;
|
||||||
|
}
|
||||||
|
|
||||||
AnalyticsRepository getAnalyticsRepository() {
|
AnalyticsRepository getAnalyticsRepository() {
|
||||||
if (analyticsRepo is null) {
|
if (analyticsRepo is null) {
|
||||||
analyticsRepo = new SqliteAnalyticsRepository(db);
|
analyticsRepo = new SqliteAnalyticsRepository(db);
|
||||||
|
|
@ -322,7 +330,8 @@ class SqliteProfileDataSource : ProfileDataSource {
|
||||||
if (currentVersion == SCHEMA_VERSION) return;
|
if (currentVersion == SCHEMA_VERSION) return;
|
||||||
|
|
||||||
static const migrations = [
|
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 if (migrations.length != SCHEMA_VERSION) {
|
||||||
static assert(false, "Schema version doesn't match the list of defined migrations.");
|
static assert(false, "Schema version doesn't match the list of defined migrations.");
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,7 @@ struct CategoryPayload {
|
||||||
string name;
|
string name;
|
||||||
string description;
|
string description;
|
||||||
string color;
|
string color;
|
||||||
Nullable!ulong parentId;
|
Optional!ulong parentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(PROFILE_PATH ~ "/categories")
|
@PostMapping(PROFILE_PATH ~ "/categories")
|
||||||
|
|
@ -215,3 +215,37 @@ void handleDeleteCategory(ref ServerHttpRequest request, ref ServerHttpResponse
|
||||||
private ulong getCategoryId(in ServerHttpRequest request) {
|
private ulong getCategoryId(in ServerHttpRequest request) {
|
||||||
return getPathParamOrThrow!ulong(request, "categoryId");
|
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);
|
TransactionDetail update(ulong transactionId, in AddTransactionPayload data);
|
||||||
void deleteById(ulong id);
|
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;
|
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.datetime;
|
||||||
import std.typecons;
|
import std.typecons;
|
||||||
import d2sqlite3;
|
import d2sqlite3;
|
||||||
|
|
@ -13,6 +14,8 @@ import util.money;
|
||||||
import util.pagination;
|
import util.pagination;
|
||||||
import util.data;
|
import util.data;
|
||||||
import account.model;
|
import account.model;
|
||||||
|
import account.dto;
|
||||||
|
import attachment.dto;
|
||||||
|
|
||||||
class SqliteTransactionVendorRepository : TransactionVendorRepository {
|
class SqliteTransactionVendorRepository : TransactionVendorRepository {
|
||||||
private Database db;
|
private Database db;
|
||||||
|
|
@ -228,7 +231,7 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
|
||||||
import std.typecons;
|
import std.typecons;
|
||||||
return TransactionCategory(
|
return TransactionCategory(
|
||||||
row.peek!ulong(0),
|
row.peek!ulong(0),
|
||||||
toOptional(row.peek!(Nullable!ulong)(1)),
|
row.parseOptional!ulong(1),
|
||||||
row.peek!string(2),
|
row.peek!string(2),
|
||||||
row.peek!string(3),
|
row.peek!string(3),
|
||||||
row.peek!string(4)
|
row.peek!string(4)
|
||||||
|
|
@ -283,12 +286,8 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
Page!TransactionsListItem findAll(in PageRequest pr) {
|
Page!TransactionsListItem findAll(in PageRequest pr) {
|
||||||
const pageIdsQuery = "SELECT DISTINCT txn.id FROM \"transaction\" txn " ~ pr.toSql();
|
string query = import("sql/query/get_transactions.sql") ~ "\n" ~ pr.toSql();
|
||||||
QueryBuilder qb = getBuilderForTransactionsList();
|
TransactionsListItem[] results = util.sqlite.findAll(db, query, &parseListItem);
|
||||||
addSelectsForTransactionsList(qb);
|
|
||||||
qb.where("txn.id IN (" ~ pageIdsQuery ~ ")");
|
|
||||||
string query = qb.build() ~ "\n" ~ pr.toOrderClause();
|
|
||||||
TransactionsListItem[] results = util.sqlite.findAllDirect(db, query, &parseListItems);
|
|
||||||
ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM \"transaction\"");
|
ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM \"transaction\"");
|
||||||
return Page!(TransactionsListItem).of(results, pr, totalCount);
|
return Page!(TransactionsListItem).of(results, pr, totalCount);
|
||||||
}
|
}
|
||||||
|
|
@ -328,10 +327,10 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
qb.conditions = [];
|
qb.conditions = [];
|
||||||
qb.argBinders = [];
|
qb.argBinders = [];
|
||||||
addSelectsForTransactionsList(qb);
|
addSelectsForTransactionsList(qb);
|
||||||
|
qb.groupBy("txn.id");
|
||||||
qb.where("txn.id IN (" ~ idsStr ~ ")");
|
qb.where("txn.id IN (" ~ idsStr ~ ")");
|
||||||
string query = qb.build() ~ "\n" ~ pr.toOrderClause();
|
string query = qb.build() ~ "\n" ~ pr.toOrderClause();
|
||||||
Statement stmt = db.prepare(query);
|
TransactionsListItem[] results = util.sqlite.findAll(db, query, &parseListItem);
|
||||||
auto results = parseListItems(stmt.execute());
|
|
||||||
return Page!TransactionsListItem.of(results, pr, count);
|
return Page!TransactionsListItem.of(results, pr, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -370,7 +369,7 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
Optional!TransactionDetail findById(ulong id) {
|
Optional!TransactionDetail findById(ulong id) {
|
||||||
Optional!TransactionDetail item = util.sqlite.findOne(
|
Optional!TransactionDetail item = util.sqlite.findOne(
|
||||||
db,
|
db,
|
||||||
import("sql/get_transaction.sql"),
|
import("sql/query/get_transaction.sql"),
|
||||||
(row) {
|
(row) {
|
||||||
TransactionDetail item;
|
TransactionDetail item;
|
||||||
item.id = row.peek!ulong(0);
|
item.id = row.peek!ulong(0);
|
||||||
|
|
@ -383,43 +382,41 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
|
|
||||||
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7);
|
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7);
|
||||||
if (!vendorId.isNull) {
|
if (!vendorId.isNull) {
|
||||||
item.vendor = Optional!(TransactionDetail.Vendor).of(
|
item.vendor = Optional!TransactionVendor.of(TransactionVendor(
|
||||||
TransactionDetail.Vendor(
|
|
||||||
vendorId.get,
|
vendorId.get,
|
||||||
row.peek!string(8),
|
row.peek!string(8),
|
||||||
row.peek!string(9)
|
row.peek!string(9)
|
||||||
)).toNullable;
|
));
|
||||||
}
|
}
|
||||||
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(10);
|
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(10);
|
||||||
if (!categoryId.isNull) {
|
if (!categoryId.isNull) {
|
||||||
item.category = Optional!(TransactionDetail.Category).of(
|
item.category = Optional!TransactionCategory.of(TransactionCategory(
|
||||||
TransactionDetail.Category(
|
|
||||||
categoryId.get,
|
categoryId.get,
|
||||||
row.peek!(Nullable!ulong)(11),
|
row.parseOptional!ulong(11),
|
||||||
row.peek!string(12),
|
row.peek!string(12),
|
||||||
row.peek!string(13),
|
row.peek!string(13),
|
||||||
row.peek!string(14)
|
row.peek!string(14)
|
||||||
)).toNullable;
|
));
|
||||||
}
|
}
|
||||||
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(15);
|
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(15);
|
||||||
if (!creditedAccountId.isNull) {
|
if (!creditedAccountId.isNull) {
|
||||||
item.creditedAccount = Optional!(TransactionDetail.Account).of(
|
item.creditedAccount = Optional!SimpleAccountResponse.of(
|
||||||
TransactionDetail.Account(
|
SimpleAccountResponse(
|
||||||
creditedAccountId.get,
|
creditedAccountId.get,
|
||||||
row.peek!string(16),
|
row.peek!string(16),
|
||||||
row.peek!string(17),
|
row.peek!string(17),
|
||||||
row.peek!string(18)
|
row.peek!string(18)
|
||||||
)).toNullable;
|
));
|
||||||
}
|
}
|
||||||
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(19);
|
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(19);
|
||||||
if (!debitedAccountId.isNull) {
|
if (!debitedAccountId.isNull) {
|
||||||
item.debitedAccount = Optional!(TransactionDetail.Account).of(
|
item.debitedAccount = Optional!SimpleAccountResponse.of(
|
||||||
TransactionDetail.Account(
|
SimpleAccountResponse(
|
||||||
debitedAccountId.get,
|
debitedAccountId.get,
|
||||||
row.peek!string(20),
|
row.peek!string(20),
|
||||||
row.peek!string(21),
|
row.peek!string(21),
|
||||||
row.peek!string(22)
|
row.peek!string(22)
|
||||||
)).toNullable;
|
));
|
||||||
}
|
}
|
||||||
string tagsStr = row.peek!string(23);
|
string tagsStr = row.peek!string(23);
|
||||||
if (tagsStr !is null && tagsStr.length > 0) {
|
if (tagsStr !is null && tagsStr.length > 0) {
|
||||||
|
|
@ -435,23 +432,23 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
if (item.isNull) return item;
|
if (item.isNull) return item;
|
||||||
item.value.lineItems = util.sqlite.findAll(
|
item.value.lineItems = util.sqlite.findAll(
|
||||||
db,
|
db,
|
||||||
import("sql/get_line_items.sql"),
|
import("sql/query/get_line_items.sql"),
|
||||||
(row) {
|
(row) {
|
||||||
TransactionDetail.LineItem li;
|
TransactionLineItemResponse li;
|
||||||
li.idx = row.peek!uint(0);
|
li.idx = row.peek!uint(0);
|
||||||
li.valuePerItem = row.peek!long(1);
|
li.valuePerItem = row.peek!long(1);
|
||||||
li.quantity = row.peek!ulong(2);
|
li.quantity = row.peek!ulong(2);
|
||||||
li.description = row.peek!string(3);
|
li.description = row.peek!string(3);
|
||||||
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(4);
|
Optional!ulong categoryId = row.parseOptional!ulong(4);
|
||||||
if (!categoryId.isNull) {
|
if (categoryId) {
|
||||||
li.category = Optional!(TransactionDetail.Category).of(
|
li.category = Optional!TransactionCategory.of(
|
||||||
TransactionDetail.Category(
|
TransactionCategory(
|
||||||
categoryId.get,
|
categoryId.value,
|
||||||
row.peek!(Nullable!ulong)(5),
|
row.parseOptional!ulong(5),
|
||||||
row.peek!string(6),
|
row.peek!string(6),
|
||||||
row.peek!string(7),
|
row.peek!string(7),
|
||||||
row.peek!string(8)
|
row.peek!string(8)
|
||||||
)).toNullable;
|
));
|
||||||
}
|
}
|
||||||
return li;
|
return li;
|
||||||
},
|
},
|
||||||
|
|
@ -470,8 +467,8 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
data.currencyCode,
|
data.currencyCode,
|
||||||
data.description,
|
data.description,
|
||||||
data.internalTransfer,
|
data.internalTransfer,
|
||||||
data.vendorId,
|
data.vendorId.toNullable(),
|
||||||
data.categoryId
|
data.categoryId.toNullable()
|
||||||
);
|
);
|
||||||
ulong transactionId = db.lastInsertRowid();
|
ulong transactionId = db.lastInsertRowid();
|
||||||
insertLineItems(transactionId, data);
|
insertLineItems(transactionId, data);
|
||||||
|
|
@ -496,8 +493,8 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
data.currencyCode,
|
data.currencyCode,
|
||||||
data.description,
|
data.description,
|
||||||
data.internalTransfer,
|
data.internalTransfer,
|
||||||
data.vendorId,
|
data.vendorId.toNullable(),
|
||||||
data.categoryId,
|
data.categoryId.toNullable(),
|
||||||
transactionId
|
transactionId
|
||||||
);
|
);
|
||||||
// Re-write all line items:
|
// Re-write all line items:
|
||||||
|
|
@ -521,97 +518,56 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private static TransactionsListItem parseListItem(Row row) {
|
||||||
* Function to parse a list of transaction list items as obtained from the
|
|
||||||
* `get_transactions.sql` query. Because there are possibly multiple rows
|
|
||||||
* per transaction, we have to deal with the result range as a whole.
|
|
||||||
* Params:
|
|
||||||
* r = The result range to read.
|
|
||||||
* Returns: The list of transaction list items.
|
|
||||||
*/
|
|
||||||
private static TransactionsListItem[] parseListItems(ResultRange r) {
|
|
||||||
import std.array : appender;
|
|
||||||
auto app = appender!(TransactionsListItem[]);
|
|
||||||
TransactionsListItem item;
|
TransactionsListItem item;
|
||||||
|
item.id = row.peek!ulong(0);
|
||||||
/// Helper function that appends the current item to the list, and resets state.
|
|
||||||
void appendItem() {
|
|
||||||
import std.algorithm : sort;
|
|
||||||
sort(item.tags);
|
|
||||||
app ~= item;
|
|
||||||
item.id = 0;
|
|
||||||
item.tags = [];
|
|
||||||
item.vendor = Optional!(TransactionsListItem.Vendor).empty();
|
|
||||||
item.category = Optional!(TransactionsListItem.Category).empty();
|
|
||||||
item.creditedAccount = Optional!(TransactionsListItem.Account).empty();
|
|
||||||
item.debitedAccount = Optional!(TransactionsListItem.Account).empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
size_t rowCount = 0;
|
|
||||||
foreach (Row row; r) {
|
|
||||||
rowCount++;
|
|
||||||
ulong txnId = row.peek!ulong(0);
|
|
||||||
if (item.id != txnId) {
|
|
||||||
// We're parsing a new item. First, append the current one if there is one.
|
|
||||||
if (item.id != 0) {
|
|
||||||
appendItem();
|
|
||||||
}
|
|
||||||
|
|
||||||
item.id = txnId;
|
|
||||||
item.timestamp = row.peek!string(1);
|
item.timestamp = row.peek!string(1);
|
||||||
item.addedAt = row.peek!string(2);
|
item.addedAt = row.peek!string(2);
|
||||||
item.amount = row.peek!ulong(3);
|
item.amount = row.peek!ulong(3);
|
||||||
item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
|
item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
|
||||||
item.description = row.peek!string(5);
|
item.description = row.peek!string(5);
|
||||||
item.internalTransfer = row.peek!bool(6);
|
item.internalTransfer = row.peek!bool(6);
|
||||||
// Read the nullable Vendor information.
|
Optional!ulong vendorId = row.parseOptional!ulong(7);
|
||||||
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7);
|
if (vendorId) {
|
||||||
if (!vendorId.isNull) {
|
item.vendor = SimpleVendorResponse(
|
||||||
string vendorName = row.peek!string(8);
|
vendorId.value,
|
||||||
item.vendor = Optional!(TransactionsListItem.Vendor).of(
|
row.peek!string(8)
|
||||||
TransactionsListItem.Vendor(vendorId.get, vendorName));
|
).toOptional;
|
||||||
}
|
}
|
||||||
// Read the nullable Category information.
|
Optional!ulong categoryId = row.parseOptional!ulong(9);
|
||||||
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(9);
|
if (categoryId) {
|
||||||
if (!categoryId.isNull) {
|
item.category = SimpleCategoryResponse(
|
||||||
string categoryName = row.peek!string(10);
|
categoryId.value,
|
||||||
string categoryColor = row.peek!string(11);
|
row.peek!string(10),
|
||||||
item.category = Optional!(TransactionsListItem.Category).of(
|
row.peek!string(11)
|
||||||
TransactionsListItem.Category(categoryId.get, categoryName, categoryColor));
|
).toOptional;
|
||||||
}
|
}
|
||||||
// Read the nullable creditedAccount.
|
Optional!ulong creditedAccountId = row.parseOptional!ulong(12);
|
||||||
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(12);
|
if (creditedAccountId) {
|
||||||
if (!creditedAccountId.isNull) {
|
item.creditedAccount = SimpleAccountResponse(
|
||||||
ulong id = creditedAccountId.get;
|
creditedAccountId.value,
|
||||||
string name = row.peek!string(13);
|
row.peek!string(13),
|
||||||
string type = row.peek!string(14);
|
row.peek!string(14),
|
||||||
string suffix = row.peek!string(15);
|
row.peek!string(15)
|
||||||
item.creditedAccount = Optional!(TransactionsListItem.Account).of(
|
).toOptional;
|
||||||
TransactionsListItem.Account(id, name, type, suffix));
|
|
||||||
}
|
}
|
||||||
// Read the nullable debitedAccount.
|
Optional!ulong debitedAccountId = row.parseOptional!ulong(16);
|
||||||
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(16);
|
if (debitedAccountId) {
|
||||||
if (!debitedAccountId.isNull) {
|
item.debitedAccount = SimpleAccountResponse(
|
||||||
ulong id = debitedAccountId.get;
|
debitedAccountId.value,
|
||||||
string name = row.peek!string(17);
|
row.peek!string(17),
|
||||||
string type = row.peek!string(18);
|
row.peek!string(18),
|
||||||
string suffix = row.peek!string(19);
|
row.peek!string(19)
|
||||||
item.debitedAccount = Optional!(TransactionsListItem.Account).of(
|
).toOptional;
|
||||||
TransactionsListItem.Account(id, name, type, suffix));
|
|
||||||
}
|
}
|
||||||
|
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;
|
||||||
// Read multi-row properties, like tags, to the current item.
|
|
||||||
string tag = row.peek!string(20);
|
|
||||||
if (tag !is null) {
|
|
||||||
item.tags ~= tag;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If there's one last cached item, append it to the list.
|
|
||||||
if (item.id != 0) {
|
|
||||||
appendItem();
|
|
||||||
}
|
|
||||||
return app[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void insertLineItems(ulong transactionId, in AddTransactionPayload data) {
|
private void insertLineItems(ulong transactionId, in AddTransactionPayload data) {
|
||||||
|
|
@ -624,7 +580,7 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
lineItem.valuePerItem,
|
lineItem.valuePerItem,
|
||||||
lineItem.quantity,
|
lineItem.quantity,
|
||||||
lineItem.description,
|
lineItem.description,
|
||||||
lineItem.categoryId
|
lineItem.categoryId.toNullable()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -664,6 +620,168 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
.select("account_debit.name")
|
.select("account_debit.name")
|
||||||
.select("account_debit.type")
|
.select("account_debit.type")
|
||||||
.select("account_debit.number_suffix")
|
.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;
|
module transaction.dto;
|
||||||
|
|
||||||
import handy_http_primitives : Optional;
|
import handy_http_primitives : Optional;
|
||||||
import asdf : serdeTransformOut;
|
|
||||||
import std.typecons;
|
import std.typecons;
|
||||||
|
|
||||||
import transaction.model : TransactionCategory;
|
import transaction.model : TransactionCategory, TransactionVendor;
|
||||||
import attachment.dto;
|
import attachment.dto;
|
||||||
|
import account.dto;
|
||||||
import util.data;
|
import util.data;
|
||||||
import util.money;
|
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.
|
/// The transaction data provided when a list of transactions is requested.
|
||||||
struct TransactionsListItem {
|
struct TransactionsListItem {
|
||||||
ulong id;
|
ulong id;
|
||||||
|
|
@ -18,33 +40,11 @@ struct TransactionsListItem {
|
||||||
Currency currency;
|
Currency currency;
|
||||||
string description;
|
string description;
|
||||||
bool internalTransfer;
|
bool internalTransfer;
|
||||||
@serdeTransformOut!serializeOptional
|
Optional!SimpleVendorResponse vendor;
|
||||||
Optional!Vendor vendor;
|
Optional!SimpleCategoryResponse category;
|
||||||
@serdeTransformOut!serializeOptional
|
Optional!SimpleAccountResponse creditedAccount;
|
||||||
Optional!Category category;
|
Optional!SimpleAccountResponse debitedAccount;
|
||||||
@serdeTransformOut!serializeOptional
|
|
||||||
Optional!Account creditedAccount;
|
|
||||||
@serdeTransformOut!serializeOptional
|
|
||||||
Optional!Account debitedAccount;
|
|
||||||
string[] tags;
|
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.
|
/// Transaction data provided when fetching a single transaction.
|
||||||
|
|
@ -56,42 +56,13 @@ struct TransactionDetail {
|
||||||
Currency currency;
|
Currency currency;
|
||||||
string description;
|
string description;
|
||||||
bool internalTransfer;
|
bool internalTransfer;
|
||||||
Nullable!Vendor vendor;
|
Optional!TransactionVendor vendor;
|
||||||
Nullable!Category category;
|
Optional!TransactionCategory category;
|
||||||
Nullable!Account creditedAccount;
|
Optional!SimpleAccountResponse creditedAccount;
|
||||||
Nullable!Account debitedAccount;
|
Optional!SimpleAccountResponse debitedAccount;
|
||||||
string[] tags;
|
string[] tags;
|
||||||
LineItem[] lineItems;
|
TransactionLineItemResponse[] lineItems;
|
||||||
AttachmentResponse[] attachments;
|
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.
|
/// Data provided when a new transaction is added by a user.
|
||||||
|
|
@ -101,26 +72,25 @@ struct AddTransactionPayload {
|
||||||
string currencyCode;
|
string currencyCode;
|
||||||
string description;
|
string description;
|
||||||
bool internalTransfer;
|
bool internalTransfer;
|
||||||
Nullable!ulong vendorId;
|
Optional!ulong vendorId;
|
||||||
Nullable!ulong categoryId;
|
Optional!ulong categoryId;
|
||||||
Nullable!ulong creditedAccountId;
|
Optional!ulong creditedAccountId;
|
||||||
Nullable!ulong debitedAccountId;
|
Optional!ulong debitedAccountId;
|
||||||
string[] tags;
|
string[] tags;
|
||||||
LineItem[] lineItems;
|
LineItemPayload[] lineItems;
|
||||||
ulong[] attachmentIdsToRemove;
|
ulong[] attachmentIdsToRemove;
|
||||||
|
|
||||||
static struct LineItem {
|
static struct LineItemPayload {
|
||||||
long valuePerItem;
|
long valuePerItem;
|
||||||
ulong quantity;
|
ulong quantity;
|
||||||
string description;
|
string description;
|
||||||
Nullable!ulong categoryId;
|
Optional!ulong categoryId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Structure for depicting an entire hierarchical tree structure of categories.
|
/// Structure for depicting an entire hierarchical tree structure of categories.
|
||||||
struct TransactionCategoryTree {
|
struct TransactionCategoryTree {
|
||||||
ulong id;
|
ulong id;
|
||||||
@serdeTransformOut!serializeOptional
|
|
||||||
Optional!ulong parentId;
|
Optional!ulong parentId;
|
||||||
string name;
|
string name;
|
||||||
string description;
|
string description;
|
||||||
|
|
@ -131,7 +101,6 @@ struct TransactionCategoryTree {
|
||||||
|
|
||||||
struct TransactionCategoryResponse {
|
struct TransactionCategoryResponse {
|
||||||
ulong id;
|
ulong id;
|
||||||
@serdeTransformOut!serializeOptional
|
|
||||||
Optional!ulong parentId;
|
Optional!ulong parentId;
|
||||||
string name;
|
string name;
|
||||||
string description;
|
string description;
|
||||||
|
|
@ -170,3 +139,48 @@ struct AggregateTransactionData {
|
||||||
}
|
}
|
||||||
CurrencyData[] currencies;
|
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;
|
import util.money;
|
||||||
|
|
||||||
struct TransactionVendor {
|
struct TransactionVendor {
|
||||||
immutable ulong id;
|
ulong id;
|
||||||
immutable string name;
|
string name;
|
||||||
immutable string description;
|
string description;
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TransactionCategory {
|
struct TransactionCategory {
|
||||||
|
|
@ -20,23 +20,23 @@ struct TransactionCategory {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Transaction {
|
struct Transaction {
|
||||||
immutable ulong id;
|
ulong id;
|
||||||
/// The time at which the transaction happened.
|
/// The time at which the transaction happened.
|
||||||
immutable SysTime timestamp;
|
SysTime timestamp;
|
||||||
/// The time at which the transaction entity was saved.
|
/// The time at which the transaction entity was saved.
|
||||||
immutable SysTime addedAt;
|
SysTime addedAt;
|
||||||
immutable ulong amount;
|
ulong amount;
|
||||||
immutable Currency currency;
|
Currency currency;
|
||||||
immutable string description;
|
string description;
|
||||||
immutable Optional!ulong vendorId;
|
Optional!ulong vendorId;
|
||||||
immutable Optional!ulong categoryId;
|
Optional!ulong categoryId;
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TransactionLineItem {
|
struct TransactionLineItem {
|
||||||
immutable ulong transactionId;
|
ulong transactionId;
|
||||||
immutable uint idx;
|
uint idx;
|
||||||
immutable long valuePerItem;
|
long valuePerItem;
|
||||||
immutable ulong quantity;
|
ulong quantity;
|
||||||
immutable string description;
|
string description;
|
||||||
immutable Optional!ulong categoryId;
|
Optional!ulong categoryId;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,8 @@ import attachment.dto;
|
||||||
// Transactions Services
|
// Transactions Services
|
||||||
|
|
||||||
Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest pageRequest) {
|
Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest pageRequest) {
|
||||||
Page!TransactionsListItem page = ds.getTransactionRepository()
|
return ds.getTransactionRepository()
|
||||||
.findAll(pageRequest);
|
.findAll(pageRequest);
|
||||||
return page;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
TransactionDetail getTransaction(ProfileDataSource ds, ulong transactionId) {
|
TransactionDetail getTransaction(ProfileDataSource ds, ulong transactionId) {
|
||||||
|
|
@ -53,20 +52,20 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload
|
||||||
ds.doTransaction(() {
|
ds.doTransaction(() {
|
||||||
TransactionDetail txn = txnRepo.insert(payload);
|
TransactionDetail txn = txnRepo.insert(payload);
|
||||||
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
||||||
if (!payload.creditedAccountId.isNull) {
|
if (payload.creditedAccountId) {
|
||||||
jeRepo.insert(
|
jeRepo.insert(
|
||||||
timestamp,
|
timestamp,
|
||||||
payload.creditedAccountId.get,
|
payload.creditedAccountId.value,
|
||||||
txn.id,
|
txn.id,
|
||||||
txn.amount,
|
txn.amount,
|
||||||
AccountJournalEntryType.CREDIT,
|
AccountJournalEntryType.CREDIT,
|
||||||
txn.currency
|
txn.currency
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!payload.debitedAccountId.isNull) {
|
if (payload.debitedAccountId) {
|
||||||
jeRepo.insert(
|
jeRepo.insert(
|
||||||
timestamp,
|
timestamp,
|
||||||
payload.debitedAccountId.get,
|
payload.debitedAccountId.value,
|
||||||
txn.id,
|
txn.id,
|
||||||
txn.amount,
|
txn.amount,
|
||||||
AccountJournalEntryType.DEBIT,
|
AccountJournalEntryType.DEBIT,
|
||||||
|
|
@ -120,45 +119,45 @@ private void updateLinkedAccountJournalEntries(
|
||||||
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
||||||
const bool amountOrCurrencyChanged = prev.amount != curr.amount || prev.currency.code != curr.currency.code;
|
const bool amountOrCurrencyChanged = prev.amount != curr.amount || prev.currency.code != curr.currency.code;
|
||||||
const bool updateCreditEntry = amountOrCurrencyChanged || (
|
const bool updateCreditEntry = amountOrCurrencyChanged || (
|
||||||
(prev.creditedAccount.isNull && !payload.creditedAccountId.isNull) ||
|
(!prev.creditedAccount && payload.creditedAccountId) ||
|
||||||
(!prev.creditedAccount.isNull && payload.creditedAccountId.isNull) ||
|
(prev.creditedAccount && !payload.creditedAccountId) ||
|
||||||
(
|
(
|
||||||
!prev.creditedAccount.isNull &&
|
prev.creditedAccount &&
|
||||||
!payload.creditedAccountId.isNull &&
|
payload.creditedAccountId &&
|
||||||
prev.creditedAccount.get.id != payload.creditedAccountId.get
|
prev.creditedAccount.value.id != payload.creditedAccountId.value
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const bool updateDebitEntry = amountOrCurrencyChanged || (
|
const bool updateDebitEntry = amountOrCurrencyChanged || (
|
||||||
(prev.debitedAccount.isNull && !payload.creditedAccountId.isNull) ||
|
(!prev.debitedAccount && payload.creditedAccountId) ||
|
||||||
(!prev.debitedAccount.isNull && payload.debitedAccountId.isNull) ||
|
(prev.debitedAccount && !payload.debitedAccountId) ||
|
||||||
(
|
(
|
||||||
!prev.debitedAccount.isNull &&
|
prev.debitedAccount &&
|
||||||
!payload.debitedAccountId.isNull &&
|
payload.debitedAccountId &&
|
||||||
prev.debitedAccount.get.id != payload.debitedAccountId.get
|
prev.debitedAccount.value.id != payload.debitedAccountId.value
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update journal entries if necessary:
|
// Update journal entries if necessary:
|
||||||
if (updateCreditEntry && !prev.creditedAccount.isNull) {
|
if (updateCreditEntry && prev.creditedAccount) {
|
||||||
jeRepo.deleteByAccountIdAndTransactionId(prev.creditedAccount.get.id, prev.id);
|
jeRepo.deleteByAccountIdAndTransactionId(prev.creditedAccount.value.id, prev.id);
|
||||||
}
|
}
|
||||||
if (updateCreditEntry && !payload.creditedAccountId.isNull) {
|
if (updateCreditEntry && payload.creditedAccountId) {
|
||||||
jeRepo.insert(
|
jeRepo.insert(
|
||||||
timestamp,
|
timestamp,
|
||||||
payload.creditedAccountId.get,
|
payload.creditedAccountId.value,
|
||||||
curr.id,
|
curr.id,
|
||||||
curr.amount,
|
curr.amount,
|
||||||
AccountJournalEntryType.CREDIT,
|
AccountJournalEntryType.CREDIT,
|
||||||
curr.currency
|
curr.currency
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (updateDebitEntry && !prev.debitedAccount.isNull) {
|
if (updateDebitEntry && prev.debitedAccount) {
|
||||||
jeRepo.deleteByAccountIdAndTransactionId(prev.debitedAccount.get.id, prev.id);
|
jeRepo.deleteByAccountIdAndTransactionId(prev.debitedAccount.value.id, prev.id);
|
||||||
}
|
}
|
||||||
if (updateDebitEntry && !payload.debitedAccountId.isNull) {
|
if (updateDebitEntry && payload.debitedAccountId) {
|
||||||
jeRepo.insert(
|
jeRepo.insert(
|
||||||
timestamp,
|
timestamp,
|
||||||
payload.debitedAccountId.get,
|
payload.debitedAccountId.value,
|
||||||
curr.id,
|
curr.id,
|
||||||
curr.amount,
|
curr.amount,
|
||||||
AccountJournalEntryType.DEBIT,
|
AccountJournalEntryType.DEBIT,
|
||||||
|
|
@ -187,13 +186,13 @@ private void validateTransactionPayload(
|
||||||
AccountRepository accountRepo,
|
AccountRepository accountRepo,
|
||||||
in AddTransactionPayload payload
|
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.");
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "At least one account must be linked.");
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!payload.creditedAccountId.isNull &&
|
payload.creditedAccountId &&
|
||||||
!payload.debitedAccountId.isNull &&
|
payload.debitedAccountId &&
|
||||||
payload.creditedAccountId.get == payload.debitedAccountId.get
|
payload.creditedAccountId.value == payload.debitedAccountId.value
|
||||||
) {
|
) {
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot link the same account as both credit and debit.");
|
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) {
|
if (timestamp > now) {
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot create transaction in the future.");
|
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.");
|
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.");
|
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.");
|
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.");
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Debited account doesn't exist.");
|
||||||
}
|
}
|
||||||
foreach (tag; payload.tags) {
|
foreach (tag; payload.tags) {
|
||||||
|
|
@ -232,7 +231,7 @@ private void validateTransactionPayload(
|
||||||
if (payload.lineItems.length > 0) {
|
if (payload.lineItems.length > 0) {
|
||||||
long lineItemsTotal = 0;
|
long lineItemsTotal = 0;
|
||||||
foreach (lineItem; payload.lineItems) {
|
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.");
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's category doesn't exist.");
|
||||||
}
|
}
|
||||||
if (lineItem.quantity == 0) {
|
if (lineItem.quantity == 0) {
|
||||||
|
|
@ -454,7 +453,7 @@ TransactionCategoryResponse createCategory(ProfileDataSource ds, in CategoryPayl
|
||||||
if (repo.existsByName(payload.name)) {
|
if (repo.existsByName(payload.name)) {
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Name already in use.");
|
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.");
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id.");
|
||||||
}
|
}
|
||||||
import std.regex;
|
import std.regex;
|
||||||
|
|
@ -463,7 +462,7 @@ TransactionCategoryResponse createCategory(ProfileDataSource ds, in CategoryPayl
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid color hex string.");
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid color hex string.");
|
||||||
}
|
}
|
||||||
auto category = repo.insert(
|
auto category = repo.insert(
|
||||||
toOptional(payload.parentId),
|
payload.parentId,
|
||||||
payload.name,
|
payload.name,
|
||||||
payload.description,
|
payload.description,
|
||||||
payload.color
|
payload.color
|
||||||
|
|
@ -481,7 +480,7 @@ TransactionCategoryResponse updateCategory(ProfileDataSource ds, ulong categoryI
|
||||||
if (payload.name != prev.name && repo.existsByName(payload.name)) {
|
if (payload.name != prev.name && repo.existsByName(payload.name)) {
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Name already in use.");
|
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.");
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id.");
|
||||||
}
|
}
|
||||||
TransactionCategory curr = repo.updateById(
|
TransactionCategory curr = repo.updateById(
|
||||||
|
|
@ -489,7 +488,7 @@ TransactionCategoryResponse updateCategory(ProfileDataSource ds, ulong categoryI
|
||||||
payload.name,
|
payload.name,
|
||||||
payload.description,
|
payload.description,
|
||||||
payload.color,
|
payload.color,
|
||||||
toOptional!ulong(payload.parentId)
|
payload.parentId
|
||||||
);
|
);
|
||||||
return TransactionCategoryResponse.of(curr);
|
return TransactionCategoryResponse.of(curr);
|
||||||
}
|
}
|
||||||
|
|
@ -497,3 +496,21 @@ TransactionCategoryResponse updateCategory(ProfileDataSource ds, ulong categoryI
|
||||||
void deleteCategory(ProfileDataSource ds, ulong categoryId) {
|
void deleteCategory(ProfileDataSource ds, ulong categoryId) {
|
||||||
ds.getTransactionCategoryRepository().deleteById(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) {
|
T getPathParamOrThrow(T)(in ServerHttpRequest req, string name) {
|
||||||
import handy_http_handlers.path_handler;
|
import handy_http_handlers.path_handler;
|
||||||
import std.conv : to, ConvException;
|
import std.conv : to, ConvException;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
module util.sample_data;
|
module util.sample_data;
|
||||||
|
|
||||||
import slf4d;
|
import slf4d;
|
||||||
import handy_http_primitives : Optional, mapIfPresent;
|
import handy_http_primitives : Optional, mapIfPresent, toOptional;
|
||||||
|
|
||||||
import auth;
|
import auth;
|
||||||
import profile;
|
import profile;
|
||||||
|
|
@ -143,10 +143,10 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
||||||
AddTransactionPayload data;
|
AddTransactionPayload data;
|
||||||
data.timestamp = timestamp.toISOExtString();
|
data.timestamp = timestamp.toISOExtString();
|
||||||
if (uniform01() < 0.7) {
|
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) {
|
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.
|
// Randomly choose an account to credit / debit the transaction to.
|
||||||
|
|
@ -162,11 +162,11 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (uniform01() < 0.5) {
|
if (uniform01() < 0.5) {
|
||||||
data.creditedAccountId = Optional!ulong.of(primaryAccount.id).toNullable;
|
data.creditedAccountId = Optional!ulong.of(primaryAccount.id);
|
||||||
if (secondaryAccountId) data.debitedAccountId = secondaryAccountId.toNullable;
|
if (secondaryAccountId) data.debitedAccountId = secondaryAccountId;
|
||||||
} else {
|
} else {
|
||||||
data.debitedAccountId = Optional!ulong.of(primaryAccount.id).toNullable;
|
data.debitedAccountId = Optional!ulong.of(primaryAccount.id);
|
||||||
if (secondaryAccountId) data.creditedAccountId = secondaryAccountId.toNullable;
|
if (secondaryAccountId) data.creditedAccountId = secondaryAccountId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Randomly choose some tags to add.
|
// Randomly choose some tags to add.
|
||||||
|
|
@ -185,25 +185,25 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
||||||
if (uniform01 < 0.5) {
|
if (uniform01 < 0.5) {
|
||||||
long lineItemTotal = 0;
|
long lineItemTotal = 0;
|
||||||
foreach (n; 1..uniform(1, 20)) {
|
foreach (n; 1..uniform(1, 20)) {
|
||||||
AddTransactionPayload.LineItem item;
|
AddTransactionPayload.LineItemPayload item;
|
||||||
item.valuePerItem = uniform(1, 10_000);
|
item.valuePerItem = uniform(1, 10_000);
|
||||||
item.quantity = uniform(1, 5);
|
item.quantity = uniform(1, 5);
|
||||||
lineItemTotal += item.quantity * item.valuePerItem;
|
lineItemTotal += item.quantity * item.valuePerItem;
|
||||||
item.description = "Sample item " ~ n.to!string;
|
item.description = "Sample item " ~ n.to!string;
|
||||||
if (uniform01 < 0.5) {
|
if (uniform01 < 0.5) {
|
||||||
TransactionCategory category = choice(categories);
|
TransactionCategory category = choice(categories);
|
||||||
item.categoryId = category.id;
|
item.categoryId = Optional!ulong.of(category.id);
|
||||||
}
|
}
|
||||||
data.lineItems ~= item;
|
data.lineItems ~= item;
|
||||||
}
|
}
|
||||||
long diff = data.amount - lineItemTotal;
|
long diff = data.amount - lineItemTotal;
|
||||||
// Add one final line item that adds up to the transaction total.
|
// Add one final line item that adds up to the transaction total.
|
||||||
if (diff != 0) {
|
if (diff != 0) {
|
||||||
data.lineItems ~= AddTransactionPayload.LineItem(
|
data.lineItems ~= AddTransactionPayload.LineItemPayload(
|
||||||
diff,
|
diff,
|
||||||
1,
|
1,
|
||||||
"Last item which reconciles line items total with transaction amount.",
|
"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;
|
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 {
|
struct QueryBuilder {
|
||||||
string fromTable;
|
string fromTable;
|
||||||
string[] selections;
|
string[] selections;
|
||||||
string[] joins;
|
string[] joins;
|
||||||
string[] conditions;
|
string[] conditions;
|
||||||
|
string[] groupings;
|
||||||
void delegate(ref Statement, ref int)[] argBinders;
|
void delegate(ref Statement, ref int)[] argBinders;
|
||||||
|
|
||||||
this(string fromTable) {
|
this(string fromTable) {
|
||||||
|
|
@ -223,6 +247,11 @@ struct QueryBuilder {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ref groupBy(string grouping) {
|
||||||
|
groupings ~= grouping;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
string build() const {
|
string build() const {
|
||||||
import std.algorithm : map;
|
import std.algorithm : map;
|
||||||
import std.string : join;
|
import std.string : join;
|
||||||
|
|
@ -240,6 +269,10 @@ struct QueryBuilder {
|
||||||
app ~= "\nWHERE\n";
|
app ~= "\nWHERE\n";
|
||||||
app ~= conditions.map!(s => " " ~ s).join(" AND\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[];
|
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.value_per_item,
|
||||||
i.quantity,
|
i.quantity,
|
||||||
i.description,
|
i.description,
|
||||||
|
|
||||||
i.category_id,
|
i.category_id,
|
||||||
category.parent_id,
|
category.parent_id,
|
||||||
category.name,
|
category.name,
|
||||||
category.description,
|
category.description,
|
||||||
category.color
|
category.color
|
||||||
|
|
||||||
FROM transaction_line_item i
|
FROM transaction_line_item i
|
||||||
LEFT JOIN transaction_category category
|
LEFT JOIN transaction_category category
|
||||||
ON category.id = i.category_id
|
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
|
-- Basic/Utility Entities
|
||||||
|
|
||||||
|
|
@ -218,3 +223,80 @@ CREATE TABLE history_item_linked_journal_entry (
|
||||||
FOREIGN KEY (journal_entry_id) REFERENCES account_journal_entry(id)
|
FOREIGN KEY (journal_entry_id) REFERENCES account_journal_entry(id)
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE
|
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