diff --git a/accounting/build.gradle.kts b/accounting/build.gradle.kts new file mode 100644 index 00000000..efceaa32 --- /dev/null +++ b/accounting/build.gradle.kts @@ -0,0 +1,5 @@ +description = "Umbrella : Accounting" + +dependencies{ + implementation(project(":core")) +} \ No newline at end of file diff --git a/accounting/src/main/java/de/srsoftware/umbrella/accounting/AccountDb.java b/accounting/src/main/java/de/srsoftware/umbrella/accounting/AccountDb.java new file mode 100644 index 00000000..feb2b953 --- /dev/null +++ b/accounting/src/main/java/de/srsoftware/umbrella/accounting/AccountDb.java @@ -0,0 +1,28 @@ +/* © SRSoftware 2025 */ +package de.srsoftware.umbrella.accounting; + +import de.srsoftware.umbrella.core.model.Account; +import de.srsoftware.umbrella.core.model.Transaction; +import java.util.Collection; +import java.util.List; +import java.util.Set; + +public interface AccountDb { + void dropTransactionTag(long transactionId, String tag); + + Collection listAccounts(long userId); + + Account loadAccount(long accountId); + + Transaction loadTransaction(long transactionId); + + List loadTransactions(Account account); + + Account save(Account account); + + Transaction save(Transaction transaction); + + Set searchField(long userId, String field, String key); + + Set searchTagsContaining(String key, long accountId); +} diff --git a/accounting/src/main/java/de/srsoftware/umbrella/accounting/AccountingModule.java b/accounting/src/main/java/de/srsoftware/umbrella/accounting/AccountingModule.java new file mode 100644 index 00000000..216cc61d --- /dev/null +++ b/accounting/src/main/java/de/srsoftware/umbrella/accounting/AccountingModule.java @@ -0,0 +1,329 @@ +/* © 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 = 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 = 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 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(); + + 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(); + 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 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); + var tags = accountDb.searchTagsContaining(key,accountId); + if (tags.size()<10) tags.addAll(tagService().search(key,user)); + return sendContent(ex,egalize(tags,key)); + } + + private List egalize(Set tags, String key) { + var result = new HashSet(); + 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 boolean postToAccount(long accountId, Path path, UmbrellaUser user, HttpExchange ex) throws IOException { + return switch (path.pop()) { + case TAGS -> postSearchTags(accountId,user,ex); + case null, default -> super.doPost(path,ex); + }; + } + + public ArrayList> searchOptions(UmbrellaUser user, String field, String key){ + var users = userService().search(key); + var destinations = accountDb.searchField(user.id(), field, key); + var optionList = new ArrayList>(); + 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; + } +} diff --git a/accounting/src/main/java/de/srsoftware/umbrella/accounting/Constants.java b/accounting/src/main/java/de/srsoftware/umbrella/accounting/Constants.java new file mode 100644 index 00000000..eb281211 --- /dev/null +++ b/accounting/src/main/java/de/srsoftware/umbrella/accounting/Constants.java @@ -0,0 +1,11 @@ +/* © SRSoftware 2025 */ +package de.srsoftware.umbrella.accounting; + +public class Constants { + public static final String CONFIG_DATABASE = "umbrella.modules.accounting.database"; + + public static final String TABLE_ACCOUNTS = "accounts"; + public static final String TABLE_TAGS = "tag"; + public static final String TABLE_TAGS_TRANSACTIONS = "tags_transactions"; + public static final String TABLE_TRANSACTIONS = "transactions"; +} diff --git a/accounting/src/main/java/de/srsoftware/umbrella/accounting/SqliteDb.java b/accounting/src/main/java/de/srsoftware/umbrella/accounting/SqliteDb.java new file mode 100644 index 00000000..e9161147 --- /dev/null +++ b/accounting/src/main/java/de/srsoftware/umbrella/accounting/SqliteDb.java @@ -0,0 +1,308 @@ +/* © SRSoftware 2025 */ +package de.srsoftware.umbrella.accounting; + +import static de.srsoftware.tools.NotImplemented.notImplemented; +import static de.srsoftware.tools.jdbc.Condition.*; +import static de.srsoftware.tools.jdbc.Query.*; +import static de.srsoftware.tools.jdbc.Query.SelectQuery.ALL; +import static de.srsoftware.umbrella.accounting.Constants.*; +import static de.srsoftware.umbrella.core.constants.Field.*; +import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.*; +import static de.srsoftware.umbrella.core.model.Translatable.t; +import static java.text.MessageFormat.format; + +import de.srsoftware.tools.jdbc.Condition; +import de.srsoftware.tools.jdbc.Query; +import de.srsoftware.umbrella.core.BaseDb; +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.Account; +import de.srsoftware.umbrella.core.model.Transaction; +import java.sql.Connection; +import java.sql.SQLException; +import java.time.ZoneOffset; +import java.util.*; + +public class SqliteDb extends BaseDb implements AccountDb { + public SqliteDb(Connection connection) { + super(connection); + } + + @Override + protected int createTables() { + var version = createSettingsTable(); + switch (version){ + case 0: + createAccountsTable(); + createTransactionsTable(); + createTagsTable(); + createTagsTransactionsTable(); + } + return setCurrentVersion(1); + } + + private void createAccountsTable() { + var sql = """ + CREATE TABLE IF NOT EXISTS {0} ( + {1} INTEGER PRIMARY KEY, + {2} VARCHAR(255) NOT NULL, + {3} LONG NOT NULL, + {4} CURRENCY VARCHAR(16) + );"""; + + try { + sql = format(sql,TABLE_ACCOUNTS, ID, Field.NAME, Field.OWNER, Field.CURRENCY); + var stmt = db.prepareStatement(sql); + stmt.execute(); + stmt.close(); + } catch (SQLException e) { + throw failedToCreateTable(TABLE_ACCOUNTS).causedBy(e); + } + } + + private void createTagsTable(){ + var sql = """ + CREATE TABLE IF NOT EXISTS {0} ( + {1} INTEGER PRIMARY KEY, + {2} VARCHAR(32) UNIQUE NOT NULL + ); + """; + try { + sql = format(sql,TABLE_TAGS, ID,Field.TAG); + var stmt = db.prepareStatement(sql); + stmt.execute(); + stmt.close(); + } catch (SQLException e) { + throw failedToCreateTable(TABLE_TAGS).causedBy(e); + } + } + + private void createTagsTransactionsTable(){ + var sql = """ + CREATE TABLE IF NOT EXISTS {0} ( + {1} LONG NOT NULL, + {2} LONG NOT NULL, + PRIMARY KEY({1}, {2}) + ); + """; + try { + sql = format(sql,TABLE_TAGS_TRANSACTIONS,Field.TRANSACTION_ID,Field.TAG_ID); + var stmt = db.prepareStatement(sql); + stmt.execute(); + stmt.close(); + } catch (SQLException e) { + throw failedToCreateTable(TABLE_TAGS).causedBy(e); + } + } + + private void createTransactionsTable() { + var sql = """ + CREATE TABLE IF NOT EXISTS {0} ( + {7} INTEGER PRIMARY KEY, + {1} LONG NOT NULL, + {2} LONG NOT NULL, + {3} VARCHAR(255) NOT NULL, + {4} VARCHAR(255) NOT NULL, + {5} DOUBLE NOT NULL, + {6} TEXT + );"""; + try { + sql = format(sql,TABLE_TRANSACTIONS,Field.ACCOUNT,Field.TIMESTAMP,Field.SOURCE,Field.DESTINATION, Field.AMOUNT,Field.DESCRIPTION, ID); + var stmt = db.prepareStatement(sql); + stmt.execute(); + stmt.close(); + } catch (SQLException e) { + throw failedToCreateTable(TABLE_TRANSACTIONS).causedBy(e); + } + } + + @Override + public void dropTransactionTag(long transactionId, String tag) { + try { + var tagIds = new HashSet(); + var rs = select(ID).from(TABLE_TAGS).where(TAG, equal(tag)).exec(db); + while (rs.next()) tagIds.add(rs.getLong(1)); + rs.close(); + + delete().from(TABLE_TAGS_TRANSACTIONS).where(TRANSACTION_ID, equal(transactionId)).where(TAG_ID, in(tagIds.toArray())).execute(db); + } catch (SQLException e) { + throw failedToDropObject(tag); + } + } + + @Override + public HashSet listAccounts(long userId) { + try { + var accountIds = new HashSet(); + var rs = select("DISTINCT " + Field.ACCOUNT).from(TABLE_TRANSACTIONS).where(Field.SOURCE, equal(userId)).exec(db); + while (rs.next()) accountIds.add(rs.getLong(1)); + rs.close(); + rs = select("DISTINCT " + Field.ACCOUNT).from(TABLE_TRANSACTIONS).where(Field.DESTINATION, equal(userId)).exec(db); + while (rs.next()) accountIds.add(rs.getLong(1)); + rs.close(); + var accounts = new HashSet(); + rs = select(ALL).from(TABLE_ACCOUNTS).where(ID, Condition.in(accountIds.toArray())).exec(db); + while (rs.next()) accounts.add(Account.of(rs)); + rs.close(); + return accounts; + } catch (SQLException e){ + throw failedToLoadObject(Text.ACCOUNTING).causedBy(e); + } + } + + @Override + public Account loadAccount(long accountId) { + try { + var rs = select(ALL).from(TABLE_ACCOUNTS).where(ID,equal(accountId)).exec(db); + Account account = null; + if (rs.next()) account = Account.of(rs); + rs.close(); + if (account==null) throw failedToLoadObject(Text.ACCOUNT,accountId); + return account; + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + @Override + public Transaction loadTransaction(long transactionId) { + try { + Transaction transaction = null; + var rs = select(ALL).from(TABLE_TRANSACTIONS).where(ID,equal(transactionId)).exec(db); + if (rs.next()) transaction = Transaction.of(rs); + rs.close(); + if (transaction != null) return transaction; + throw failedToLoadObject(Text.TRANSACTION,transactionId); + } catch (SQLException e) { + throw failedToLoadObject(Text.TRANSACTION); + } + } + + @Override + public List loadTransactions(Account account) { + try { + var transactions = new HashMap(); + var rs = select(ALL).from(TABLE_TRANSACTIONS).where(Field.ACCOUNT,equal(account.id())).exec(db); + while (rs.next()) { + var transaction = Transaction.of(rs); + transactions.put(transaction.id(),transaction); + } + rs.close(); + var transactionIds = transactions.keySet().toArray(); + rs = select(ALL).from(TABLE_TAGS_TRANSACTIONS).leftJoin(Field.TAG_ID,TABLE_TAGS, ID).where(Field.TRANSACTION_ID,in(transactionIds)).exec(db); + while (rs.next()) { + var transaction = transactions.get(rs.getLong(Field.TRANSACTION_ID)); + if (transaction != null) transaction.tags().add(rs.getString(Field.TAG)); + } + rs.close(); + return transactions.values().stream().sorted(Comparator.comparing(Transaction::date)).toList(); + } catch (SQLException e) { + throw failedToLoadMembers(account); + } + } + + @Override + public Account save(Account account) { + if (account.id() == 0) try { // new account + var rs = Query.insertInto(TABLE_ACCOUNTS,Field.NAME, Field.CURRENCY, Field.OWNER).values(account.name(),account.currency(),account.ownerId()).execute(db).getGeneratedKeys(); + Long newId = null; + if (rs.next()) newId = rs.getLong(1); + rs.close(); + if (newId == null) throw failedToStoreObject(account); + return account.withId(newId); + } catch (SQLException e) { + throw failedToStoreObject(account).causedBy(e); + } else { + throw notImplemented(this,"save(account)"); + } + } + + @Override + public Transaction save(Transaction transaction) { + var timestamp = transaction.date().toEpochSecond(ZoneOffset.UTC); + if (transaction.id() == 0) { + try { + var rs = insertInto(TABLE_TRANSACTIONS, Field.ACCOUNT, Field.TIMESTAMP, Field.SOURCE, Field.DESTINATION, Field.AMOUNT, Field.DESCRIPTION) + .values(transaction.accountId(), timestamp, transaction.source().value(), transaction.destination().value(), transaction.amount(), transaction.purpose()) + .execute(db).getGeneratedKeys(); + if (rs.next()) transaction = transaction.withId(rs.getLong(1)); + rs.close(); + } catch (SQLException e) { + throw failedToStoreObject(transaction); + } + } else if (transaction.isDirty()) { + try { + if (transaction.amount() == 0) { + delete().from(TABLE_TRANSACTIONS).where(Field.ID, equal(transaction.id())).where(ACCOUNT, equal(transaction.accountId())).execute(db); + } else { + replaceInto(TABLE_TRANSACTIONS, Field.ID, Field.ACCOUNT, Field.TIMESTAMP, Field.SOURCE, Field.DESTINATION, Field.AMOUNT, Field.DESCRIPTION) + .values(transaction.id(), transaction.accountId(), timestamp, transaction.source().value(), transaction.destination().value(), transaction.amount(), transaction.purpose()) + .execute(db).close(); + } + return transaction.clearDirtyState(); + } catch (SQLException e) { + throw failedToStoreObject(transaction); + } + } + return saveTags(transaction); + } + + private Transaction saveTags(Transaction transaction) { + var remaining = new HashSet<>(transaction.tags()); + var existingTags = new HashMap(); + try { + var rs = select(ALL).from(TABLE_TAGS).where(Field.TAG,in(transaction.tags().toArray())).exec(db); + while (rs.next()) existingTags.put(rs.getString(Field.TAG), rs.getLong(ID)); + rs.close(); + } catch (SQLException e){ + throw failedToLoadMembers(transaction); + } + remaining.removeAll(existingTags.keySet()); + for (var tag : remaining) { + try { + var rs = insertInto(TABLE_TAGS, Field.TAG).values(tag).execute(db).getGeneratedKeys(); + if (rs.next()) existingTags.put(tag,rs.getLong(1)); + rs.close(); + } catch (SQLException e){ + throw failedToStoreObject(tag); + } + } + try { + var query = replaceInto(TABLE_TAGS_TRANSACTIONS, Field.TRANSACTION_ID, Field.TAG_ID); + for (var tag_id : existingTags.values()) query.values(transaction.id(), tag_id); + query.execute(db).close(); + } catch (SQLException e){ + throw failedToStoreObject(transaction); + } + return transaction; + } + + @Override + public HashSet searchField(long userId, String field , String key) { + var accounts = listAccounts(userId); + var accountIds = accounts.stream().map(Account::id).toArray(); + var destinations = new HashSet(); + try { + var rs = Query.select("DISTINCT "+field).from(TABLE_TRANSACTIONS).where(Field.ACCOUNT,in(accountIds)).where(field,like("%"+key+"%")).exec(db); + while (rs.next()) destinations.add(rs.getString(1)); + rs.close(); + return destinations; + } catch (SQLException e) { + throw failedToReadFromTable(field,TABLE_TRANSACTIONS).causedBy(e); + } + } + + @Override + public Set searchTagsContaining(String key, long accountId) { + try { + var tags = new HashSet(); + var rs = select(ALL).from(TABLE_TRANSACTIONS).leftJoin(ID,TABLE_TAGS_TRANSACTIONS,Field.TRANSACTION_ID).leftJoin(Field.TAG_ID,TABLE_TAGS, ID).where(Field.TAG,like(format("%{0}%",key))).exec(db); + while (rs.next()) tags.add(rs.getString(Field.TAG)); + rs.close(); + return tags; + } catch (SQLException e){ + throw failedToSearchDb(t(Text.TAGS)); + } + } +} diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 94839ead..fc93936e 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -11,6 +11,7 @@ application{ } dependencies{ + implementation(project(":accounting")); implementation(project(":bookmark")); implementation(project(":bus")); implementation(project(":company")) @@ -47,6 +48,7 @@ tasks.jar { .map(::zipTree) // OR .map { zipTree(it) } from(dependencies) dependsOn( + ":accounting:jar", ":bookmark:jar", ":bus:jar", ":company:jar", diff --git a/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java b/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java index 42e76b2a..23d87a8a 100644 --- a/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java +++ b/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java @@ -9,6 +9,7 @@ import static java.lang.System.Logger.Level.INFO; import com.sun.net.httpserver.HttpServer; import de.srsoftware.configuration.JsonConfig; import de.srsoftware.tools.ColorLogger; +import de.srsoftware.umbrella.accounting.AccountingModule; import de.srsoftware.umbrella.bookmarks.BookmarkApi; import de.srsoftware.umbrella.company.CompanyModule; import de.srsoftware.umbrella.contact.ContactModule; @@ -92,6 +93,7 @@ public class Application { new WikiModule(config).bindPath("/api/wiki").on(server); new FileModule(config).bindPath("/api/files").on(server); new SettingsService(config).bindPath("/api/settings").on(server); + new AccountingModule(config).bindPath("/api/accounting").on(server); } catch (Exception e) { LOG.log(ERROR,"Startup failed",e); System.exit(-1); diff --git a/core/src/main/java/de/srsoftware/umbrella/core/ModuleRegistry.java b/core/src/main/java/de/srsoftware/umbrella/core/ModuleRegistry.java index dff11922..5ecb60ee 100644 --- a/core/src/main/java/de/srsoftware/umbrella/core/ModuleRegistry.java +++ b/core/src/main/java/de/srsoftware/umbrella/core/ModuleRegistry.java @@ -5,6 +5,7 @@ package de.srsoftware.umbrella.core; import de.srsoftware.umbrella.core.api.*; public class ModuleRegistry { + private AccountingService accountingService; private BookmarkService bookmarkService; private CompanyService companyService; private ContactService contactService; @@ -29,23 +30,24 @@ public class ModuleRegistry { public static void add(Object service) { switch (service) { - case BookmarkService bs: singleton.bookmarkService = bs; break; - case CompanyService cs: singleton.companyService = cs; break; - case ContactService cs: singleton.contactService = cs; break; - case DocumentService ds: singleton.documentService = ds; break; - case FileService fs: singleton.fileService = fs; break; - case StockService is: singleton.stockService = is; break; - case MarkdownService ms: singleton.markdownService = ms; break; - case NoteService ns: singleton.noteService = ns; break; - case PollService ps: singleton.pollService = ps; break; - case PostBox pb: singleton.postBox = pb; break; - case ProjectService ps: singleton.projectService = ps; break; - case TagService ts: singleton.tagService = ts; break; - case TaskService ts: singleton.taskService = ts; break; - case TimeService ts: singleton.timeService = ts; break; - case Translator tr: singleton.translator = tr; break; - case UserService us: singleton.userService = us; break; - case WikiService ws: singleton.wikiService = ws; break; + case AccountingService as: singleton.accountingService = as; break; + case BookmarkService bs: singleton.bookmarkService = bs; break; + case CompanyService cs: singleton.companyService = cs; break; + case ContactService cs: singleton.contactService = cs; break; + case DocumentService ds: singleton.documentService = ds; break; + case FileService fs: singleton.fileService = fs; break; + case StockService is: singleton.stockService = is; break; + case MarkdownService ms: singleton.markdownService = ms; break; + case NoteService ns: singleton.noteService = ns; break; + case PollService ps: singleton.pollService = ps; break; + case PostBox pb: singleton.postBox = pb; break; + case ProjectService ps: singleton.projectService = ps; break; + case TagService ts: singleton.tagService = ts; break; + case TaskService ts: singleton.taskService = ts; break; + case TimeService ts: singleton.timeService = ts; break; + case Translator tr: singleton.translator = tr; break; + case UserService us: singleton.userService = us; break; + case WikiService ws: singleton.wikiService = ws; break; case null: break; default: System.getLogger(ModuleRegistry.class.getSimpleName()).log(System.Logger.Level.WARNING,"Trying to add untracked class {0}",service.getClass().getSimpleName()); } diff --git a/core/src/main/java/de/srsoftware/umbrella/core/SettingsService.java b/core/src/main/java/de/srsoftware/umbrella/core/SettingsService.java index 924519e9..9a87797a 100644 --- a/core/src/main/java/de/srsoftware/umbrella/core/SettingsService.java +++ b/core/src/main/java/de/srsoftware/umbrella/core/SettingsService.java @@ -85,6 +85,7 @@ public class SettingsService extends BaseHandler { entries.add(MenuEntry.of(13,Module.STOCK)); entries.add(MenuEntry.of(14,Module.MESSAGE, MESSAGES)); entries.add(MenuEntry.of(15,Module.POLL,Text.POLLS)); + entries.add(MenuEntry.of(16,Module.ACCOUNTING,Text.ACCOUNTING)); for (var i=0; i userIds, String tag); + Collection search(String key, UmbrellaUser user); + void updateId(String module, Object oldId, Object newId); } diff --git a/core/src/main/java/de/srsoftware/umbrella/core/api/UserService.java b/core/src/main/java/de/srsoftware/umbrella/core/api/UserService.java index ae5769e4..e8073262 100644 --- a/core/src/main/java/de/srsoftware/umbrella/core/api/UserService.java +++ b/core/src/main/java/de/srsoftware/umbrella/core/api/UserService.java @@ -22,4 +22,5 @@ public interface UserService { Optional loadUser(Optional sessionToken) throws UmbrellaException; Optional loadUser(HttpExchange ex) throws UmbrellaException; Optional refreshSession(HttpExchange ex); + Map search(String key); } diff --git a/core/src/main/java/de/srsoftware/umbrella/core/constants/Field.java b/core/src/main/java/de/srsoftware/umbrella/core/constants/Field.java index 430e524a..a8ca1d2d 100644 --- a/core/src/main/java/de/srsoftware/umbrella/core/constants/Field.java +++ b/core/src/main/java/de/srsoftware/umbrella/core/constants/Field.java @@ -3,6 +3,7 @@ package de.srsoftware.umbrella.core.constants; public class Field { public static final String ACTION = "action"; + public static final String ACCOUNT = "account"; public static final String ADDRESS = "address"; public static final String ALLOWED_STATES = "allowed_states"; public static final String AMOUNT = "amount"; @@ -43,6 +44,8 @@ public class Field { public static final String DELIVERY = "delivery"; public static final String DELIVERY_DATE = "delivery_date"; public static final String DESCRIPTION = "description"; + public static final String DESTINATION = "destination"; + public static final String DISPLAY = "display"; public static final String DOCUMENT = "document"; public static final String DOCUMENT_ID = "document_id"; public static final String DOC_TYPE_ID = "document_type_id"; @@ -127,6 +130,7 @@ public class Field { public static final String PROJECT = "project"; public static final String PROJECT_ID = "project_id"; public static final String PROPERTIES = "properties"; + public static final String PURPOSE = "purpose"; public static final String RECEIVERS = "receivers"; public static final String REDIRECT = "redirect"; @@ -153,6 +157,7 @@ public class Field { public static final String TAG = "tag"; public static final String TAGS = "tags"; public static final String TAG_COLORS = "tag_colors"; + public static final String TAG_ID = "tag_id"; public static final String TASK = "task"; public static final String TASK_IDS = "task_ids"; public static final String TASKS = "tasks"; @@ -171,6 +176,8 @@ public class Field { public static final String TO = "to"; public static final String TOKEN = "token"; public static final String TOTAL_PRIO = "total_prio"; + public static final String TRANSACTION_ID = "transaction_id"; + public static final String TRANSACTIONS = "transactions"; public static final String TYPE = "type"; public static final String TYPE_ID = "type_id"; diff --git a/core/src/main/java/de/srsoftware/umbrella/core/constants/Module.java b/core/src/main/java/de/srsoftware/umbrella/core/constants/Module.java index f3b8512d..c81df819 100644 --- a/core/src/main/java/de/srsoftware/umbrella/core/constants/Module.java +++ b/core/src/main/java/de/srsoftware/umbrella/core/constants/Module.java @@ -2,19 +2,20 @@ package de.srsoftware.umbrella.core.constants; public class Module { - public static final String BOOKMARK = "bookmark"; - public static final String COMPANY = "company"; - public static final String CONTACT = "contact"; - public static final String DOCUMENT = "document"; - public static final String FILES = "files"; - public static final String MESSAGE = "message"; - public static final String NOTES = "notes"; - public static final String POLL = "poll"; - public static final String PROJECT = "project"; - public static final String STOCK = "stock"; - public static final String TAGS = "tags"; - public static final String TASK = "task"; - public static final String TIME = "time"; - public static final String USER = "user"; - public static final String WIKI = "wiki"; + public static final String ACCOUNTING = "accounting"; + public static final String BOOKMARK = "bookmark"; + public static final String COMPANY = "company"; + public static final String CONTACT = "contact"; + public static final String DOCUMENT = "document"; + public static final String FILES = "files"; + public static final String MESSAGE = "message"; + public static final String NOTES = "notes"; + public static final String POLL = "poll"; + public static final String PROJECT = "project"; + public static final String STOCK = "stock"; + public static final String TAGS = "tags"; + public static final String TASK = "task"; + public static final String TIME = "time"; + public static final String USER = "user"; + public static final String WIKI = "wiki"; } diff --git a/core/src/main/java/de/srsoftware/umbrella/core/constants/Path.java b/core/src/main/java/de/srsoftware/umbrella/core/constants/Path.java index 5a0f7bb6..20664737 100644 --- a/core/src/main/java/de/srsoftware/umbrella/core/constants/Path.java +++ b/core/src/main/java/de/srsoftware/umbrella/core/constants/Path.java @@ -13,6 +13,8 @@ public class Path { public static final String COMPANY = "company"; public static final String CONNECTED = "connected"; + public static final String DESTINATIONS = "destinations"; + public static final String EVALUATE = "evaluate"; public static final String ITEM = "item"; @@ -37,6 +39,7 @@ public class Path { public static final String PROJECT = "project"; public static final String PROPERTIES = "properties"; public static final String PROPERTY = "property"; + public static final String PURPOSES = "purposes"; public static final String READ = "read"; public static final String REDIRECT = "redirect"; @@ -44,13 +47,17 @@ public class Path { public static final String SEARCH = "search"; public static final String SELECT = "select"; public static final String SETTINGS = "settings"; + public static final String SOURCES = "sources"; public static final String STATES = "states"; public static final String STARTED = "started"; public static final String STATE = "state"; public static final String STOP = "stop"; - public static final String TAGGED = "tagged"; - public static final String TOKEN = "token"; + public static final String TAG = "tag"; + public static final String TAGS = "tags"; + public static final String TAGGED = "tagged"; + public static final String TRANSACTION = "transaction"; + public static final String TOKEN = "token"; public static final String USER = "user"; public static final String USES = "uses"; diff --git a/core/src/main/java/de/srsoftware/umbrella/core/constants/Text.java b/core/src/main/java/de/srsoftware/umbrella/core/constants/Text.java index 41aab871..8f2be0cd 100644 --- a/core/src/main/java/de/srsoftware/umbrella/core/constants/Text.java +++ b/core/src/main/java/de/srsoftware/umbrella/core/constants/Text.java @@ -5,6 +5,9 @@ package de.srsoftware.umbrella.core.constants; * This is a collection of messages that appear throughout the project */ public class Text { + public static final String ACCOUNT = "account"; + public static final String ACCOUNTING = "accounting"; + public static final String BOOKMARK = "bookmark"; public static final String BOOKMARKS = "bookmarks"; public static final String BOOLEAN = "Boolean"; @@ -47,8 +50,8 @@ public class Text { public static final String NOTE_WITH_ID = "note ({id})"; public static final String NUMBER = "number"; - public static final Object OPTION = "option" - ; + public static final Object OPTION = "option"; + public static final String PATH = "path"; public static final String PERMISSION = "permission"; public static final String POLL = "poll"; @@ -75,6 +78,7 @@ public class Text { public static final String TASKS = "tasks"; public static final String TIMETRACKING = "timetracking"; public static final String TIME_WITH_ID = "time ({id})"; + public static final String TRANSACTION = "transaction"; public static final String TYPE = "type"; public static final String UNIT = "unit"; diff --git a/core/src/main/java/de/srsoftware/umbrella/core/model/Account.java b/core/src/main/java/de/srsoftware/umbrella/core/model/Account.java new file mode 100644 index 00000000..d2610d60 --- /dev/null +++ b/core/src/main/java/de/srsoftware/umbrella/core/model/Account.java @@ -0,0 +1,35 @@ +/* © SRSoftware 2025 */ +package de.srsoftware.umbrella.core.model; + +import static de.srsoftware.umbrella.core.ModuleRegistry.userService; + +import de.srsoftware.tools.Mappable; +import de.srsoftware.umbrella.core.constants.Field; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Map; + +public record Account(long id, String name, String currency, long ownerId) implements Mappable { + public static Account of(ResultSet rs) throws SQLException { + var id = rs.getLong(Field.ID); + var name = rs.getString(Field.NAME); + var owner = rs.getLong(Field.OWNER); + var currency = rs.getString(Field.CURRENCY); + return new Account(id,name,currency,owner); + } + + @Override + public Map toMap() { + var owner = userService().loadUser(ownerId); + return Map.of( + Field.ID, id, + Field.NAME, name, + Field.CURRENCY, currency, + Field.OWNER, owner.toMap() + ); + } + + public Account withId(long newId) { + return new Account(newId,name,currency,ownerId); + } +} diff --git a/core/src/main/java/de/srsoftware/umbrella/core/model/IdOrString.java b/core/src/main/java/de/srsoftware/umbrella/core/model/IdOrString.java new file mode 100644 index 00000000..37310fd3 --- /dev/null +++ b/core/src/main/java/de/srsoftware/umbrella/core/model/IdOrString.java @@ -0,0 +1,63 @@ +/* © SRSoftware 2025 */ +package de.srsoftware.umbrella.core.model; + +import de.srsoftware.tools.Mappable; +import de.srsoftware.umbrella.core.constants.Field; +import java.util.HashMap; +import java.util.Map; + +public class IdOrString implements Mappable { + private final Long id; + private final String value; + + public IdOrString(String val){ + this.value = val; + this.id = parseOrNull(val); + } + + public IdOrString(long id){ + this.value = ""+id; + this.id = id; + } + + public boolean isId(){ + return id != null; + } + + public static IdOrString of(String val){ + return new IdOrString(val); + } + + public static IdOrString of(UmbrellaUser user) { + return new IdOrString(user.id()); + } + + private static Long parseOrNull(String val) { + try { + return Long.parseLong(val); + } catch (NumberFormatException e) { + return null; + } + } + + public long id(){ + return id; + } + + @Override + public Map toMap() { + var map = new HashMap(); + map.put(Field.VALUE,value); + if (isId()) map.put(Field.ID, id); + return map; + } + + @Override + public String toString() { + return value; + } + + public String value(){ + return value; + } + } \ No newline at end of file diff --git a/core/src/main/java/de/srsoftware/umbrella/core/model/Transaction.java b/core/src/main/java/de/srsoftware/umbrella/core/model/Transaction.java new file mode 100644 index 00000000..07472574 --- /dev/null +++ b/core/src/main/java/de/srsoftware/umbrella/core/model/Transaction.java @@ -0,0 +1,146 @@ +/* © SRSoftware 2025 */ +package de.srsoftware.umbrella.core.model; + +import de.srsoftware.tools.Mappable; +import de.srsoftware.umbrella.core.constants.Field; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static java.text.MessageFormat.format; + +public class Transaction implements Mappable { + private long accountId, id; + private LocalDateTime date; + private IdOrString source, destination; + private double amount; + private String purpose; + private Set tags; + private HashSet dirtyFields = new HashSet<>(); + + public Transaction(long id, long accountId, LocalDateTime date, IdOrString source, IdOrString destination, double amount, String purpose, Set tags){ + this.id = id; + this.accountId = accountId; + this.date = date; + this.source = source; + this.destination = destination; + this.amount = amount; + this.purpose = purpose; + this.tags = tags == null ? new HashSet<>() : tags; + } + + public long accountId(){ + return accountId; + } + + public double amount(){ + return amount; + } + + public Transaction amount(double newVal){ + amount = newVal; + dirtyFields.add(Field.AMOUNT); + return this; + } + + public Transaction clearDirtyState(){ + dirtyFields.clear(); + return this; + } + + public LocalDateTime date(){ + return date; + } + + public Transaction date(LocalDateTime newVal){ + date = newVal; + dirtyFields.add(Field.DATE); + return this; + } + + public Transaction date(LocalDate date) { + return date(this.date.withYear(date.getYear()).withMonth(date.getMonthValue()).withDayOfMonth(date.getDayOfMonth())); + } + + public IdOrString destination(){ + return destination; + } + + public Transaction destination(IdOrString newVal){ + destination = newVal; + dirtyFields.add(Field.DESTINATION); + return this; + } + + public long id(){ + return id; + } + + public boolean isDirty(){ + return !dirtyFields.isEmpty(); + } + + public static Transaction of(ResultSet rs) throws SQLException { + var accountId = rs.getLong(Field.ACCOUNT); + var timestamp = rs.getLong(Field.TIMESTAMP); + var date = LocalDateTime.ofEpochSecond(timestamp,0, ZoneOffset.UTC); + var source = IdOrString.of(rs.getString(Field.SOURCE)); + var destination = IdOrString.of(rs.getString(Field.DESTINATION)); + var amount = rs.getDouble(Field.AMOUNT); + var purpose = rs.getString(Field.DESCRIPTION); + var id = rs.getLong(Field.ID); + return new Transaction(id, accountId, date, source, destination, amount, purpose, null); + } + + public String purpose(){ + return purpose; + } + + public Transaction purpose(String newVal){ + purpose = newVal; + dirtyFields.add(Field.PURPOSE); + return this; + } + + public IdOrString source(){ + return source; + } + + public Transaction source(IdOrString newVal){ + source = newVal; + dirtyFields.add(Field.SOURCE); + return this; + } + + public Set tags(){ + return tags; + } + + @Override + public Map toMap() { + return Map.of( + Field.ID, id, + Field.ACCOUNT, accountId, + Field.DATE, date.toLocalDate(), + Field.SOURCE, source.toMap(), + Field.DESTINATION, destination.toMap(), + Field.AMOUNT, amount, + Field.PURPOSE, purpose, + Field.TAGS, tags.stream().sorted().toList() + ); + } + + @Override + public String toString() { + return format("Transaction ({0} -[{1}]-> {2})",source,amount,destination); + } + + public Transaction withId(long id) { + return new Transaction(id, accountId, date, source, destination, amount, purpose, new HashSet<>(tags)); + } +} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index a74156dd..a84253ab 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -5,6 +5,8 @@ import { loadTranslation } from './translations.svelte'; import { checkUser, user } from './user.svelte'; + import Account from "./routes/accounting/account.svelte"; + import Accounts from "./routes/accounting/index.svelte"; import AddDoc from "./routes/document/Add.svelte"; import AddTask from "./routes/task/Add.svelte"; import Bookmark from "./routes/bookmark/View.svelte"; @@ -25,6 +27,7 @@ import Messages from "./routes/message/Messages.svelte"; import MsgSettings from "./routes/message/Settings.svelte"; import Menu from "./Components/Menu.svelte"; + import NewAccount from "./routes/accounting/new.svelte"; import NewPage from "./routes/wiki/AddPage.svelte"; import Notes from "./routes/notes/Index.svelte"; import PollList from "./routes/poll/Index.svelte"; @@ -88,6 +91,9 @@ {@html messages.warning} {/if} + + + diff --git a/frontend/src/Components/Autocomplete.svelte b/frontend/src/Components/Autocomplete.svelte index 35a02c88..37c0f8ef 100644 --- a/frontend/src/Components/Autocomplete.svelte +++ b/frontend/src/Components/Autocomplete.svelte @@ -4,6 +4,7 @@ let { + id = null, autofocus = false, getCandidates = dummyGetCandidates, onCommit = dummyOnCommit, @@ -52,7 +53,6 @@ candidate = candidates[idx]; candidates = []; selected = []; - console.log(candidate); onSelect(candidate); } @@ -93,6 +93,7 @@ return false; } + candidate = { display : candidate.display }; candidates = await getCandidates(candidate.display); if (selected>candidates.length) selected = candidates.length; return false; @@ -103,12 +104,12 @@ span { position : relative } select { position : absolute; top: 30px; left: 3px; } - select { background: black; color: orange; border: 1px solid orange; border-radius: 5px; } + select { background: black; color: orange; border: 1px solid orange; border-radius: 5px; z-index: 50; } option:checked { background: orange; color: black; } - + {#if candidates && candidates.length > 0} + {:else} -{value} +{value} {/if} diff --git a/frontend/src/routes/accounting/account.svelte b/frontend/src/routes/accounting/account.svelte new file mode 100644 index 00000000..35a4ff9e --- /dev/null +++ b/frontend/src/routes/accounting/account.svelte @@ -0,0 +1,119 @@ + + + + +{#if filter.length > 0} +
+ {t('filter by tags')} +
+ {#each filter as tag,i} + {tag}  + {/each} +
+
+ +{/if} +{#if account} +
+ {account.name} + + + + + {#each Object.entries(users) as [id,user]} + + {/each} + + + + + + + {#each transactions as transaction, i} + {#if checker(transaction.tags,filter)} + + {/if} + {/each} + + + {#each Object.entries(users) as [id,user]} + + {/each} + + + +
{t('date')}{user.name}{t('other party')}{t('purpose')}{t('tags')}
+
+ {t('sums')} +
+ {user.name}
+ {sums[id].toFixed(2)} {account.currency} +
+
+ {sums[0].toFixed(2)} {account.currency} +
+
+ + +{/if} \ No newline at end of file diff --git a/frontend/src/routes/accounting/add_entry.svelte b/frontend/src/routes/accounting/add_entry.svelte new file mode 100644 index 00000000..eab4357e --- /dev/null +++ b/frontend/src/routes/accounting/add_entry.svelte @@ -0,0 +1,150 @@ + + + + +
+ {#if new_account} + {t('create_new_object',{object:t('account')})} + + {t('account name')} + + + + {t('currency')} + + + +
+ {t('first transaction')} + {:else} + {t('add_object',{object:t('transaction')})} + + {/if} + + {t('date')} + + + + + {t('source')} + + + {t('destination')} + + + {t('amount')} + +  {entry.account.currency} + + + {t('purpose')} + + + {t('tags')} + + + + + + +
\ No newline at end of file diff --git a/frontend/src/routes/accounting/index.svelte b/frontend/src/routes/accounting/index.svelte new file mode 100644 index 00000000..e4184537 --- /dev/null +++ b/frontend/src/routes/accounting/index.svelte @@ -0,0 +1,51 @@ + + +
+ + + + + + + + {t('accounts')} + +
\ No newline at end of file diff --git a/frontend/src/routes/accounting/new.svelte b/frontend/src/routes/accounting/new.svelte new file mode 100644 index 00000000..5f4df92f --- /dev/null +++ b/frontend/src/routes/accounting/new.svelte @@ -0,0 +1,5 @@ + + + diff --git a/frontend/src/routes/accounting/transaction.svelte b/frontend/src/routes/accounting/transaction.svelte new file mode 100644 index 00000000..29065314 --- /dev/null +++ b/frontend/src/routes/accounting/transaction.svelte @@ -0,0 +1,130 @@ + + +{#if !hidden} + + + + + {#each Object.entries(users) as [id,user]} + + {#if id == transaction.source.id} + - {account.currency} + {/if} + {#if id == transaction.destination.id} +  {account.currency} + {/if} + + {/each} + + {#if !transaction.source.id} + ← + {/if} + {#if !transaction.destination.id} + → + {/if} + + + + + + {#each transaction.tags as tag,i} + + addToFilter(tag)}>{tag}  + + {/each} + + + + + +{/if} \ No newline at end of file diff --git a/frontend/src/routes/company/Editor.svelte b/frontend/src/routes/company/Editor.svelte index 8579fdba..7d128d6f 100644 --- a/frontend/src/routes/company/Editor.svelte +++ b/frontend/src/routes/company/Editor.svelte @@ -1,5 +1,5 @@ {#if value} - + {/if} \ No newline at end of file diff --git a/frontend/src/routes/contact/Name.svelte b/frontend/src/routes/contact/Name.svelte index 9aa18932..9ef85cb2 100644 --- a/frontend/src/routes/contact/Name.svelte +++ b/frontend/src/routes/contact/Name.svelte @@ -16,18 +16,18 @@
{#if n.prefix} - onSet(n.prefix,newVal)} title={t('name_prefix')} /> + onSet(n.prefix,newVal)} title={t('name_prefix')} /> {/if} {#if n.given} - onSet(n.given,newVal)} title={t('given_name')} /> + onSet(n.given,newVal)} title={t('given_name')} /> {/if} {#if n.additional} - onSet(n.additional,newVal)} title={t('additional_name')} /> + onSet(n.additional,newVal)} title={t('additional_name')} /> {/if} {#if n.family} - onSet(n.family,newVal)} title={t('family_name')} /> + onSet(n.family,newVal)} title={t('family_name')} /> {/if} {#if n.suffix} - onSet(n.suffix,newVal)} title={t('')} /> + onSet(n.suffix,newVal)} title={t('')} /> {/if}
\ No newline at end of file diff --git a/frontend/src/routes/contact/Number.svelte b/frontend/src/routes/contact/Number.svelte index 53114e49..fd86475a 100644 --- a/frontend/src/routes/contact/Number.svelte +++ b/frontend/src/routes/contact/Number.svelte @@ -39,5 +39,5 @@ -
+
{/if} \ No newline at end of file diff --git a/frontend/src/routes/contact/Org.svelte b/frontend/src/routes/contact/Org.svelte index 04b0285f..5b7c52e3 100644 --- a/frontend/src/routes/contact/Org.svelte +++ b/frontend/src/routes/contact/Org.svelte @@ -14,5 +14,5 @@ {#if value} - + {/if} \ No newline at end of file diff --git a/frontend/src/routes/contact/URL.svelte b/frontend/src/routes/contact/URL.svelte index 4c392218..b47cedab 100644 --- a/frontend/src/routes/contact/URL.svelte +++ b/frontend/src/routes/contact/URL.svelte @@ -14,5 +14,5 @@ {#if value} - + {/if} \ No newline at end of file diff --git a/frontend/src/routes/stock/Index.svelte b/frontend/src/routes/stock/Index.svelte index 7ab1c641..754f1bc9 100644 --- a/frontend/src/routes/stock/Index.svelte +++ b/frontend/src/routes/stock/Index.svelte @@ -263,7 +263,7 @@
{#if location}

- patchLocation(location,'name',newName)} /> + patchLocation(location,'name',newName)} /> {#if location.parent_location_id} diff --git a/frontend/src/routes/stock/ItemProps.svelte b/frontend/src/routes/stock/ItemProps.svelte index c936c3ba..2fe1eeb0 100644 --- a/frontend/src/routes/stock/ItemProps.svelte +++ b/frontend/src/routes/stock/ItemProps.svelte @@ -68,7 +68,7 @@ {#if item} - update('name',v)} /> + update('name',v)} />
{@html item.description.rendered} @@ -80,7 +80,7 @@ {t('ID')} - update('code',v)} /> + update('code',v)} /> {#each item.properties.toSorted(byName) as prop} diff --git a/frontend/src/routes/tags/TagList.svelte b/frontend/src/routes/tags/TagList.svelte index a9b0d9b7..103fde82 100644 --- a/frontend/src/routes/tags/TagList.svelte +++ b/frontend/src/routes/tags/TagList.svelte @@ -11,6 +11,7 @@ let { id = null, + getCandidates = getCandidateTags, module, tags = $bindable([]), user_list = [], @@ -64,7 +65,7 @@ return false; } - async function getCandidates(input){ + async function getCandidateTags(input){ if (!input || input.length <3) return []; const url = api(`tags/search/${encodeURI(input)}`); const res = await get(url); diff --git a/frontend/src/routes/task/ListTask.svelte b/frontend/src/routes/task/ListTask.svelte index 5173f323..cb74453b 100644 --- a/frontend/src/routes/task/ListTask.svelte +++ b/frontend/src/routes/task/ListTask.svelte @@ -156,7 +156,7 @@ {#if !deleted}
  • e.preventDefault()} {ondragstart} class="task {states[task.status]?.toLowerCase()}"> - + {#if task.est_time} ({+task.est_time} h) {/if} diff --git a/frontend/src/routes/wiki/View.svelte b/frontend/src/routes/wiki/View.svelte index c06b385b..8423d8fb 100644 --- a/frontend/src/routes/wiki/View.svelte +++ b/frontend/src/routes/wiki/View.svelte @@ -165,7 +165,7 @@ {/each}
  • - patchTitle(t)} /> + patchTitle(t)} /> {#if page.version != page.versions[0]} {t('not_recent_version')} {/if} diff --git a/frontend/src/urls.svelte.js b/frontend/src/urls.svelte.js index c0214662..046648ce 100644 --- a/frontend/src/urls.svelte.js +++ b/frontend/src/urls.svelte.js @@ -8,11 +8,13 @@ export function get(url){ return fetch(url,{ credentials:'include' }); } -export function drop(url){ - return fetch(url,{ - credentials:'include', - method:'DELETE' - }); +export function drop(url, payload){ + let data = { + credentials:'include', + method:'DELETE' + }; + if (payload) data['body'] = JSON.stringify(payload); + return fetch(url,data); } export function eventStream(createHandler,updateHandler,deleteHandler){ @@ -35,7 +37,7 @@ export function post(url,data){ return fetch(url,{ credentials : 'include', method : 'POST', - body : JSON.stringify(data) + body : typeof data === 'string' ? data : JSON.stringify(data) }); } @@ -54,4 +56,4 @@ export function target(code){ } return altered; -} \ No newline at end of file +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 9b27edfe..1ba16a67 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,6 @@ rootProject.name = "Umbrella25" +include("accounting") include("backend") include("bookmark") include("bus") @@ -22,5 +23,4 @@ include("time") include("translations") include("user") include("web") -include("wiki") - +include("wiki") \ No newline at end of file diff --git a/tags/src/main/java/de/srsoftware/umbrella/tags/TagModule.java b/tags/src/main/java/de/srsoftware/umbrella/tags/TagModule.java index cf985419..5503a997 100644 --- a/tags/src/main/java/de/srsoftware/umbrella/tags/TagModule.java +++ b/tags/src/main/java/de/srsoftware/umbrella/tags/TagModule.java @@ -153,8 +153,13 @@ public class TagModule extends BaseHandler implements TagService { return tag; } + @Override + public Collection search(String key, UmbrellaUser user) { + return tagDb.search(key, user); + } + private boolean searchTags(HttpExchange ex, String head, UmbrellaUser user) throws IOException { - return sendContent(ex, tagDb.search(head, user)); + return sendContent(ex, search(head, user)); } @Override diff --git a/user/src/main/java/de/srsoftware/umbrella/user/UserModule.java b/user/src/main/java/de/srsoftware/umbrella/user/UserModule.java index 14ced958..2e895e95 100644 --- a/user/src/main/java/de/srsoftware/umbrella/user/UserModule.java +++ b/user/src/main/java/de/srsoftware/umbrella/user/UserModule.java @@ -412,8 +412,7 @@ public class UserModule extends BaseHandler implements UserService { private boolean getUserList(HttpExchange ex, UmbrellaUser user) throws IOException, UmbrellaException { if (!(user instanceof DbUser dbUser && dbUser.permissions().contains(LIST_USERS))) throw forbidden("You are not allowed to list users!"); - var list = users.list(0, null,null).values().stream().map(UmbrellaUser::toMap).toList(); - return sendContent(ex,list); + return sendContent(ex,users.list(0, null,null).values().stream().map(UmbrellaUser::toMap)); } private boolean impersonate(HttpExchange ex, Long targetId) throws IOException, UmbrellaException { @@ -556,7 +555,7 @@ public class UserModule extends BaseHandler implements UserService { var requestingUser = loadUser(ex); if (!(requestingUser.isPresent() && requestingUser.get() instanceof DbUser dbUser)) return unauthorized(ex); var key = body(ex); - return sendContent(ex,mapValues(users.search(key))); + return sendContent(ex,mapValues(search(key))); } @Override @@ -577,6 +576,11 @@ public class UserModule extends BaseHandler implements UserService { return score; } + @Override + public Map search(String key) { + return users.search(key); + } + private boolean update(HttpExchange ex, DbUser user, JSONObject json) throws UmbrellaException, IOException { var id = user.id(); var name = json.has(NAME) && json.get(NAME) instanceof String s && !s.isBlank() ? s : user.name();