Add file selector, attachments support.
This commit is contained in:
parent
2dae054950
commit
683d11a9a4
|
|
@ -15,12 +15,14 @@ import account.model;
|
||||||
import account.service;
|
import account.service;
|
||||||
import util.money;
|
import util.money;
|
||||||
import util.pagination;
|
import util.pagination;
|
||||||
|
import util.data;
|
||||||
import account.data;
|
import account.data;
|
||||||
|
import attachment.data;
|
||||||
|
import attachment.dto;
|
||||||
|
|
||||||
/// 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;
|
import asdf : serdeTransformOut;
|
||||||
import util.data;
|
|
||||||
|
|
||||||
ulong id;
|
ulong id;
|
||||||
string createdAt;
|
string createdAt;
|
||||||
|
|
@ -28,7 +30,7 @@ struct AccountResponse {
|
||||||
string type;
|
string type;
|
||||||
string numberSuffix;
|
string numberSuffix;
|
||||||
string name;
|
string name;
|
||||||
string currency;
|
Currency currency;
|
||||||
string description;
|
string description;
|
||||||
@serdeTransformOut!serializeOptional
|
@serdeTransformOut!serializeOptional
|
||||||
Optional!long currentBalance;
|
Optional!long currentBalance;
|
||||||
|
|
@ -41,7 +43,7 @@ struct AccountResponse {
|
||||||
r.type = account.type.id;
|
r.type = account.type.id;
|
||||||
r.numberSuffix = account.numberSuffix;
|
r.numberSuffix = account.numberSuffix;
|
||||||
r.name = account.name;
|
r.name = account.name;
|
||||||
r.currency = account.currency.code.dup;
|
r.currency = account.currency;
|
||||||
r.description = account.description;
|
r.description = account.description;
|
||||||
r.currentBalance = currentBalance;
|
r.currentBalance = currentBalance;
|
||||||
return r;
|
return r;
|
||||||
|
|
@ -124,15 +126,21 @@ struct AccountValueRecordResponse {
|
||||||
string type;
|
string type;
|
||||||
long value;
|
long value;
|
||||||
Currency currency;
|
Currency currency;
|
||||||
|
AttachmentResponse[] attachments;
|
||||||
|
|
||||||
static AccountValueRecordResponse of(in AccountValueRecord vr) {
|
static AccountValueRecordResponse of(in AccountValueRecord vr, AttachmentRepository attachmentRepo) {
|
||||||
|
import std.algorithm : map;
|
||||||
|
import std.array : array;
|
||||||
return AccountValueRecordResponse(
|
return AccountValueRecordResponse(
|
||||||
vr.id,
|
vr.id,
|
||||||
vr.timestamp.toISOExtString(),
|
vr.timestamp.toISOExtString(),
|
||||||
vr.accountId,
|
vr.accountId,
|
||||||
vr.type,
|
vr.type,
|
||||||
vr.value,
|
vr.value,
|
||||||
vr.currency
|
vr.currency,
|
||||||
|
attachmentRepo.findAllByValueRecordId(vr.id)
|
||||||
|
.map!(AttachmentResponse.of)
|
||||||
|
.array
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -140,9 +148,10 @@ struct AccountValueRecordResponse {
|
||||||
void handleGetValueRecords(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
void handleGetValueRecords(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
ulong accountId = request.getPathParamAs!ulong("accountId");
|
ulong accountId = request.getPathParamAs!ulong("accountId");
|
||||||
auto ds = getProfileDataSource(request);
|
auto ds = getProfileDataSource(request);
|
||||||
|
scope attachmentRepo = ds.getAttachmentRepository();
|
||||||
auto page = ds.getAccountValueRecordRepository()
|
auto page = ds.getAccountValueRecordRepository()
|
||||||
.findAllByAccountId(accountId, PageRequest.parse(request, VALUE_RECORD_DEFAULT_PAGE_REQUEST))
|
.findAllByAccountId(accountId, PageRequest.parse(request, VALUE_RECORD_DEFAULT_PAGE_REQUEST))
|
||||||
.mapTo!()(&AccountValueRecordResponse.of);
|
.mapTo!()((vr) => AccountValueRecordResponse.of(vr, attachmentRepo));
|
||||||
writeJsonBody(response, page);
|
writeJsonBody(response, page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,9 +159,10 @@ void handleGetValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse
|
||||||
ulong accountId = request.getPathParamAs!ulong("accountId");
|
ulong accountId = request.getPathParamAs!ulong("accountId");
|
||||||
ulong valueRecordId = request.getPathParamAs!ulong("valueRecordId");
|
ulong valueRecordId = request.getPathParamAs!ulong("valueRecordId");
|
||||||
auto ds = getProfileDataSource(request);
|
auto ds = getProfileDataSource(request);
|
||||||
|
auto attachmentRepo = ds.getAttachmentRepository();
|
||||||
auto record = ds.getAccountValueRecordRepository().findById(accountId, valueRecordId)
|
auto record = ds.getAccountValueRecordRepository().findById(accountId, valueRecordId)
|
||||||
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||||
writeJsonBody(response, AccountValueRecordResponse.of(record));
|
writeJsonBody(response, AccountValueRecordResponse.of(record, attachmentRepo));
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ValueRecordCreationPayload {
|
struct ValueRecordCreationPayload {
|
||||||
|
|
@ -166,23 +176,50 @@ void handleCreateValueRecord(ref ServerHttpRequest request, ref ServerHttpRespon
|
||||||
ProfileDataSource ds = getProfileDataSource(request);
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
Account account = ds.getAccountRepository().findById(accountId)
|
Account account = ds.getAccountRepository().findById(accountId)
|
||||||
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||||
ValueRecordCreationPayload payload = readJsonBodyAs!ValueRecordCreationPayload(request);
|
AccountValueRecordRepository valueRecordRepo = ds.getAccountValueRecordRepository();
|
||||||
|
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
|
||||||
|
auto fullPayload = parseMultipartFilesAndBody!ValueRecordCreationPayload(request);
|
||||||
|
ValueRecordCreationPayload payload = fullPayload.payload;
|
||||||
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp);
|
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp);
|
||||||
AccountValueRecordType type = AccountValueRecordType.BALANCE; // TODO: Support more types.
|
AccountValueRecordType type = AccountValueRecordType.BALANCE; // TODO: Support more types.
|
||||||
AccountValueRecord record = ds.getAccountValueRecordRepository().insert(
|
ulong valueRecordId;
|
||||||
|
ds.doTransaction(() {
|
||||||
|
AccountValueRecord record = valueRecordRepo.insert(
|
||||||
timestamp,
|
timestamp,
|
||||||
account.id,
|
account.id,
|
||||||
type,
|
type,
|
||||||
payload.value,
|
payload.value,
|
||||||
account.currency
|
account.currency
|
||||||
);
|
);
|
||||||
writeJsonBody(response, AccountValueRecordResponse.of(record));
|
foreach (attachment; fullPayload.files) {
|
||||||
|
ulong attachmentId = attachmentRepo.save(
|
||||||
|
timestamp, attachment.name, attachment.contentType, attachment.content);
|
||||||
|
valueRecordRepo.linkAttachment(record.id, attachmentId);
|
||||||
|
}
|
||||||
|
valueRecordId = record.id;
|
||||||
|
});
|
||||||
|
writeJsonBody(
|
||||||
|
response,
|
||||||
|
AccountValueRecordResponse.of(
|
||||||
|
valueRecordRepo.findById(accountId, valueRecordId).orElseThrow(),
|
||||||
|
attachmentRepo
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleDeleteValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
void handleDeleteValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
ulong accountId = request.getPathParamAs!ulong("accountId");
|
ulong accountId = request.getPathParamAs!ulong("accountId");
|
||||||
ulong valueRecordId = request.getPathParamAs!ulong("valueRecordId");
|
ulong valueRecordId = request.getPathParamAs!ulong("valueRecordId");
|
||||||
ProfileDataSource ds = getProfileDataSource(request);
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
ds.getAccountValueRecordRepository()
|
AccountValueRecordRepository valueRecordRepo = ds.getAccountValueRecordRepository();
|
||||||
.deleteById(accountId, valueRecordId);
|
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
|
||||||
|
AccountValueRecord valueRecord = valueRecordRepo.findById(accountId, valueRecordId)
|
||||||
|
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||||
|
ds.doTransaction(() {
|
||||||
|
// First delete all attachments.
|
||||||
|
foreach (a; attachmentRepo.findAllByValueRecordId(valueRecord.id)) {
|
||||||
|
attachmentRepo.remove(a.id);
|
||||||
|
}
|
||||||
|
valueRecordRepo.deleteById(accountId, valueRecordId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ interface AccountValueRecordRepository {
|
||||||
long value,
|
long value,
|
||||||
Currency currency
|
Currency currency
|
||||||
);
|
);
|
||||||
|
void linkAttachment(ulong valueRecordId, ulong attachmentId);
|
||||||
Page!AccountValueRecord findAllByAccountId(ulong accountId, in PageRequest pr);
|
Page!AccountValueRecord findAllByAccountId(ulong accountId, in PageRequest pr);
|
||||||
void deleteById(ulong accountId, ulong id);
|
void deleteById(ulong accountId, ulong id);
|
||||||
Optional!AccountValueRecord findNearestByAccountIdBefore(ulong accountId, SysTime timestamp);
|
Optional!AccountValueRecord findNearestByAccountIdBefore(ulong accountId, SysTime timestamp);
|
||||||
|
|
|
||||||
|
|
@ -335,6 +335,15 @@ class SqliteAccountValueRecordRepository : AccountValueRecordRepository {
|
||||||
return findById(accountId, id).orElseThrow();
|
return findById(accountId, id).orElseThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void linkAttachment(ulong valueRecordId, ulong attachmentId) {
|
||||||
|
util.sqlite.update(
|
||||||
|
db,
|
||||||
|
"INSERT INTO account_value_record_attachment (value_record_id, attachment_id) VALUES (?, ?)",
|
||||||
|
valueRecordId,
|
||||||
|
attachmentId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void deleteById(ulong accountId, ulong id) {
|
void deleteById(ulong accountId, ulong id) {
|
||||||
util.sqlite.update(
|
util.sqlite.update(
|
||||||
db,
|
db,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import std.datetime;
|
||||||
interface AttachmentRepository {
|
interface AttachmentRepository {
|
||||||
Optional!Attachment findById(ulong id);
|
Optional!Attachment findById(ulong id);
|
||||||
Attachment[] findAllByLinkedEntity(string subquery, ulong entityId);
|
Attachment[] findAllByLinkedEntity(string subquery, ulong entityId);
|
||||||
ulong save(SysTime uploadedAt, string filename, string contentType, ubyte[] content);
|
Attachment[] findAllByTransactionId(ulong transactionId);
|
||||||
|
Attachment[] findAllByValueRecordId(ulong valueRecordId);
|
||||||
|
ulong save(SysTime uploadedAt, string filename, string contentType, in ubyte[] content);
|
||||||
void remove(ulong id);
|
void remove(ulong id);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,18 +19,34 @@ class SqliteAttachmentRepository : AttachmentRepository {
|
||||||
Optional!Attachment findById(ulong id) {
|
Optional!Attachment findById(ulong id) {
|
||||||
return findOne(
|
return findOne(
|
||||||
db,
|
db,
|
||||||
"SELECT * FROM attachment WHERE id = ?",
|
"SELECT id, uploaded_at, filename, content_type, size FROM attachment WHERE id = ?",
|
||||||
&parseAttachment,
|
&parseAttachment,
|
||||||
id
|
id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Attachment[] findAllByLinkedEntity(string subquery, ulong entityId) {
|
Attachment[] findAllByLinkedEntity(string subquery, ulong entityId) {
|
||||||
const query = format!"SELECT * FROM attachment WHERE id IN (%s)"(subquery);
|
const query = format!("SELECT id, uploaded_at, filename, content_type, size " ~
|
||||||
|
"FROM attachment WHERE id IN (%s) " ~
|
||||||
|
"ORDER BY filename ASC, uploaded_at DESC")(subquery);
|
||||||
return findAll(db, query, &parseAttachment, entityId);
|
return findAll(db, query, &parseAttachment, entityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
ulong save(SysTime uploadedAt, string filename, string contentType, ubyte[] content) {
|
Attachment[] findAllByTransactionId(ulong transactionId) {
|
||||||
|
return findAllByLinkedEntity(
|
||||||
|
"SELECT attachment_id FROM transaction_attachment WHERE transaction_id = ?",
|
||||||
|
transactionId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Attachment[] findAllByValueRecordId(ulong valueRecordId) {
|
||||||
|
return findAllByLinkedEntity(
|
||||||
|
"SELECT attachment_id FROM account_value_record_attachment WHERE value_record_id = ?",
|
||||||
|
valueRecordId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ulong save(SysTime uploadedAt, string filename, string contentType, in ubyte[] content) {
|
||||||
util.sqlite.update(
|
util.sqlite.update(
|
||||||
db,
|
db,
|
||||||
q"SQL
|
q"SQL
|
||||||
|
|
@ -61,8 +77,7 @@ SQL",
|
||||||
parseISOTimestamp(row, 1),
|
parseISOTimestamp(row, 1),
|
||||||
row.peek!string(2),
|
row.peek!string(2),
|
||||||
row.peek!string(3),
|
row.peek!string(3),
|
||||||
row.peek!ulong(4),
|
row.peek!ulong(4)
|
||||||
parseBlob(row, 5)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
module attachment.dto;
|
||||||
|
|
||||||
|
import attachment.model;
|
||||||
|
|
||||||
|
struct AttachmentResponse {
|
||||||
|
ulong id;
|
||||||
|
string uploadedAt;
|
||||||
|
string filename;
|
||||||
|
string contentType;
|
||||||
|
ulong size;
|
||||||
|
|
||||||
|
static AttachmentResponse of(Attachment a) {
|
||||||
|
return AttachmentResponse(
|
||||||
|
a.id,
|
||||||
|
a.uploadedAt.toISOExtString(),
|
||||||
|
a.filename,
|
||||||
|
a.contentType,
|
||||||
|
a.size
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,5 +8,4 @@ struct Attachment {
|
||||||
immutable string filename;
|
immutable string filename;
|
||||||
immutable string contentType;
|
immutable string contentType;
|
||||||
immutable ulong size;
|
immutable ulong size;
|
||||||
immutable ubyte[] content;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,10 @@ interface PropertiesRepository {
|
||||||
interface ProfileDataSource {
|
interface ProfileDataSource {
|
||||||
import account.data;
|
import account.data;
|
||||||
import transaction.data;
|
import transaction.data;
|
||||||
|
import attachment.data;
|
||||||
|
|
||||||
PropertiesRepository getPropertiesRepository();
|
PropertiesRepository getPropertiesRepository();
|
||||||
|
AttachmentRepository getAttachmentRepository();
|
||||||
|
|
||||||
AccountRepository getAccountRepository();
|
AccountRepository getAccountRepository();
|
||||||
AccountJournalEntryRepository getAccountJournalEntryRepository();
|
AccountJournalEntryRepository getAccountJournalEntryRepository();
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,8 @@ class SqliteProfileDataSource : ProfileDataSource {
|
||||||
import account.data_impl_sqlite;
|
import account.data_impl_sqlite;
|
||||||
import transaction.data;
|
import transaction.data;
|
||||||
import transaction.data_impl_sqlite;
|
import transaction.data_impl_sqlite;
|
||||||
|
import attachment.data;
|
||||||
|
import attachment.data_impl_sqlite;
|
||||||
|
|
||||||
const SCHEMA = import("sql/schema.sql");
|
const SCHEMA = import("sql/schema.sql");
|
||||||
private const string dbPath;
|
private const string dbPath;
|
||||||
|
|
@ -159,35 +161,39 @@ class SqliteProfileDataSource : ProfileDataSource {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PropertiesRepository getPropertiesRepository() {
|
PropertiesRepository getPropertiesRepository() return scope {
|
||||||
return new SqlitePropertiesRepository(db);
|
return new SqlitePropertiesRepository(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
AccountRepository getAccountRepository() {
|
AttachmentRepository getAttachmentRepository() return scope {
|
||||||
|
return new SqliteAttachmentRepository(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
AccountRepository getAccountRepository() return scope {
|
||||||
return new SqliteAccountRepository(db);
|
return new SqliteAccountRepository(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
AccountJournalEntryRepository getAccountJournalEntryRepository() {
|
AccountJournalEntryRepository getAccountJournalEntryRepository() return scope {
|
||||||
return new SqliteAccountJournalEntryRepository(db);
|
return new SqliteAccountJournalEntryRepository(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
AccountValueRecordRepository getAccountValueRecordRepository() {
|
AccountValueRecordRepository getAccountValueRecordRepository() return scope {
|
||||||
return new SqliteAccountValueRecordRepository(db);
|
return new SqliteAccountValueRecordRepository(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
TransactionVendorRepository getTransactionVendorRepository() {
|
TransactionVendorRepository getTransactionVendorRepository() return scope {
|
||||||
return new SqliteTransactionVendorRepository(db);
|
return new SqliteTransactionVendorRepository(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
TransactionCategoryRepository getTransactionCategoryRepository() {
|
TransactionCategoryRepository getTransactionCategoryRepository() return scope {
|
||||||
return new SqliteTransactionCategoryRepository(db);
|
return new SqliteTransactionCategoryRepository(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
TransactionTagRepository getTransactionTagRepository() {
|
TransactionTagRepository getTransactionTagRepository() return scope {
|
||||||
return new SqliteTransactionTagRepository(db);
|
return new SqliteTransactionTagRepository(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
TransactionRepository getTransactionRepository() {
|
TransactionRepository getTransactionRepository() return scope {
|
||||||
return new SqliteTransactionRepository(db);
|
return new SqliteTransactionRepository(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,20 +37,20 @@ void handleGetTransaction(ref ServerHttpRequest request, ref ServerHttpResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
ProfileDataSource ds = getProfileDataSource(request);
|
|
||||||
auto payload = readJsonBodyAs!AddTransactionPayload(request);
|
|
||||||
TransactionDetail txn = addTransaction(ds, payload);
|
|
||||||
import asdf : serializeToJson;
|
import asdf : serializeToJson;
|
||||||
|
auto fullPayload = parseMultipartFilesAndBody!AddTransactionPayload(request);
|
||||||
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
|
TransactionDetail txn = addTransaction(ds, fullPayload.payload, fullPayload.files);
|
||||||
string jsonStr = serializeToJson(txn);
|
string jsonStr = serializeToJson(txn);
|
||||||
response.writeBodyString(jsonStr, "application/json");
|
response.writeBodyString(jsonStr, "application/json");
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
import asdf : serializeToJson;
|
||||||
ProfileDataSource ds = getProfileDataSource(request);
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
ulong txnId = getTransactionIdOrThrow(request);
|
ulong txnId = getTransactionIdOrThrow(request);
|
||||||
auto payload = readJsonBodyAs!AddTransactionPayload(request);
|
auto fullPayload = parseMultipartFilesAndBody!AddTransactionPayload(request);
|
||||||
TransactionDetail txn = updateTransaction(ds, txnId, payload);
|
TransactionDetail txn = updateTransaction(ds, txnId, fullPayload.payload, fullPayload.files);
|
||||||
import asdf : serializeToJson;
|
|
||||||
string jsonStr = serializeToJson(txn);
|
string jsonStr = serializeToJson(txn);
|
||||||
response.writeBodyString(jsonStr, "application/json");
|
response.writeBodyString(jsonStr, "application/json");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ interface TransactionRepository {
|
||||||
Page!TransactionsListItem findAll(PageRequest pr);
|
Page!TransactionsListItem findAll(PageRequest pr);
|
||||||
Optional!TransactionDetail findById(ulong id);
|
Optional!TransactionDetail findById(ulong id);
|
||||||
TransactionDetail insert(in AddTransactionPayload data);
|
TransactionDetail insert(in AddTransactionPayload data);
|
||||||
|
void linkAttachment(ulong transactionId, ulong attachmentId);
|
||||||
TransactionDetail update(ulong transactionId, in AddTransactionPayload data);
|
TransactionDetail update(ulong transactionId, in AddTransactionPayload data);
|
||||||
void deleteById(ulong id);
|
void deleteById(ulong id);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -370,6 +370,15 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
return findById(transactionId).orElseThrow();
|
return findById(transactionId).orElseThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void linkAttachment(ulong transactionId, ulong attachmentId) {
|
||||||
|
util.sqlite.update(
|
||||||
|
db,
|
||||||
|
"INSERT INTO transaction_attachment (transaction_id, attachment_id) VALUES (?, ?)",
|
||||||
|
transactionId,
|
||||||
|
attachmentId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
TransactionDetail update(ulong transactionId, in AddTransactionPayload data) {
|
TransactionDetail update(ulong transactionId, in AddTransactionPayload data) {
|
||||||
util.sqlite.update(
|
util.sqlite.update(
|
||||||
db,
|
db,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import asdf : serdeTransformOut;
|
||||||
import std.typecons;
|
import std.typecons;
|
||||||
|
|
||||||
import transaction.model : TransactionCategory;
|
import transaction.model : TransactionCategory;
|
||||||
|
import attachment.dto;
|
||||||
import util.data;
|
import util.data;
|
||||||
import util.money;
|
import util.money;
|
||||||
|
|
||||||
|
|
@ -59,6 +60,7 @@ struct TransactionDetail {
|
||||||
Nullable!Account debitedAccount;
|
Nullable!Account debitedAccount;
|
||||||
string[] tags;
|
string[] tags;
|
||||||
LineItem[] lineItems;
|
LineItem[] lineItems;
|
||||||
|
AttachmentResponse[] attachments;
|
||||||
|
|
||||||
static struct Vendor {
|
static struct Vendor {
|
||||||
ulong id;
|
ulong id;
|
||||||
|
|
@ -102,6 +104,7 @@ struct AddTransactionPayload {
|
||||||
Nullable!ulong debitedAccountId;
|
Nullable!ulong debitedAccountId;
|
||||||
string[] tags;
|
string[] tags;
|
||||||
LineItem[] lineItems;
|
LineItem[] lineItems;
|
||||||
|
ulong[] attachmentIdsToRemove;
|
||||||
|
|
||||||
static struct LineItem {
|
static struct LineItem {
|
||||||
long valuePerItem;
|
long valuePerItem;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@ import account.data;
|
||||||
import util.money;
|
import util.money;
|
||||||
import util.pagination;
|
import util.pagination;
|
||||||
import util.data;
|
import util.data;
|
||||||
import core.internal.container.common;
|
import attachment.data;
|
||||||
|
import attachment.dto;
|
||||||
|
|
||||||
// Transactions Services
|
// Transactions Services
|
||||||
|
|
||||||
|
|
@ -24,14 +25,22 @@ Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest p
|
||||||
}
|
}
|
||||||
|
|
||||||
TransactionDetail getTransaction(ProfileDataSource ds, ulong transactionId) {
|
TransactionDetail getTransaction(ProfileDataSource ds, ulong transactionId) {
|
||||||
return ds.getTransactionRepository().findById(transactionId)
|
import std.algorithm : map;
|
||||||
|
import std.array : array;
|
||||||
|
TransactionDetail txn = ds.getTransactionRepository().findById(transactionId)
|
||||||
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||||
|
txn.attachments = ds.getAttachmentRepository().findAllByTransactionId(txn.id)
|
||||||
|
.map!(AttachmentResponse.of)
|
||||||
|
.array;
|
||||||
|
return txn;
|
||||||
}
|
}
|
||||||
|
|
||||||
TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload payload) {
|
TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload payload, in MultipartFile[] files) {
|
||||||
|
TransactionRepository txnRepo = ds.getTransactionRepository();
|
||||||
TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository();
|
TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository();
|
||||||
TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository();
|
TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository();
|
||||||
AccountRepository accountRepo = ds.getAccountRepository();
|
AccountRepository accountRepo = ds.getAccountRepository();
|
||||||
|
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
|
||||||
|
|
||||||
validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
|
validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
|
||||||
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
|
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
|
||||||
|
|
@ -39,8 +48,7 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload
|
||||||
// Add the transaction:
|
// Add the transaction:
|
||||||
ulong txnId;
|
ulong txnId;
|
||||||
ds.doTransaction(() {
|
ds.doTransaction(() {
|
||||||
TransactionRepository txRepo = ds.getTransactionRepository();
|
TransactionDetail txn = txnRepo.insert(payload);
|
||||||
TransactionDetail txn = txRepo.insert(payload);
|
|
||||||
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
||||||
if (!payload.creditedAccountId.isNull) {
|
if (!payload.creditedAccountId.isNull) {
|
||||||
jeRepo.insert(
|
jeRepo.insert(
|
||||||
|
|
@ -64,18 +72,25 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload
|
||||||
}
|
}
|
||||||
TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
|
TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
|
||||||
tagRepo.updateTags(txn.id, payload.tags);
|
tagRepo.updateTags(txn.id, payload.tags);
|
||||||
|
updateAttachments(txn.id, timestamp, payload.attachmentIdsToRemove, files, attachmentRepo, txnRepo);
|
||||||
txnId = txn.id;
|
txnId = txn.id;
|
||||||
});
|
});
|
||||||
return ds.getTransactionRepository().findById(txnId).orElseThrow();
|
return getTransaction(ds, txnId);
|
||||||
}
|
}
|
||||||
|
|
||||||
TransactionDetail updateTransaction(ProfileDataSource ds, ulong transactionId, in AddTransactionPayload payload) {
|
TransactionDetail updateTransaction(
|
||||||
|
ProfileDataSource ds,
|
||||||
|
ulong transactionId,
|
||||||
|
in AddTransactionPayload payload,
|
||||||
|
in MultipartFile[] files
|
||||||
|
) {
|
||||||
TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository();
|
TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository();
|
||||||
TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository();
|
TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository();
|
||||||
AccountRepository accountRepo = ds.getAccountRepository();
|
AccountRepository accountRepo = ds.getAccountRepository();
|
||||||
TransactionRepository transactionRepo = ds.getTransactionRepository();
|
TransactionRepository transactionRepo = ds.getTransactionRepository();
|
||||||
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
||||||
TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
|
TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
|
||||||
|
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
|
||||||
|
|
||||||
validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
|
validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
|
||||||
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
|
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
|
||||||
|
|
@ -122,17 +137,24 @@ TransactionDetail updateTransaction(ProfileDataSource ds, ulong transactionId, i
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update tags.
|
|
||||||
tagRepo.updateTags(transactionId, payload.tags);
|
tagRepo.updateTags(transactionId, payload.tags);
|
||||||
|
updateAttachments(curr.id, timestamp, payload.attachmentIdsToRemove, files, attachmentRepo, transactionRepo);
|
||||||
});
|
});
|
||||||
return transactionRepo.findById(transactionId).orElseThrow();
|
return getTransaction(ds, transactionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteTransaction(ProfileDataSource ds, ulong transactionId) {
|
void deleteTransaction(ProfileDataSource ds, ulong transactionId) {
|
||||||
TransactionRepository txnRepo = ds.getTransactionRepository();
|
TransactionRepository txnRepo = ds.getTransactionRepository();
|
||||||
|
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
|
||||||
TransactionDetail txn = txnRepo.findById(transactionId)
|
TransactionDetail txn = txnRepo.findById(transactionId)
|
||||||
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||||
|
ds.doTransaction(() {
|
||||||
|
// First delete all attachments.
|
||||||
|
foreach (a; attachmentRepo.findAllByTransactionId(txn.id)) {
|
||||||
|
attachmentRepo.remove(a.id);
|
||||||
|
}
|
||||||
txnRepo.deleteById(txn.id);
|
txnRepo.deleteById(txn.id);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateTransactionPayload(
|
private void validateTransactionPayload(
|
||||||
|
|
@ -205,6 +227,28 @@ private void validateTransactionPayload(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to add / remove attachments for a transaction.
|
||||||
|
*/
|
||||||
|
void updateAttachments(
|
||||||
|
ulong transactionId,
|
||||||
|
SysTime timestamp,
|
||||||
|
in ulong[] attachmentIdsToRemove,
|
||||||
|
in MultipartFile[] attachmentsToAdd,
|
||||||
|
AttachmentRepository attachmentRepo,
|
||||||
|
TransactionRepository txnRepo
|
||||||
|
) {
|
||||||
|
// Save & link attachment files:
|
||||||
|
foreach (file; attachmentsToAdd) {
|
||||||
|
ulong attachmentId = attachmentRepo.save(timestamp, file.name, file.contentType, file.content);
|
||||||
|
txnRepo.linkAttachment(transactionId, attachmentId);
|
||||||
|
}
|
||||||
|
// Delete attachments (this cascades to delete the link record in transaction_attachment).
|
||||||
|
foreach (idToRemove; attachmentIdsToRemove) {
|
||||||
|
attachmentRepo.remove(idToRemove);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Vendors Services
|
// Vendors Services
|
||||||
|
|
||||||
TransactionVendor[] getAllVendors(ProfileDataSource ds) {
|
TransactionVendor[] getAllVendors(ProfileDataSource ds) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
module util.data;
|
module util.data;
|
||||||
|
|
||||||
import handy_http_primitives;
|
import handy_http_primitives;
|
||||||
|
import handy_http_data.multipart;
|
||||||
import std.typecons;
|
import std.typecons;
|
||||||
|
|
||||||
Optional!T toOptional(T)(Nullable!T value) {
|
Optional!T toOptional(T)(Nullable!T value) {
|
||||||
|
|
@ -41,3 +42,86 @@ ulong getPathParamOrThrow(T = ulong)(in ServerHttpRequest req, string name) {
|
||||||
// No params matched, so throw a NOT FOUND error.
|
// No params matched, so throw a NOT FOUND error.
|
||||||
throw new HttpStatusException(HttpStatus.NOT_FOUND, "Missing required path parameter \"" ~ name ~ "\".");
|
throw new HttpStatusException(HttpStatus.NOT_FOUND, "Missing required path parameter \"" ~ name ~ "\".");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct MultipartFile {
|
||||||
|
string name;
|
||||||
|
string contentType;
|
||||||
|
ubyte[] content;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MultipartFilesAndBody(T) {
|
||||||
|
T payload;
|
||||||
|
MultipartFile[] files;
|
||||||
|
}
|
||||||
|
|
||||||
|
MultipartFilesAndBody!T parseMultipartFilesAndBody(T)(
|
||||||
|
ref ServerHttpRequest request,
|
||||||
|
string payloadPartName = "payload",
|
||||||
|
string filesPartName = "file"
|
||||||
|
) {
|
||||||
|
import asdf : deserialize, SerdeException;
|
||||||
|
import std.uni : toLower;
|
||||||
|
import std.array;
|
||||||
|
|
||||||
|
MultipartFormData formData;
|
||||||
|
try {
|
||||||
|
formData = readBodyAsMultipartFormData(request);
|
||||||
|
} catch (MultipartFormatException e) {
|
||||||
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid multipart form data.");
|
||||||
|
}
|
||||||
|
|
||||||
|
MultipartFilesAndBody!T result;
|
||||||
|
RefAppender!(MultipartFile[]) app = appender(&result.files);
|
||||||
|
foreach (element; formData.elements) {
|
||||||
|
if (toLower(element.name) == payloadPartName) {
|
||||||
|
try {
|
||||||
|
result.payload = deserialize!T(element.content);
|
||||||
|
} catch (SerdeException e) {
|
||||||
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, e.msg);
|
||||||
|
}
|
||||||
|
} else if (toLower(element.name) == filesPartName) {
|
||||||
|
app ~= parseMultipartFile(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MultipartFile parseMultipartFile(in MultipartElement e) {
|
||||||
|
import std.algorithm : splitter, count;
|
||||||
|
import std.array;
|
||||||
|
import std.string : strip;
|
||||||
|
const requiredHeaders = ["Content-Disposition", "Content-Type"];
|
||||||
|
static foreach (headerName; requiredHeaders) {
|
||||||
|
if (headerName !in e.headers || e.headers[headerName].length < 1) {
|
||||||
|
throw new HttpStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"Missing required \"" ~ headerName ~ "\" header for multipart file."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ("Content-Disposition" !in e.headers) {
|
||||||
|
throw new HttpStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"Missing required \"Content-Disposition\" header for multipart file."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
string filename;
|
||||||
|
foreach (part; e.headers["Content-Disposition"].splitter(";")) {
|
||||||
|
string[] partSegments = part.splitter("=").array;
|
||||||
|
if (count(partSegments) == 2 && partSegments[0].strip() == "filename") {
|
||||||
|
filename = partSegments[1].strip().strip("\"");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filename is null || filename.length < 1) {
|
||||||
|
throw new HttpStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"Missing filename for multipart file."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return MultipartFile(
|
||||||
|
filename,
|
||||||
|
e.headers["Content-Type"],
|
||||||
|
cast(ubyte[]) e.content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ struct Page(T) {
|
||||||
bool isLast;
|
bool isLast;
|
||||||
|
|
||||||
|
|
||||||
Page!U mapTo(U)(U function(T) fn) {
|
Page!U mapTo(U)(U delegate(T) fn) {
|
||||||
import std.algorithm : map;
|
import std.algorithm : map;
|
||||||
import std.array : array;
|
import std.array : array;
|
||||||
return Page!(U)(items.map!(fn).array, pageRequest, totalElements, totalPages, isFirst, isLast);
|
return Page!(U)(items.map!(fn).array, pageRequest, totalElements, totalPages, isFirst, isLast);
|
||||||
|
|
|
||||||
|
|
@ -202,7 +202,7 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
auto txn = addTransaction(ds, data);
|
auto txn = addTransaction(ds, data, []);
|
||||||
infoF!" Generated transaction %d"(txn.id);
|
infoF!" Generated transaction %d"(txn.id);
|
||||||
timestamp -= seconds(uniform(10, 1_000_000));
|
timestamp -= seconds(uniform(10, 1_000_000));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ export interface Account {
|
||||||
type: string
|
type: string
|
||||||
numberSuffix: string
|
numberSuffix: string
|
||||||
name: string
|
name: string
|
||||||
currency: string
|
currency: Currency
|
||||||
description: string
|
description: string
|
||||||
currentBalance: number | null
|
currentBalance: number | null
|
||||||
}
|
}
|
||||||
|
|
@ -133,8 +133,14 @@ export class AccountApiClient extends ApiClient {
|
||||||
createValueRecord(
|
createValueRecord(
|
||||||
accountId: number,
|
accountId: number,
|
||||||
payload: AccountValueRecordCreationPayload,
|
payload: AccountValueRecordCreationPayload,
|
||||||
|
attachments: File[],
|
||||||
): Promise<AccountValueRecord> {
|
): Promise<AccountValueRecord> {
|
||||||
return super.postJson(this.path + '/' + accountId + '/value-records', payload)
|
const formData = new FormData()
|
||||||
|
formData.append('payload', JSON.stringify(payload))
|
||||||
|
for (const file of attachments) {
|
||||||
|
formData.append('file', file)
|
||||||
|
}
|
||||||
|
return super.postFormData(`${this.path}/${accountId}/value-records`, formData)
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteValueRecord(accountId: number, valueRecordId: number): Promise<void> {
|
deleteValueRecord(accountId: number, valueRecordId: number): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,37 @@ export abstract class ApiClient {
|
||||||
return await r.json()
|
return await r.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async postFormData<T>(path: string, body: FormData): Promise<T> {
|
||||||
|
try {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const response = await fetch(this.baseUrl + path, {
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer ' + authStore.state?.token,
|
||||||
|
},
|
||||||
|
body: body,
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await response.text()
|
||||||
|
throw new StatusError(response.status, message)
|
||||||
|
}
|
||||||
|
return await response.json()
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) throw error
|
||||||
|
let message = 'Request to ' + path + ' failed.'
|
||||||
|
if (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'message' in error &&
|
||||||
|
typeof error.message === 'string'
|
||||||
|
) {
|
||||||
|
message = error.message
|
||||||
|
}
|
||||||
|
console.error(error)
|
||||||
|
throw new NetworkError(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected async postText(path: string, body: object | undefined = undefined): Promise<string> {
|
protected async postText(path: string, body: object | undefined = undefined): Promise<string> {
|
||||||
const r = await this.doRequest('POST', path, body)
|
const r = await this.doRequest('POST', path, body)
|
||||||
return await r.text()
|
return await r.text()
|
||||||
|
|
@ -70,6 +101,37 @@ export abstract class ApiClient {
|
||||||
return await r.json()
|
return await r.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async putFormData<T>(path: string, body: FormData): Promise<T> {
|
||||||
|
try {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const response = await fetch(this.baseUrl + path, {
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer ' + authStore.state?.token,
|
||||||
|
},
|
||||||
|
body: body,
|
||||||
|
method: 'PUT',
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await response.text()
|
||||||
|
throw new StatusError(response.status, message)
|
||||||
|
}
|
||||||
|
return await response.json()
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) throw error
|
||||||
|
let message = 'Request to ' + path + ' failed.'
|
||||||
|
if (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'message' in error &&
|
||||||
|
typeof error.message === 'string'
|
||||||
|
) {
|
||||||
|
message = error.message
|
||||||
|
}
|
||||||
|
console.error(error)
|
||||||
|
throw new NetworkError(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getApiStatus(): Promise<boolean> {
|
async getApiStatus(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await this.doRequest('GET', '/status')
|
await this.doRequest('GET', '/status')
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ export interface TransactionDetail {
|
||||||
debitedAccount: TransactionDetailAccount | null
|
debitedAccount: TransactionDetailAccount | null
|
||||||
tags: string[]
|
tags: string[]
|
||||||
lineItems: TransactionDetailLineItem[]
|
lineItems: TransactionDetailLineItem[]
|
||||||
|
attachments: TransactionDetailAttachment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TransactionDetailAccount {
|
export interface TransactionDetailAccount {
|
||||||
|
|
@ -104,6 +105,14 @@ export interface TransactionDetailLineItem {
|
||||||
category: TransactionCategory | null
|
category: TransactionCategory | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TransactionDetailAttachment {
|
||||||
|
id: number
|
||||||
|
uploadedAt: string
|
||||||
|
filename: string
|
||||||
|
contentType: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface AddTransactionPayload {
|
export interface AddTransactionPayload {
|
||||||
timestamp: string
|
timestamp: string
|
||||||
amount: number
|
amount: number
|
||||||
|
|
@ -115,6 +124,7 @@ export interface AddTransactionPayload {
|
||||||
debitedAccountId: number | null
|
debitedAccountId: number | null
|
||||||
tags: string[]
|
tags: string[]
|
||||||
lineItems: AddTransactionPayloadLineItem[]
|
lineItems: AddTransactionPayloadLineItem[]
|
||||||
|
attachmentIdsToRemove: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddTransactionPayloadLineItem {
|
export interface AddTransactionPayloadLineItem {
|
||||||
|
|
@ -205,12 +215,26 @@ export class TransactionApiClient extends ApiClient {
|
||||||
return super.getJson(this.path + '/transactions/' + id)
|
return super.getJson(this.path + '/transactions/' + id)
|
||||||
}
|
}
|
||||||
|
|
||||||
addTransaction(data: AddTransactionPayload): Promise<TransactionDetail> {
|
addTransaction(data: AddTransactionPayload, files: File[] = []): Promise<TransactionDetail> {
|
||||||
return super.postJson(this.path + '/transactions', data)
|
const formData = new FormData()
|
||||||
|
formData.append('payload', JSON.stringify(data))
|
||||||
|
for (const file of files) {
|
||||||
|
formData.append('file', file)
|
||||||
|
}
|
||||||
|
return super.postFormData(this.path + '/transactions', formData)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTransaction(id: number, data: AddTransactionPayload): Promise<TransactionDetail> {
|
updateTransaction(
|
||||||
return super.putJson(this.path + '/transactions/' + id, data)
|
id: number,
|
||||||
|
data: AddTransactionPayload,
|
||||||
|
files: File[] = [],
|
||||||
|
): Promise<TransactionDetail> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('payload', JSON.stringify(data))
|
||||||
|
for (const file of files) {
|
||||||
|
formData.append('file', file)
|
||||||
|
}
|
||||||
|
return super.putFormData(this.path + '/transactions/' + id, formData)
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteTransaction(id: number): Promise<void> {
|
deleteTransaction(id: number): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import AppButton from './AppButton.vue';
|
||||||
import { AccountApiClient, AccountValueRecordType, type Account, type AccountValueRecord, type AccountValueRecordCreationPayload } from '@/api/account';
|
import { AccountApiClient, AccountValueRecordType, type Account, type AccountValueRecord, type AccountValueRecordCreationPayload } from '@/api/account';
|
||||||
import { useProfileStore } from '@/stores/profile-store';
|
import { useProfileStore } from '@/stores/profile-store';
|
||||||
import { datetimeLocalToISO, getDatetimeLocalValueForNow } from '@/util/time';
|
import { datetimeLocalToISO, getDatetimeLocalValueForNow } from '@/util/time';
|
||||||
|
import FileSelector from './FileSelector.vue';
|
||||||
|
|
||||||
const props = defineProps<{ account: Account }>()
|
const props = defineProps<{ account: Account }>()
|
||||||
const profileStore = useProfileStore()
|
const profileStore = useProfileStore()
|
||||||
|
|
@ -17,6 +18,7 @@ const savedValueRecord: Ref<AccountValueRecord | undefined> = ref(undefined)
|
||||||
// Form data:
|
// Form data:
|
||||||
const timestamp = ref('')
|
const timestamp = ref('')
|
||||||
const amount = ref(0)
|
const amount = ref(0)
|
||||||
|
const attachments: Ref<File[]> = ref([])
|
||||||
|
|
||||||
async function show(): Promise<AccountValueRecord | undefined> {
|
async function show(): Promise<AccountValueRecord | undefined> {
|
||||||
if (!modal.value) return Promise.resolve(undefined)
|
if (!modal.value) return Promise.resolve(undefined)
|
||||||
|
|
@ -39,7 +41,7 @@ async function addValueRecord() {
|
||||||
}
|
}
|
||||||
const api = new AccountApiClient(profileStore.state)
|
const api = new AccountApiClient(profileStore.state)
|
||||||
try {
|
try {
|
||||||
savedValueRecord.value = await api.createValueRecord(props.account.id, payload)
|
savedValueRecord.value = await api.createValueRecord(props.account.id, payload, attachments.value)
|
||||||
modal.value?.close('saved')
|
modal.value?.close('saved')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
|
@ -66,6 +68,10 @@ defineExpose({ show })
|
||||||
<input type="number" v-model="amount" step="0.01" />
|
<input type="number" v-model="amount" step="0.01" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
<FormGroup>
|
||||||
|
<h5>Attachments</h5>
|
||||||
|
<FileSelector v-model:uploaded-files="attachments" />
|
||||||
|
</FormGroup>
|
||||||
</AppForm>
|
</AppForm>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:buttons>
|
<template v-slot:buttons>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, useTemplateRef, watch, type Ref } from 'vue';
|
||||||
|
import AppButton from './AppButton.vue';
|
||||||
|
|
||||||
|
interface ExistingFile {
|
||||||
|
id: number
|
||||||
|
filename: string
|
||||||
|
contentType: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class FileListItem {
|
||||||
|
constructor(
|
||||||
|
public readonly filename: string,
|
||||||
|
public readonly contentType: string,
|
||||||
|
public readonly size: number
|
||||||
|
) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExistingFileListItem extends FileListItem {
|
||||||
|
public readonly id: number
|
||||||
|
constructor(file: ExistingFile) {
|
||||||
|
super(file.filename, file.contentType, file.size)
|
||||||
|
this.id = file.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NewFileListItem extends FileListItem {
|
||||||
|
public readonly file: File
|
||||||
|
constructor(file: File) {
|
||||||
|
super(file.name, file.type, file.size)
|
||||||
|
this.file = file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
disabled?: boolean,
|
||||||
|
initialFiles?: ExistingFile[]
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
disabled: false,
|
||||||
|
initialFiles: () => []
|
||||||
|
})
|
||||||
|
const fileInput = useTemplateRef('fileInput')
|
||||||
|
|
||||||
|
const uploadedFiles = defineModel<File[]>('uploaded-files', { default: [] })
|
||||||
|
const removedFiles = defineModel<number[]>('removed-files', { default: [] })
|
||||||
|
|
||||||
|
// Internal file list model:
|
||||||
|
const files: Ref<FileListItem[]> = ref([])
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
files.value = props.initialFiles.map(f => new ExistingFileListItem(f))
|
||||||
|
// If input initial files change, reset the file selector to just those.
|
||||||
|
watch(() => props.initialFiles, () => {
|
||||||
|
files.value = props.initialFiles.map(f => new ExistingFileListItem(f))
|
||||||
|
})
|
||||||
|
// When our internal model changes, update the defined uploaded/removed files models.
|
||||||
|
watch(() => files, () => {
|
||||||
|
// Compute the set of uploaded files as just any newly uploaded file list item.
|
||||||
|
uploadedFiles.value = files.value.filter(f => f instanceof NewFileListItem).map(f => f.file)
|
||||||
|
// Compute the set of removed files as those from the set of initial files whose ID is no longer present.
|
||||||
|
const retainedExistingFileIds = files.value
|
||||||
|
.filter(f => f instanceof ExistingFileListItem)
|
||||||
|
.map(f => f.id)
|
||||||
|
removedFiles.value = props.initialFiles
|
||||||
|
.filter(f => !retainedExistingFileIds.includes(f.id))
|
||||||
|
.map(f => f.id)
|
||||||
|
}, { deep: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
function onFileInputChanged(e: Event) {
|
||||||
|
if (props.disabled) return
|
||||||
|
const inputElement = e.target as HTMLInputElement
|
||||||
|
const fileList = inputElement.files
|
||||||
|
if (fileList === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (let i = 0; i < fileList?.length; i++) {
|
||||||
|
const file = fileList.item(i)
|
||||||
|
if (file !== null) {
|
||||||
|
files.value = [...files.value, new NewFileListItem(file)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reset the input element after we've consumed the selected files.
|
||||||
|
inputElement.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileDeleteClicked(idx: number) {
|
||||||
|
if (props.disabled) return
|
||||||
|
if (idx === 0 && files.value.length === 1) {
|
||||||
|
files.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
files.value.splice(idx, 1)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="file-selector">
|
||||||
|
<div @click.prevent="">
|
||||||
|
<div v-for="file, idx in files" :key="idx" class="file-selector-item">
|
||||||
|
<div style="display: flex; align-items: center; margin-left: 1rem;">
|
||||||
|
<div>
|
||||||
|
<div>{{ file.filename }}</div>
|
||||||
|
<div style="font-size: 0.75rem;">{{ file.contentType }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<AppButton v-if="!disabled" icon="trash" button-type="button" @click="onFileDeleteClicked(idx)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input id="fileInput" type="file" multiple @change="onFileInputChanged" style="display: none;" ref="fileInput"
|
||||||
|
:disabled="disabled" />
|
||||||
|
<label for="fileInput">
|
||||||
|
<AppButton icon="upload" button-type="button" @click="fileInput?.click()" :disabled="disabled">Select a File
|
||||||
|
</AppButton>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style lang="css">
|
||||||
|
.file-selector {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-selector-item {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { AccountApiClient, type Account } from '@/api/account';
|
import { AccountApiClient, type Account } from '@/api/account';
|
||||||
|
import { formatMoney } from '@/api/data';
|
||||||
import AddValueRecordModal from '@/components/AddValueRecordModal.vue';
|
import AddValueRecordModal from '@/components/AddValueRecordModal.vue';
|
||||||
import AppButton from '@/components/AppButton.vue';
|
import AppButton from '@/components/AppButton.vue';
|
||||||
import AppPage from '@/components/AppPage.vue';
|
import AppPage from '@/components/AppPage.vue';
|
||||||
|
|
@ -81,12 +82,16 @@ async function addValueRecord() {
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Currency</th>
|
<th>Currency</th>
|
||||||
<td>{{ account.currency }}</td>
|
<td>{{ account.currency.code }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
<td>{{ account.description }}</td>
|
<td>{{ account.description }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr v-if="account.currentBalance">
|
||||||
|
<th>Current Balance</th>
|
||||||
|
<td>{{ formatMoney(account.currentBalance, account.currency) }}</td>
|
||||||
|
</tr>
|
||||||
</PropertiesTable>
|
</PropertiesTable>
|
||||||
<div>
|
<div>
|
||||||
<AppButton @click="addValueRecord()">Record Value</AppButton>
|
<AppButton @click="addValueRecord()">Record Value</AppButton>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { DataApiClient, type Currency } from '@/api/data';
|
||||||
import { TransactionApiClient, type AddTransactionPayload, type TransactionDetail, type TransactionDetailLineItem, type TransactionVendor } from '@/api/transaction';
|
import { TransactionApiClient, type AddTransactionPayload, type TransactionDetail, type TransactionDetailLineItem, type TransactionVendor } from '@/api/transaction';
|
||||||
import AppPage from '@/components/AppPage.vue';
|
import AppPage from '@/components/AppPage.vue';
|
||||||
import CategorySelect from '@/components/CategorySelect.vue';
|
import CategorySelect from '@/components/CategorySelect.vue';
|
||||||
|
import FileSelector from '@/components/FileSelector.vue';
|
||||||
import AppForm from '@/components/form/AppForm.vue';
|
import AppForm from '@/components/form/AppForm.vue';
|
||||||
import FormActions from '@/components/form/FormActions.vue';
|
import FormActions from '@/components/form/FormActions.vue';
|
||||||
import FormControl from '@/components/form/FormControl.vue';
|
import FormControl from '@/components/form/FormControl.vue';
|
||||||
|
|
@ -65,6 +66,8 @@ const tags: Ref<string[]> = ref([])
|
||||||
const selectedTagToAdd: Ref<string | null> = ref(null)
|
const selectedTagToAdd: Ref<string | null> = ref(null)
|
||||||
const customTagInput = ref('')
|
const customTagInput = ref('')
|
||||||
const customTagInputValid = ref(false)
|
const customTagInputValid = ref(false)
|
||||||
|
const attachmentsToUpload: Ref<File[]> = ref([])
|
||||||
|
const removedAttachmentIds: Ref<number[]> = ref([])
|
||||||
|
|
||||||
watch(customTagInput, (newValue: string) => {
|
watch(customTagInput, (newValue: string) => {
|
||||||
const result = newValue.match("^[a-z0-9-_]{3,32}$")
|
const result = newValue.match("^[a-z0-9-_]{3,32}$")
|
||||||
|
|
@ -130,7 +133,8 @@ async function doSubmit() {
|
||||||
tags: tags.value,
|
tags: tags.value,
|
||||||
lineItems: lineItems.value.map(i => {
|
lineItems: lineItems.value.map(i => {
|
||||||
return { ...i, categoryId: i.category?.id ?? null }
|
return { ...i, categoryId: i.category?.id ?? null }
|
||||||
})
|
}),
|
||||||
|
attachmentIdsToRemove: removedAttachmentIds.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const transactionApi = new TransactionApiClient()
|
const transactionApi = new TransactionApiClient()
|
||||||
|
|
@ -138,9 +142,9 @@ async function doSubmit() {
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
if (existingTransaction.value) {
|
if (existingTransaction.value) {
|
||||||
savedTransaction = await transactionApi.updateTransaction(existingTransaction.value?.id, payload)
|
savedTransaction = await transactionApi.updateTransaction(existingTransaction.value?.id, payload, attachmentsToUpload.value)
|
||||||
} else {
|
} else {
|
||||||
savedTransaction = await transactionApi.addTransaction(payload)
|
savedTransaction = await transactionApi.addTransaction(payload, attachmentsToUpload.value)
|
||||||
}
|
}
|
||||||
await router.replace(`/profiles/${profileStore.state.name}/transactions/${savedTransaction.id}`)
|
await router.replace(`/profiles/${profileStore.state.name}/transactions/${savedTransaction.id}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -237,6 +241,7 @@ function isEdited() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const attachmentsChanged = attachmentsToUpload.value.length > 0 || removedAttachmentIds.value.length > 0
|
||||||
|
|
||||||
return new Date(timestamp.value).toISOString() !== existingTransaction.value.timestamp ||
|
return new Date(timestamp.value).toISOString() !== existingTransaction.value.timestamp ||
|
||||||
amount.value * Math.pow(10, currency.value?.fractionalDigits ?? 0) !== existingTransaction.value.amount ||
|
amount.value * Math.pow(10, currency.value?.fractionalDigits ?? 0) !== existingTransaction.value.amount ||
|
||||||
|
|
@ -247,7 +252,8 @@ function isEdited() {
|
||||||
creditedAccountId.value !== (existingTransaction.value.creditedAccount?.id ?? null) ||
|
creditedAccountId.value !== (existingTransaction.value.creditedAccount?.id ?? null) ||
|
||||||
debitedAccountId.value !== (existingTransaction.value.debitedAccount?.id ?? null) ||
|
debitedAccountId.value !== (existingTransaction.value.debitedAccount?.id ?? null) ||
|
||||||
!tagsEqual ||
|
!tagsEqual ||
|
||||||
!lineItemsEqual
|
!lineItemsEqual ||
|
||||||
|
attachmentsChanged
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -327,6 +333,12 @@ function isEdited() {
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<h5>Attachments</h5>
|
||||||
|
<FileSelector :initial-files="existingTransaction?.attachments ?? []"
|
||||||
|
v-model:uploaded-files="attachmentsToUpload" v-model:removed-files="removedAttachmentIds" />
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
<FormActions @cancel="doCancel()" :disabled="loading || !isFormValid() || !isEdited()"
|
<FormActions @cancel="doCancel()" :disabled="loading || !isFormValid() || !isEdited()"
|
||||||
:submit-text="editing ? 'Save' : 'Add'" />
|
:submit-text="editing ? 'Save' : 'Add'" />
|
||||||
</AppForm>
|
</AppForm>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { AccountApiClient, type Account } from '@/api/account'
|
import { AccountApiClient, type Account } from '@/api/account'
|
||||||
|
import { formatMoney } from '@/api/data'
|
||||||
import AppButton from '@/components/AppButton.vue'
|
import AppButton from '@/components/AppButton.vue'
|
||||||
import HomeModule from '@/components/HomeModule.vue'
|
import HomeModule from '@/components/HomeModule.vue'
|
||||||
import { useProfileStore } from '@/stores/profile-store'
|
import { useProfileStore } from '@/stores/profile-store'
|
||||||
|
|
@ -32,6 +33,7 @@ onMounted(async () => {
|
||||||
<th>Currency</th>
|
<th>Currency</th>
|
||||||
<th>Number</th>
|
<th>Number</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
|
<th>Balance</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -40,9 +42,13 @@ onMounted(async () => {
|
||||||
<RouterLink :to="`/profiles/${profileStore.state?.name}/accounts/${account.id}`">{{ account.name }}
|
<RouterLink :to="`/profiles/${profileStore.state?.name}/accounts/${account.id}`">{{ account.name }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ account.currency }}</td>
|
<td>{{ account.currency.code }}</td>
|
||||||
<td>...{{ account.numberSuffix }}</td>
|
<td>...{{ account.numberSuffix }}</td>
|
||||||
<td>{{ account.type }}</td>
|
<td>{{ account.type }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="account.currentBalance">{{ formatMoney(account.currentBalance, account.currency) }}</span>
|
||||||
|
<span v-if="!account.currentBalance">Unknown</span>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue