From 94f11ea38f9f269540dec0ab6d8d2421a6c4376a Mon Sep 17 00:00:00 2001 From: Stephan Richter Date: Sun, 16 Aug 2026 23:08:51 +0200 Subject: [PATCH] implemented download of account data as CSV Signed-off-by: Stephan Richter --- .../umbrella/accounting/AccountingModule.java | 14 +++++-- .../umbrella/core/api/AccountingService.java | 18 +++++++-- .../umbrella/core/model/Transaction.java | 40 ++++++++++++++++--- frontend/src/routes/accounting/account.svelte | 18 ++++++--- frontend/src/urls.svelte.js | 7 ++++ 5 files changed, 78 insertions(+), 19 deletions(-) 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 f2421dc2..04373b27 100644 --- a/accounting/src/main/java/de/srsoftware/umbrella/accounting/AccountingModule.java +++ b/accounting/src/main/java/de/srsoftware/umbrella/accounting/AccountingModule.java @@ -78,7 +78,7 @@ public class AccountingModule extends BaseHandler implements AccountingService { case null -> getAccounts(user.get(),ex); default -> { try { - yield getAccount(user.get(),Long.parseLong(head),ex); + yield getAccount(user.get(),Long.parseLong(head), path.pop(), ex); } catch (NumberFormatException ignored) {} yield super.doGet(path,ex); } @@ -184,9 +184,17 @@ public class AccountingModule extends BaseHandler implements AccountingService { return null; } - private boolean getAccount(UmbrellaUser user, long accountId, HttpExchange ex) throws IOException { + private boolean getAccount(UmbrellaUser user, long accountId, String format, HttpExchange ex) throws IOException { if (!accountDb.getMembers(accountId).contains(user)) throw forbidden("You are not allowed to access account {id}",Field.ID,accountId); - return sendContent(ex, loadAccount(accountId)); + var data = loadAccount(accountId); + return switch (format){ + case "csv" -> { + ex.getResponseHeaders().add("Content-Type", "text/csv"); + var users = userService().loader(); + yield sendContent(ex,data.toCsv(users)); + } + case null, default -> sendContent(ex,data); + }; } private boolean getAccounts(UmbrellaUser user, HttpExchange ex) throws IOException { 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 98753e45..e25d2aa8 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 @@ -2,18 +2,18 @@ package de.srsoftware.umbrella.core.api; import static de.srsoftware.umbrella.core.Util.mapValues; +import static java.nio.charset.StandardCharsets.UTF_8; 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.Translatable; import de.srsoftware.umbrella.core.model.UmbrellaUser; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; public interface AccountingService { - public record AccountData(Account account, List transactions, HashMap userMap) implements Mappable { + 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); } @@ -26,6 +26,16 @@ public interface AccountingService { Field.USER_LIST,mapValues(userMap) ); } + + public byte[] toCsv(Map users) { + var keys = List.of(Field.DATE, Field.SOURCE, Field.AMOUNT, Field.DESTINATION, Field.PURPOSE,Field.TAGS); + var sb = new StringBuilder(); + keys.stream().map(Translatable::t).forEach(field -> sb.append(field).append(";")); + transactions.stream().sorted(Comparator.comparing(Transaction::id)) + .map(transaction -> transaction.csvLine(users, keys,";")) + .forEach(line -> sb.append("\n").append(line)); + return sb.toString().getBytes(UTF_8); + } } AccountData loadAccount(long accountId); 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 index b4ba0b24..1ff0799d 100644 --- a/core/src/main/java/de/srsoftware/umbrella/core/model/Transaction.java +++ b/core/src/main/java/de/srsoftware/umbrella/core/model/Transaction.java @@ -9,9 +9,8 @@ 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 java.util.*; +import java.util.stream.Collectors; public class Transaction extends UmbrellaObject { private final long accountId; @@ -25,11 +24,11 @@ public class Transaction extends UmbrellaObject { public Transaction(long id, long accountId, LocalDateTime date, IdOrString source, IdOrString destination, double amount, String purpose, Set tags){ super(id); this.accountId = accountId; - this.date = date; - this.source = source; - this.destination = destination; this.amount = amount; + this.date = date; + this.destination = destination; this.purpose = purpose; + this.source = source; this.tags = tags == null ? new HashSet<>() : tags; } @@ -52,6 +51,10 @@ public class Transaction extends UmbrellaObject { return this; } + public String csvLine(Map users, List keys, String delimiter) { + return keys.stream().map(this::get).map(o -> resolveUsers(users,o)).map(o -> quote(o, delimiter)).collect(Collectors.joining(delimiter)); + } + public LocalDateTime date(){ return date; } @@ -76,6 +79,19 @@ public class Transaction extends UmbrellaObject { return this; } + public Object get(String property){ + return switch (property){ + case Field.AMOUNT -> amount; + case Field.DATE -> date.toLocalDate(); + case Field.DESTINATION -> destination; + case Field.ID -> id(); + case Field.PURPOSE -> purpose; + case Field.SOURCE -> source; + case Field.TAGS -> tags.stream().sorted().collect(Collectors.joining(", ")); + case null, default -> null; + }; + } + public boolean isDirty(){ return !dirtyFields.isEmpty(); } @@ -102,6 +118,18 @@ public class Transaction extends UmbrellaObject { return this; } + private static String quote(Object o, String delimiter){ + var str = o == null ? "" : o instanceof Map m ? m.get("value").toString() : o.toString(); + if (str.contains(delimiter)) return '"'+str.replace("\"","\"\"")+'"'; + return str; + } + + private Object resolveUsers(Map users, Object o) { + if (!(o instanceof IdOrString ios)) return o; + if (ios.isId()) return users.get(ios.id()) instanceof UmbrellaUser user ? user.name() : "unknown"; + return ios; + } + public IdOrString source(){ return source; } diff --git a/frontend/src/routes/accounting/account.svelte b/frontend/src/routes/accounting/account.svelte index ca0529d8..83e32c34 100644 --- a/frontend/src/routes/accounting/account.svelte +++ b/frontend/src/routes/accounting/account.svelte @@ -1,6 +1,6 @@