854cdded3d
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
363 lines
14 KiB
Java
363 lines
14 KiB
Java
/* © SRSoftware 2025 */
|
|
package de.srsoftware.umbrella.accounting;
|
|
|
|
import static de.srsoftware.tools.Optionals.nullIfEmpty;
|
|
import static de.srsoftware.umbrella.accounting.Constants.CONFIG_DATABASE;
|
|
import static de.srsoftware.umbrella.core.ConnectionProvider.connect;
|
|
import static de.srsoftware.umbrella.core.ModuleRegistry.tagService;
|
|
import static de.srsoftware.umbrella.core.ModuleRegistry.userService;
|
|
import static de.srsoftware.umbrella.core.Util.mapValues;
|
|
import static de.srsoftware.umbrella.core.constants.Path.*;
|
|
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.invalidField;
|
|
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.missingField;
|
|
import static java.lang.System.Logger.Level.WARNING;
|
|
|
|
import com.sun.net.httpserver.HttpExchange;
|
|
import de.srsoftware.configuration.Configuration;
|
|
import de.srsoftware.tools.Path;
|
|
import de.srsoftware.tools.SessionToken;
|
|
import de.srsoftware.umbrella.core.BaseHandler;
|
|
import de.srsoftware.umbrella.core.ModuleRegistry;
|
|
import de.srsoftware.umbrella.core.api.AccountingService;
|
|
import de.srsoftware.umbrella.core.constants.Field;
|
|
import de.srsoftware.umbrella.core.constants.Text;
|
|
import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
|
|
import de.srsoftware.umbrella.core.model.*;
|
|
import java.io.IOException;
|
|
import java.time.LocalDate;
|
|
import java.time.LocalDateTime;
|
|
import java.util.*;
|
|
import org.json.JSONArray;
|
|
import org.json.JSONObject;
|
|
|
|
public class AccountingModule extends BaseHandler implements AccountingService {
|
|
private final AccountDb accountDb;
|
|
|
|
public AccountingModule(Configuration config) throws UmbrellaException {
|
|
super();
|
|
var dbFile = config.get(CONFIG_DATABASE).orElseThrow(() -> missingField(CONFIG_DATABASE));
|
|
accountDb = new SqliteDb(connect(dbFile));
|
|
ModuleRegistry.add(this);
|
|
}
|
|
|
|
@Override
|
|
public boolean doDelete(Path path, HttpExchange ex) throws IOException {
|
|
addCors(ex);
|
|
try {
|
|
Optional<Token> token = SessionToken.from(ex).map(Token::of);
|
|
var user = userService().loadUser(token);
|
|
if (user.isEmpty()) return unauthorized(ex);
|
|
var head = path.pop();
|
|
return switch (head) {
|
|
case TRANSACTION -> {
|
|
try {
|
|
var transaction = accountDb.loadTransaction(Long.parseLong(path.pop()));
|
|
yield dropTransaction(transaction, user.get(), path, ex);
|
|
} catch (NumberFormatException ignored) {
|
|
yield super.doDelete(path,ex);
|
|
}
|
|
}
|
|
case null, default -> super.doDelete(path, ex);
|
|
};
|
|
} catch (UmbrellaException e){
|
|
return send(ex,e);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public boolean doGet(Path path, HttpExchange ex) throws IOException {
|
|
addCors(ex);
|
|
try {
|
|
Optional<Token> token = SessionToken.from(ex).map(Token::of);
|
|
var user = userService().loadUser(token);
|
|
if (user.isEmpty()) return unauthorized(ex);
|
|
var head = path.pop();
|
|
return switch (head) {
|
|
case null -> getAccounts(user.get(),ex);
|
|
default -> {
|
|
try {
|
|
yield getAccount(user.get(),Long.parseLong(head),ex);
|
|
} catch (NumberFormatException ignored) {}
|
|
yield super.doGet(path,ex);
|
|
}
|
|
};
|
|
} catch (UmbrellaException e){
|
|
return send(ex,e);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public boolean doPatch(Path path, HttpExchange ex) throws IOException {
|
|
addCors(ex);
|
|
try {
|
|
var user = userService().refreshSession(ex);
|
|
if (user.isEmpty()) return unauthorized(ex);
|
|
var head = path.pop();
|
|
return switch (head) {
|
|
case TRANSACTION -> {
|
|
try {
|
|
var tid = Long.parseLong(path.pop());
|
|
yield patchTransaction(user.get(),tid,ex);
|
|
} catch (NumberFormatException ignored) {
|
|
yield super.doPatch(path,ex);
|
|
}
|
|
|
|
}
|
|
case null, default -> super.doPatch(path, ex);
|
|
};
|
|
} catch (UmbrellaException e){
|
|
return send(ex,e);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public boolean doPost(Path path, HttpExchange ex) throws IOException {
|
|
addCors(ex);
|
|
try {
|
|
var user = userService().refreshSession(ex);
|
|
if (user.isEmpty()) return unauthorized(ex);
|
|
var head = path.pop();
|
|
return switch (head) {
|
|
case null -> postEntry(user.get(),ex);
|
|
case DESTINATIONS -> postSearchDestinations(user.get(),ex);
|
|
case PURPOSES -> postSearchPurposes(user.get(),ex);
|
|
case SOURCES -> postSearchSources(user.get(),ex);
|
|
default -> {
|
|
try {
|
|
var accountId = Long.parseLong(head);
|
|
yield postToAccount(accountId,path,user.get(),ex);
|
|
} catch (NumberFormatException ignored) {
|
|
yield super.doPost(path,ex);
|
|
}
|
|
|
|
}
|
|
};
|
|
} catch (UmbrellaException e){
|
|
return send(ex,e);
|
|
}
|
|
}
|
|
|
|
public boolean dropTransaction(Transaction transaction, UmbrellaUser user, Path path, HttpExchange ex) throws IOException {
|
|
var head = path.pop();
|
|
return switch (head){
|
|
case TAG -> dropTransactionTag(user,transaction,ex);
|
|
case null, default -> super.doDelete(path,ex);
|
|
};
|
|
}
|
|
|
|
private boolean dropTransactionTag(UmbrellaUser user, Transaction transaction, HttpExchange ex) throws IOException {
|
|
LOG.log(WARNING,"Missing permission check in AccountModule.dropTransactionTag!");
|
|
var json = json(ex);
|
|
if (!json.has(Field.TAG)) throw missingField(Field.TAG);
|
|
var tag = json.getString(Field.TAG);
|
|
if (tag.isBlank()) throw invalidField(Field.TAG,"non-empty");
|
|
accountDb.dropTransactionTag(transaction.id(),tag);
|
|
transaction.tags().remove(tag);
|
|
return sendContent(ex, transaction);
|
|
}
|
|
|
|
private List<String> egalize(Set<String> tags, String key) {
|
|
var result = new HashSet<String>();
|
|
var lower = key.toLowerCase();
|
|
var len = key.length();
|
|
for (var tag : tags) {
|
|
if (tag.length() == key.length()) continue;
|
|
result.add(tag.toLowerCase().startsWith(lower) ? key + tag.substring(len) : tag);
|
|
}
|
|
return result.stream().sorted(String.CASE_INSENSITIVE_ORDER).toList();
|
|
}
|
|
|
|
private String extractParty(String field, JSONObject json){
|
|
if (!json.has(field)) return null;
|
|
if (!(json.get(field) instanceof JSONObject data)) return null;
|
|
if (data.has(Field.ID)) return data.get(Field.ID).toString();
|
|
if (data.has(Field.DISPLAY)) return data.get(Field.DISPLAY).toString();
|
|
return null;
|
|
}
|
|
|
|
private boolean getAccount(UmbrellaUser user, long accountId, HttpExchange ex) throws IOException {
|
|
LOG.log(WARNING,"Missing authorization check in AccountingModule.getAccount(…)!");
|
|
var account = accountDb.loadAccount(accountId);
|
|
var transactions = accountDb.loadTransactions(account);
|
|
var userMap = new HashMap<Long,UmbrellaUser>();
|
|
|
|
for (var transaction : transactions){
|
|
var source = transaction.source();
|
|
if (source.isId()) {
|
|
var userId = source.id();
|
|
if (!userMap.containsKey(userId)) userMap.put(source.id(),userService().loadUser(userId));
|
|
}
|
|
var destination = transaction.destination();
|
|
if (destination.isId()) {
|
|
var userId = destination.id();
|
|
if (!userMap.containsKey(userId)) userMap.put(destination.id(),userService().loadUser(userId));
|
|
}
|
|
}
|
|
|
|
return sendContent(ex, Map.of(
|
|
Field.ACCOUNT,account.toMap(),
|
|
Field.TRANSACTIONS,transactions.stream().map(Transaction::toMap).toList(),
|
|
Field.USER_LIST,mapValues(userMap)
|
|
));
|
|
}
|
|
|
|
private boolean getAccounts(UmbrellaUser user, HttpExchange ex) throws IOException {
|
|
return sendContent(ex,accountDb.listAccounts(user.id()).stream().map(Account::toMap));
|
|
}
|
|
|
|
|
|
|
|
private static boolean noNumbers(String s){
|
|
try {
|
|
Long.parseLong(s);
|
|
return false;
|
|
} catch (NumberFormatException e) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private boolean patchTransaction(UmbrellaUser user, long transactionId, HttpExchange ex) throws IOException {
|
|
var transaction = accountDb.loadTransaction(transactionId);
|
|
LOG.log(WARNING,"Missing permission check in patchTransaction(…)!");
|
|
var json = json(ex);
|
|
if (json.has(Field.AMOUNT)) transaction.amount(json.getDouble(Field.AMOUNT));
|
|
if (json.has(Field.DATE)) transaction.date(LocalDate.parse(json.getString(Field.DATE)));
|
|
if (json.has(Field.DESTINATION)) transaction.destination(IdOrString.of(json.getString(Field.DESTINATION)));
|
|
if (json.has(Field.PURPOSE)) transaction.purpose(json.getString(Field.PURPOSE));
|
|
if (json.has(Field.SOURCE)) transaction.source(IdOrString.of(json.getString(Field.SOURCE)));
|
|
if (json.has(Field.TAG)) transaction.tags().add(json.getString(Field.TAG));
|
|
return sendContent(ex,accountDb.save(transaction));
|
|
}
|
|
|
|
private boolean postEntry(UmbrellaUser user, HttpExchange ex) throws IOException {
|
|
var json = json(ex);
|
|
if (!json.has(Field.ACCOUNT)) throw missingField(Field.ACCOUNT);
|
|
if (!(json.get(Field.ACCOUNT) instanceof JSONObject acc)) throw invalidField(Field.ACCOUNT,JSON);
|
|
|
|
if (!json.has(Field.DATE)) throw missingField(Field.DATE);
|
|
var date = LocalDate.parse(json.getString(Field.DATE));
|
|
var dateTime = LocalDateTime.now().withYear(date.getYear()).withMonth(date.getMonthValue()).withDayOfMonth(date.getDayOfMonth());
|
|
if (!json.has(Field.AMOUNT)) throw missingField(Field.AMOUNT);
|
|
if (!(json.get(Field.AMOUNT) instanceof Number amount)) throw invalidField(Field.AMOUNT,Text.NUMBER);
|
|
|
|
var purpose = json.has(Field.PURPOSE) ? json.getString(Field.PURPOSE) : null;
|
|
|
|
if (!json.has(Field.SOURCE)) throw missingField(Field.SOURCE);
|
|
if (!(json.get(Field.SOURCE) instanceof JSONObject sourceData)) throw invalidField(Field.SOURCE,JSON);
|
|
if (!json.has(Field.DESTINATION)) throw missingField(Field.DESTINATION);
|
|
if (!(json.get(Field.DESTINATION) instanceof JSONObject destinationData)) throw invalidField(Field.DESTINATION,JSON);
|
|
|
|
IdOrString source = null, destination = null;
|
|
|
|
if (sourceData.has(Field.ID)) {
|
|
if (!(sourceData.get(Field.ID) instanceof Number uid)) throw invalidField(String.join(".",Field.SOURCE,Field.ID),Text.NUMBER);
|
|
source = IdOrString.of(userService().loadUser(uid.longValue()));
|
|
} else {
|
|
if (!sourceData.has(Field.DISPLAY)) throw missingField(String.join(".",Field.SOURCE,Field.DISPLAY));
|
|
source = IdOrString.of(sourceData.getString(Field.DISPLAY));
|
|
if (source.value().isBlank()) throw invalidField(String.join(".",Field.SOURCE,Field.DISPLAY),Text.STRING);
|
|
}
|
|
|
|
if (destinationData.has(Field.ID)) {
|
|
if (!(destinationData.get(Field.ID) instanceof Number uid)) throw invalidField(String.join(".",Field.DESTINATION,Field.ID),Text.NUMBER);
|
|
destination = IdOrString.of(userService().loadUser(uid.longValue()));
|
|
} else {
|
|
if (!destinationData.has(Field.DISPLAY)) throw missingField(String.join(".",Field.DESTINATION,Field.DISPLAY));
|
|
destination = IdOrString.of(destinationData.getString(Field.DISPLAY));
|
|
if (destination.value().isBlank()) throw invalidField(String.join(".",Field.DESTINATION,Field.DISPLAY),Text.STRING);
|
|
}
|
|
|
|
|
|
Long accountId = null;
|
|
if (acc.has(Field.ID)) {
|
|
if (!(acc.get(Field.ID) instanceof Number accId)) throw invalidField(String.join(".",Field.ACCOUNT,Field.ID), Text.NUMBER);
|
|
if (accId.longValue() != 0) accountId = accId.longValue();
|
|
}
|
|
Account newAccount = null;
|
|
if (accountId == null){
|
|
if (!acc.has(Field.NAME)) throw missingField(String.join(".",Field.ACCOUNT,Field.NAME));
|
|
var accountName = acc.getString(Field.NAME);
|
|
if (accountName.isBlank()) throw invalidField(String.join(".",Field.ACCOUNT,Field.NAME),Text.STRING);
|
|
|
|
var currency = acc.has(Field.CURRENCY) ? nullIfEmpty(acc.getString(Field.CURRENCY)) : null;
|
|
|
|
newAccount = accountDb.save(new Account(0, accountName, currency, user.id()));
|
|
accountId = newAccount.id();
|
|
}
|
|
|
|
var tagList = json.has(Field.TAGS) && json.get(Field.TAGS) instanceof JSONArray t ? t : List.of();
|
|
var tags = new HashSet<String>();
|
|
tagList.forEach(e -> tags.add(e.toString()));
|
|
|
|
|
|
var transaction = accountDb.save(new Transaction(0,accountId,dateTime,source,destination,amount.doubleValue(),purpose,tags));
|
|
|
|
return sendContent(ex,newAccount != null ? newAccount : transaction);
|
|
}
|
|
|
|
public boolean postGetLastTransaction(long accountId, UmbrellaUser user, HttpExchange ex) throws IOException {
|
|
var json = json(ex);
|
|
if (!json.has(Field.SOURCE)) throw missingField(Field.SOURCE);
|
|
if (!(json.get(Field.SOURCE) instanceof JSONObject src)) throw invalidField(Field.SOURCE,JSON);
|
|
var source = src.get(src.has(Field.ID) ? Field.ID : Field.DISPLAY).toString();
|
|
if (!json.has(Field.DESTINATION)) throw missingField(Field.DESTINATION);
|
|
if (!(json.get(Field.DESTINATION) instanceof JSONObject dst)) throw invalidField(Field.SOURCE,JSON);
|
|
var dest = dst.get(dst.has(Field.ID) ? Field.ID : Field.DISPLAY).toString();
|
|
if (!json.has(Field.AMOUNT)) throw missingField(Field.AMOUNT);
|
|
if (!(json.get(Field.AMOUNT) instanceof Number amt)) throw invalidField(Field.AMOUNT,Text.NUMBER);
|
|
var amount = amt.doubleValue();
|
|
|
|
var transaction = accountDb.lastTransaction(accountId, source, dest, amount);
|
|
return transaction.isPresent() ? sendContent(ex,transaction.get()) : notFound(ex);
|
|
}
|
|
|
|
public boolean postSearchDestinations(UmbrellaUser user, HttpExchange ex) throws IOException {
|
|
return sendContent(ex,searchOptions(user, Field.DESTINATION, body(ex)));
|
|
}
|
|
|
|
public boolean postSearchPurposes(UmbrellaUser user, HttpExchange ex) throws IOException {
|
|
var key = body(ex);
|
|
var purposes = accountDb.searchField(user.id(),Field.DESCRIPTION,key);
|
|
return sendContent(ex,purposes);
|
|
}
|
|
|
|
|
|
public boolean postSearchSources(UmbrellaUser user, HttpExchange ex) throws IOException {
|
|
return sendContent(ex,searchOptions(user, Field.SOURCE, body(ex)));
|
|
}
|
|
|
|
private boolean postSearchTags(long accountId, UmbrellaUser user, HttpExchange ex) throws IOException {
|
|
LOG.log(WARNING,"Missing authorization check in AccountingModule.getAccount(…)!");
|
|
var key = body(ex);
|
|
if (!key.trim().startsWith("{")) { // search tags that contain value of body
|
|
var tags = accountDb.searchTagsContaining(key, accountId);
|
|
if (tags.size() < 10) tags.addAll(tagService().search(key, user));
|
|
return sendContent(ex, egalize(tags, key));
|
|
}
|
|
// search tags for account with specified source and destination
|
|
var json = new JSONObject(key);
|
|
var src = extractParty(Field.SOURCE, json);
|
|
var dst = extractParty(Field.DESTINATION, json);
|
|
var tags = accountDb.listTags(accountId,src,dst);
|
|
return sendContent(ex,tags);
|
|
}
|
|
|
|
private boolean postToAccount(long accountId, Path path, UmbrellaUser user, HttpExchange ex) throws IOException {
|
|
var head = path.pop();
|
|
return switch (head) {
|
|
case PURPOSES -> postGetLastTransaction(accountId,user,ex);
|
|
case TAGS -> postSearchTags(accountId,user,ex);
|
|
case null, default -> super.doPost(path,ex);
|
|
};
|
|
}
|
|
|
|
public ArrayList<Map<String,?>> searchOptions(UmbrellaUser user, String field, String key){
|
|
var users = userService().search(key);
|
|
var destinations = accountDb.searchField(user.id(), field, key);
|
|
var optionList = new ArrayList<Map<String,?>>();
|
|
users.values().stream().map(UmbrellaUser::toMap).forEach(optionList::add);
|
|
destinations.stream().filter(AccountingModule::noNumbers).map(s -> Map.of(Field.DISPLAY,s)).forEach(optionList::add);
|
|
return optionList;
|
|
}
|
|
}
|