finnow/finnow-api/source/account/api.d

282 lines
12 KiB
D

/**
* This module defines the API endpoints for dealing with Accounts directly,
* including any data-transfer objects that are needed.
*/
module account.api;
import handy_http_primitives;
import handy_http_data.json;
import handy_http_handlers.path_handler;
import std.datetime;
import profile.service;
import profile.data;
import profile.api : PROFILE_PATH;
import account.model;
import account.service;
import account.dto;
import util.money;
import util.pagination;
import util.data;
import account.data;
import attachment.data;
import attachment.dto;
const ACCOUNT_PATH = PROFILE_PATH ~ "/accounts/:accountId:ulong";
@GetMapping(PROFILE_PATH ~ "/accounts")
void handleGetAccounts(ref ServerHttpRequest request, ref ServerHttpResponse response) {
import std.algorithm;
import std.array;
auto ds = getProfileDataSource(request);
auto accounts = ds.getAccountRepository().findAll()
.map!(a => AccountResponse.of(a, getBalance(ds, a.id))).array;
writeJsonBody(response, accounts);
}
@GetMapping(ACCOUNT_PATH)
void handleGetAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId");
auto ds = getProfileDataSource(request);
auto account = ds.getAccountRepository().findById(accountId)
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
writeJsonBody(response, AccountResponse.of(account, getBalance(ds, account.id)));
}
@PostMapping(PROFILE_PATH ~ "/accounts")
void handleCreateAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
auto ds = getProfileDataSource(request);
AccountCreationPayload payload = readJsonBodyAs!AccountCreationPayload(request);
// TODO: Validate the account creation payload.
AccountType type = AccountType.fromId(payload.type);
Currency currency = Currency.ofCode(payload.currency);
Account account = ds.getAccountRepository().insert(
type,
payload.numberSuffix,
payload.name,
currency,
payload.description
);
writeJsonBody(response, AccountResponse.of(account, getBalance(ds, account.id)));
}
@PutMapping(ACCOUNT_PATH)
void handleUpdateAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId");
AccountCreationPayload payload = readJsonBodyAs!AccountCreationPayload(request);
auto ds = getProfileDataSource(request);
AccountRepository repo = ds.getAccountRepository();
auto account = repo.findById(accountId).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
Account updated = repo.update(accountId, Account(
account.id,
account.createdAt,
account.archived,
AccountType.fromId(payload.type),
payload.numberSuffix,
payload.name,
Currency.ofCode(payload.currency),
payload.description
));
writeJsonBody(response, AccountResponse.of(updated, getBalance(ds, updated.id)));
}
@DeleteMapping(ACCOUNT_PATH)
void handleDeleteAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId");
auto ds = getProfileDataSource(request);
ds.getAccountRepository().deleteById(accountId);
}
@GetMapping(ACCOUNT_PATH ~ "/balance")
void handleGetAccountBalance(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId");
auto ds = getProfileDataSource(request);
SysTime timestamp = Clock.currTime(UTC());
string providedTimestamp = request.getParamAs!string("timestamp");
if (providedTimestamp != null && providedTimestamp.length > 0) {
timestamp = SysTime.fromISOExtString(providedTimestamp);
}
Optional!long balance = getBalance(ds, accountId, timestamp);
if (balance.isNull) {
response.writeBodyString("{\"balance\": null}", ContentTypes.APPLICATION_JSON);
} else {
import std.conv : to;
response.writeBodyString("{\"balance\": " ~ balance.value.to!string ~ "}", ContentTypes.APPLICATION_JSON);
}
}
@PathMapping(HttpMethod.GET, ACCOUNT_PATH ~ "/history")
void handleGetAccountHistory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamOrThrow!ulong("accountId");
PageRequest pagination = PageRequest.parse(request, PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]));
auto ds = getProfileDataSource(request);
AccountRepository accountRepo = ds.getAccountRepository();
auto page = accountRepo.getHistory(accountId, pagination);
writeHistoryResponse(response, page);
}
private string serializeAccountHistoryItem(in AccountHistoryItemResponse i) {
import asdf : serializeToJson;
if (i.type == AccountHistoryItemType.JournalEntry) {
return serializeToJson(cast(AccountHistoryJournalEntryItemResponse) i);
} else if (i.type == AccountHistoryItemType.ValueRecord) {
return serializeToJson(cast(AccountHistoryValueRecordItemResponse) i);
} else {
return serializeToJson(i);
}
}
private void writeHistoryResponse(ref ServerHttpResponse response, in Page!AccountHistoryItemResponse page) {
// Manual serialization of response due to inheritance structure.
import asdf;
import std.json;
string initialJsonObj = serializeToJson(page);
JSONValue obj = parseJSON(initialJsonObj);
obj.object["items"] = JSONValue.emptyArray;
foreach (item; page.items) {
string initialItemJson = serializeAccountHistoryItem(item);
obj.object["items"].array ~= parseJSON(initialItemJson);
}
string jsonStr = obj.toJSON();
response.writeBodyString(jsonStr, ContentTypes.APPLICATION_JSON);
}
@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/account-balances")
void handleGetTotalBalances(ref ServerHttpRequest request, ref ServerHttpResponse response) {
auto ds = getProfileDataSource(request);
auto balances = getTotalBalanceForAllAccounts(ds);
writeJsonBody(response, balances);
}
@GetMapping(PROFILE_PATH ~ "/accounts/export")
void handleGetAccountsExport(ref ServerHttpRequest request, ref ServerHttpResponse response) {
import std.algorithm;
import std.array;
import std.conv : to;
import util.csv;
import streams;
auto pc = getProfileContextOrThrow(request);
auto ds = getProfileDataSource(pc);
AccountResponse[] accounts = ds.getAccountRepository().findAll()
.map!(a => AccountResponse.of(a, getBalance(ds, a.id))).array;
response.headers.add("Content-Type", "text/csv");
response.headers.add("Content-Disposition", "attachment; filename=accounts.csv");
CsvStreamWriter!(OutputStream!(ubyte)*) csv = CsvStreamWriter!(OutputStream!(ubyte)*)(&response.outputStream);
csv
.append("ID")
.append("Name")
.append("Number Suffix")
.append("Currency")
.append("Type")
.append("Balance")
.append("Created At")
.append("Archived")
.append("Description")
.newLine();
foreach (account; accounts) {
csv
.append(account.id)
.append(account.name)
.append(account.numberSuffix)
.append(account.currency.code)
.append(account.type)
.append(account.currentBalance
.mapIfPresent!((b) {
auto money = MoneyValue(account.currency, b);
auto n = money.toFloatingPoint();
import std.format;
return format("%." ~ account.currency.fractionalDigits.to!string ~ "f", n);
})
.orElse("")
)
.append(account.createdAt)
.append(account.archived)
.append(account.description)
.newLine();
}
}
// Value records:
const PageRequest VALUE_RECORD_DEFAULT_PAGE_REQUEST = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]);
@PathMapping(HttpMethod.GET, ACCOUNT_PATH ~ "/value-records")
void handleGetValueRecords(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId");
auto ds = getProfileDataSource(request);
scope attachmentRepo = ds.getAttachmentRepository();
auto page = ds.getAccountValueRecordRepository()
.findAllByAccountId(accountId, PageRequest.parse(request, VALUE_RECORD_DEFAULT_PAGE_REQUEST))
.mapTo!()((vr) => AccountValueRecordResponse.of(vr, attachmentRepo));
writeJsonBody(response, page);
}
@PathMapping(HttpMethod.GET, ACCOUNT_PATH ~ "/value-records/:valueRecordId:ulong")
void handleGetValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId");
ulong valueRecordId = request.getPathParamAs!ulong("valueRecordId");
auto ds = getProfileDataSource(request);
auto attachmentRepo = ds.getAttachmentRepository();
auto record = ds.getAccountValueRecordRepository().findById(accountId, valueRecordId)
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
writeJsonBody(response, AccountValueRecordResponse.of(record, attachmentRepo));
}
@PathMapping(HttpMethod.POST, ACCOUNT_PATH ~ "/value-records")
void handleCreateValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId");
ProfileDataSource ds = getProfileDataSource(request);
Account account = ds.getAccountRepository().findById(accountId)
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
AccountValueRecordRepository valueRecordRepo = ds.getAccountValueRecordRepository();
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
auto fullPayload = parseMultipartFilesAndBody!ValueRecordCreationPayload(request);
ValueRecordCreationPayload payload = fullPayload.payload;
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp);
AccountValueRecordType type = AccountValueRecordType.BALANCE; // TODO: Support more types.
ulong valueRecordId;
ds.doTransaction(() {
AccountValueRecord record = valueRecordRepo.insert(
timestamp,
account.id,
type,
payload.value,
account.currency
);
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
)
);
}
@PathMapping(HttpMethod.DELETE, ACCOUNT_PATH ~ "/value-records/:valueRecordId:ulong")
void handleDeleteValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId");
ulong valueRecordId = request.getPathParamAs!ulong("valueRecordId");
ProfileDataSource ds = getProfileDataSource(request);
AccountValueRecordRepository valueRecordRepo = ds.getAccountValueRecordRepository();
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);
});
}