diff --git a/accounting/build.gradle.kts b/accounting/build.gradle.kts index efceaa32..adb67f35 100644 --- a/accounting/build.gradle.kts +++ b/accounting/build.gradle.kts @@ -1,5 +1,6 @@ description = "Umbrella : Accounting" dependencies{ + implementation(project(":bus")) 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 index be9f40cd..88626ad1 100644 --- a/accounting/src/main/java/de/srsoftware/umbrella/accounting/AccountDb.java +++ b/accounting/src/main/java/de/srsoftware/umbrella/accounting/AccountDb.java @@ -9,6 +9,8 @@ import java.util.Optional; import java.util.Set; public interface AccountDb { + Transaction dropTransaction(Transaction transaction); + void dropTransactionTag(long transactionId, String tag); Optional lastTransaction(long accountId, String source, String dest, double amount); diff --git a/accounting/src/main/java/de/srsoftware/umbrella/accounting/AccountingModule.java b/accounting/src/main/java/de/srsoftware/umbrella/accounting/AccountingModule.java index 16c2ae65..80c20cd3 100644 --- a/accounting/src/main/java/de/srsoftware/umbrella/accounting/AccountingModule.java +++ b/accounting/src/main/java/de/srsoftware/umbrella/accounting/AccountingModule.java @@ -6,10 +6,11 @@ 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 de.srsoftware.umbrella.messagebus.MessageBus.messageBus; +import static de.srsoftware.umbrella.messagebus.events.Event.EventType.CREATE; import static java.lang.System.Logger.Level.WARNING; import com.sun.net.httpserver.HttpExchange; @@ -23,6 +24,8 @@ 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 de.srsoftware.umbrella.messagebus.events.Event; +import de.srsoftware.umbrella.messagebus.events.TransactionEvent; import java.io.IOException; import java.time.LocalDate; import java.time.LocalDateTime; @@ -137,11 +140,18 @@ public class AccountingModule extends BaseHandler implements AccountingService { } } + public boolean dropTransaction(Transaction transaction, UmbrellaUser user, HttpExchange ex) throws IOException { + var dropped = accountDb.dropTransaction(transaction); + messageBus().dispatch(new TransactionEvent(user,dropped,Event.EventType.DELETE)); + return sendContent(ex,dropped); + } + 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); + case null -> dropTransaction(transaction,user,ex); + default -> super.doDelete(path,ex); }; } @@ -177,6 +187,14 @@ public class AccountingModule extends BaseHandler implements AccountingService { private boolean getAccount(UmbrellaUser user, long accountId, HttpExchange ex) throws IOException { LOG.log(WARNING,"Missing authorization check in AccountingModule.getAccount(…)!"); + return sendContent(ex, loadAccount(accountId)); + } + + private boolean getAccounts(UmbrellaUser user, HttpExchange ex) throws IOException { + return sendContent(ex,accountDb.listAccounts(user.id()).stream().map(Account::toMap)); + } + + public AccountData loadAccount(long accountId){ var account = accountDb.loadAccount(accountId); var transactions = accountDb.loadTransactions(account); var userMap = new HashMap(); @@ -193,20 +211,9 @@ public class AccountingModule extends BaseHandler implements AccountingService { 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) - )); + return AccountData.of(account, transactions, 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); @@ -219,6 +226,7 @@ public class AccountingModule extends BaseHandler implements AccountingService { 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 oldData = transaction.toMap(); 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))); @@ -226,7 +234,9 @@ public class AccountingModule extends BaseHandler implements AccountingService { 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)); + var patched = accountDb.save(transaction); + messageBus().dispatch(new TransactionEvent(user,patched,oldData)); + return sendContent(ex,patched); } private boolean postEntry(UmbrellaUser user, HttpExchange ex) throws IOException { @@ -291,7 +301,7 @@ public class AccountingModule extends BaseHandler implements AccountingService { var transaction = accountDb.save(new Transaction(0,accountId,dateTime,source,destination,amount.doubleValue(),purpose,tags)); - + messageBus().dispatch(new TransactionEvent(user,transaction, CREATE)); return sendContent(ex,newAccount != null ? newAccount : transaction); } diff --git a/accounting/src/main/java/de/srsoftware/umbrella/accounting/SqliteDb.java b/accounting/src/main/java/de/srsoftware/umbrella/accounting/SqliteDb.java index a889eadd..b9e62b58 100644 --- a/accounting/src/main/java/de/srsoftware/umbrella/accounting/SqliteDb.java +++ b/accounting/src/main/java/de/srsoftware/umbrella/accounting/SqliteDb.java @@ -117,6 +117,21 @@ public class SqliteDb extends BaseDb implements AccountDb { } } + public Transaction dropTransaction(Transaction transaction){ + try { + db.setAutoCommit(false); + Query.delete().from(TABLE_TAGS_TRANSACTIONS).where(TRANSACTION_ID,equal(transaction.id())).execute(db); + Query.delete().from(TABLE_TRANSACTIONS).where(ID,equal(transaction.id())).execute(db); + db.setAutoCommit(true); + return transaction; + } catch (SQLException e){ + try { + db.rollback(); + } catch (SQLException ignored){}; + throw failedToDropObject(transaction); + } + } + @Override public void dropTransactionTag(long transactionId, String tag) { try { @@ -295,13 +310,9 @@ public class SqliteDb extends BaseDb implements AccountDb { } } else if (transaction.isDirty()) { try { - if (transaction.amount() == 0 || transaction.source().isEmpty() || transaction.destination().isEmpty()) { - 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(); - } + 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); diff --git a/bus/src/main/java/de/srsoftware/umbrella/messagebus/MessageApi.java b/bus/src/main/java/de/srsoftware/umbrella/messagebus/MessageApi.java index 9081fc56..09c8136a 100644 --- a/bus/src/main/java/de/srsoftware/umbrella/messagebus/MessageApi.java +++ b/bus/src/main/java/de/srsoftware/umbrella/messagebus/MessageApi.java @@ -65,7 +65,7 @@ public class MessageApi extends BaseHandler{ } } - private void sendEvent(PrintWriter out, Event event) { + private void sendEvent(PrintWriter out, Event event) { if (event == null) return; out.print("event: "); out.println(event.eventType()); diff --git a/bus/src/main/java/de/srsoftware/umbrella/messagebus/events/TransactionEvent.java b/bus/src/main/java/de/srsoftware/umbrella/messagebus/events/TransactionEvent.java new file mode 100644 index 00000000..9de64fef --- /dev/null +++ b/bus/src/main/java/de/srsoftware/umbrella/messagebus/events/TransactionEvent.java @@ -0,0 +1,77 @@ +/* © SRSoftware 2025 */ +package de.srsoftware.umbrella.messagebus.events; + +import static de.srsoftware.umbrella.core.ModuleRegistry.accountingService; +import static de.srsoftware.umbrella.core.constants.Field.*; +import static de.srsoftware.umbrella.core.model.Translatable.t; + +import de.srsoftware.umbrella.core.constants.Field; +import de.srsoftware.umbrella.core.constants.Module; +import de.srsoftware.umbrella.core.constants.Text; +import de.srsoftware.umbrella.core.model.Transaction; +import de.srsoftware.umbrella.core.model.Translatable; +import de.srsoftware.umbrella.core.model.UmbrellaUser; +import java.util.Collection; +import java.util.Map; + +public class TransactionEvent extends Event { + private Collection audience; + + public TransactionEvent(UmbrellaUser initiator, Transaction transaction, EventType type) { + super(initiator, Module.ACCOUNTING, transaction, type); + audience = null; + } + + public TransactionEvent(UmbrellaUser initiator, Transaction transaction, Map oldData){ + super(initiator,Module.ACCOUNTING,transaction,oldData); + } + + + @Override + public Collection audience() { + if (audience == null) audience = accountingService().loadAccount(payload().accountId()).userMap().values(); + return audience; + } + + @Override + public Translatable describe() { + var user = initiator().name(); + var type = t(Text.TRANSACTION); + var entity = payload().purpose(); + return switch (eventType()){ + case CREATE -> describeDetail(); + case DELETE -> describeDetail(); + case UPDATE -> describeUpdate(); + case null, default -> t("TODO"); // TODO + }; + } + + private Translatable describeUpdate() { + var head = t("Changes in {type} '{entity}':\n\n{body}",Field.TYPE,t(Text.TRANSACTION),Field.ENTITY,oldData().get(PURPOSE),BODY,diff().orElse("")); + return t("{head}\n\n{link}","head",head,"link",link()); + } + + private Translatable describeDetail(){ + var tr = payload(); + var head = subject(); + + var message = "{head}:\n\n{source}: {source_name}\n{destination}: {dest_name}\n{amount}: {value}\n{purpose}: {purpose_val}\n\n{link}"; + return t(message,Field.HEAD, head, SOURCE,t(Text.SOURCE), "source_name",tr.source(), DESTINATION,t(Text.DESTINATION),"dest_name",tr.destination(), AMOUNT,t(Text.AMOUNT), VALUE,tr.amount(), PURPOSE,t(Text.PURPOSE),"purpose_val",tr.purpose(),"link",link()); + } + + private Translatable link() { + return t("You can view/edit this transaction at {base_url}/account/{id}", ID, payload().accountId()); + } + + @Override + public Translatable subject() { + var user = initiator().name(); + var entity = payload().purpose(); + return switch (eventType()){ + case CREATE -> t("{user} added a new transaction: {entity}", USER,user, ENTITY, entity); + case DELETE -> t("The transaction '{entity}' has been deleted by {user}",Field.ENTITY, entity, USER, user); + case UPDATE -> t("{user} updated the transaction '{entity}'", USER,user, ENTITY, oldData().get(PURPOSE)); + case null, default -> t("TODO"); // TODO + }; + } +} 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 5ecb60ee..55e4a664 100644 --- a/core/src/main/java/de/srsoftware/umbrella/core/ModuleRegistry.java +++ b/core/src/main/java/de/srsoftware/umbrella/core/ModuleRegistry.java @@ -53,6 +53,10 @@ public class ModuleRegistry { } } + public static AccountingService accountingService() { + return singleton.accountingService; + } + public static BookmarkService bookmarkService(){ return singleton.bookmarkService; } diff --git a/core/src/main/java/de/srsoftware/umbrella/core/api/AccountingService.java b/core/src/main/java/de/srsoftware/umbrella/core/api/AccountingService.java index 4cbc8c58..98753e45 100644 --- a/core/src/main/java/de/srsoftware/umbrella/core/api/AccountingService.java +++ b/core/src/main/java/de/srsoftware/umbrella/core/api/AccountingService.java @@ -1,5 +1,32 @@ /* © SRSoftware 2025 */ package de.srsoftware.umbrella.core.api; +import static de.srsoftware.umbrella.core.Util.mapValues; + +import de.srsoftware.tools.Mappable; +import de.srsoftware.umbrella.core.constants.Field; +import de.srsoftware.umbrella.core.model.Account; +import de.srsoftware.umbrella.core.model.Transaction; +import de.srsoftware.umbrella.core.model.UmbrellaUser; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + public interface AccountingService { + public record AccountData(Account account, List transactions, HashMap userMap) implements Mappable { + public static AccountData of(Account account, List transactions, HashMap userMap) { + return new AccountData(account, transactions, userMap); + } + + @Override + public Map toMap() { + return Map.of( + Field.ACCOUNT,account.toMap(), + Field.TRANSACTIONS,transactions.stream().map(Transaction::toMap).toList(), + Field.USER_LIST,mapValues(userMap) + ); + } + } + + AccountData loadAccount(long accountId); } 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 a8ca1d2d..5331e28c 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 @@ -57,6 +57,7 @@ public class Field { public static final String EDITOR = "editor"; public static final String EMAIL = "email"; public static final String END_TIME = "end_time"; + public static final String ENTITY = "entity"; public static final String ENTITY_ID = "entity_id"; public static final String EST_TIME = "est_time"; public static final String EVALUATION = "evaluation"; 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 8f2be0cd..2e7fd581 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 @@ -7,6 +7,7 @@ package de.srsoftware.umbrella.core.constants; public class Text { public static final String ACCOUNT = "account"; public static final String ACCOUNTING = "accounting"; + public static final String AMOUNT = "amount"; public static final String BOOKMARK = "bookmark"; public static final String BOOKMARKS = "bookmarks"; @@ -21,6 +22,7 @@ public class Text { public static final String CUSTOMER = "customer"; public static final String CUSTOMER_SETTINGS = "customer settings"; + public static final String DESTINATION = "destination"; public static final String DOCUMENT = "document"; public static final String DOCUMENTS = "documents"; public static final String DOCUMENT_TYPE_ID = "document type id"; @@ -60,6 +62,7 @@ public class Text { public static final String PROJECT_WITH_ID = "project ({id})"; public static final String PROPERTIES = "properties"; public static final String PROPERTY = "property"; + public static final String PURPOSE = "purpose"; public static final String RECEIVER = "receiver"; public static final String RECEIVERS = "receivers"; @@ -69,6 +72,7 @@ public class Text { public static final String SERVICE_WITH_ID = "service ({id})"; public static final String SESSION = "session"; public static final String SETTINGS = "settings"; + public static final String SOURCE = "source"; public static final String STOCK = "stock"; public static final String STRING = "string"; diff --git a/documents/src/main/java/de/srsoftware/umbrella/documents/DocumentApi.java b/documents/src/main/java/de/srsoftware/umbrella/documents/DocumentApi.java index 2d1901b7..66e8b52b 100644 --- a/documents/src/main/java/de/srsoftware/umbrella/documents/DocumentApi.java +++ b/documents/src/main/java/de/srsoftware/umbrella/documents/DocumentApi.java @@ -16,6 +16,7 @@ import static de.srsoftware.umbrella.core.ModuleRegistry.*; import static de.srsoftware.umbrella.core.ResponseCode.HTTP_UNPROCESSABLE; import static de.srsoftware.umbrella.core.Util.mapValues; import static de.srsoftware.umbrella.core.constants.Field.*; +import static de.srsoftware.umbrella.core.constants.Field.AMOUNT; import static de.srsoftware.umbrella.core.constants.Field.COMPANY; import static de.srsoftware.umbrella.core.constants.Field.CUSTOMER; import static de.srsoftware.umbrella.core.constants.Field.DOCUMENT; diff --git a/documents/src/main/java/de/srsoftware/umbrella/documents/SqliteDb.java b/documents/src/main/java/de/srsoftware/umbrella/documents/SqliteDb.java index 2bd41e4b..6bfaa2cf 100644 --- a/documents/src/main/java/de/srsoftware/umbrella/documents/SqliteDb.java +++ b/documents/src/main/java/de/srsoftware/umbrella/documents/SqliteDb.java @@ -9,6 +9,7 @@ import static de.srsoftware.umbrella.core.Errors.*; import static de.srsoftware.umbrella.core.ModuleRegistry.translator; import static de.srsoftware.umbrella.core.constants.Constants.FALLBACK_LANG; import static de.srsoftware.umbrella.core.constants.Field.*; +import static de.srsoftware.umbrella.core.constants.Field.AMOUNT; import static de.srsoftware.umbrella.core.constants.Field.COMPANY; import static de.srsoftware.umbrella.core.constants.Field.CUSTOMER; import static de.srsoftware.umbrella.core.constants.Field.NUMBER; diff --git a/frontend/src/routes/accounting/account.svelte b/frontend/src/routes/accounting/account.svelte index 721a4f96..02167396 100644 --- a/frontend/src/routes/accounting/account.svelte +++ b/frontend/src/routes/accounting/account.svelte @@ -1,6 +1,6 @@