Files
Umbrella/accounting/src/main/java/de/srsoftware/umbrella/accounting/AccountingModule.java
T

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