Refactored API to use optional globally, prepped draft stuff.
Build and Deploy API / build-and-deploy (push) Has been cancelled Details

This commit is contained in:
Andrew Lalis 2026-06-15 18:44:23 -04:00
parent 6cc29589ba
commit d165ac0753
20 changed files with 721 additions and 291 deletions

View File

@ -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",

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View File

@ -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");
}

View File

@ -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.");

View File

@ -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");
}

View File

@ -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);
}

View File

@ -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(
vendorId.get,
row.peek!string(8),
row.peek!string(9)
)).toNullable;
item.vendor = Optional!TransactionVendor.of(TransactionVendor(
vendorId.get,
row.peek!string(8),
row.peek!string(9)
));
}
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(10);
if (!categoryId.isNull) {
item.category = Optional!(TransactionDetail.Category).of(
TransactionDetail.Category(
categoryId.get,
row.peek!(Nullable!ulong)(11),
row.peek!string(12),
row.peek!string(13),
row.peek!string(14)
)).toNullable;
item.category = Optional!TransactionCategory.of(TransactionCategory(
categoryId.get,
row.parseOptional!ulong(11),
row.peek!string(12),
row.peek!string(13),
row.peek!string(14)
));
}
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(15);
if (!creditedAccountId.isNull) {
item.creditedAccount = Optional!(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() {
item.id = row.peek!ulong(0);
item.timestamp = row.peek!string(1);
item.addedAt = row.peek!string(2);
item.amount = row.peek!ulong(3);
item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
item.description = row.peek!string(5);
item.internalTransfer = row.peek!bool(6);
Optional!ulong vendorId = row.parseOptional!ulong(7);
if (vendorId) {
item.vendor = SimpleVendorResponse(
vendorId.value,
row.peek!string(8)
).toOptional;
}
Optional!ulong categoryId = row.parseOptional!ulong(9);
if (categoryId) {
item.category = SimpleCategoryResponse(
categoryId.value,
row.peek!string(10),
row.peek!string(11)
).toOptional;
}
Optional!ulong creditedAccountId = row.parseOptional!ulong(12);
if (creditedAccountId) {
item.creditedAccount = SimpleAccountResponse(
creditedAccountId.value,
row.peek!string(13),
row.peek!string(14),
row.peek!string(15)
).toOptional;
}
Optional!ulong debitedAccountId = row.parseOptional!ulong(16);
if (debitedAccountId) {
item.debitedAccount = SimpleAccountResponse(
debitedAccountId.value,
row.peek!string(17),
row.peek!string(18),
row.peek!string(19)
).toOptional;
}
string aggregateTags = row.peek!string(20);
if (aggregateTags !is null) {
import std.string : split;
import std.algorithm : sort;
item.tags = aggregateTags.split(",");
sort(item.tags);
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.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));
}
// 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));
}
// 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));
}
// 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));
}
}
// 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;
}
}

View File

@ -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.
}

View File

@ -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;
}

View File

@ -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));
}

View File

@ -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;

View File

@ -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()
);
}
}

View File

@ -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[];
}

View File

@ -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
);

View File

@ -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

View File

@ -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

View File

@ -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
);