first working version where transactions can be stored

Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
This commit is contained in:
2026-04-03 00:52:13 +02:00
parent d4aaa24aaa
commit a933ced8d8
9 changed files with 155 additions and 89 deletions

View File

@@ -10,10 +10,7 @@ import de.srsoftware.umbrella.core.api.AccountingService;
import de.srsoftware.umbrella.core.constants.Field; import de.srsoftware.umbrella.core.constants.Field;
import de.srsoftware.umbrella.core.constants.Text; import de.srsoftware.umbrella.core.constants.Text;
import de.srsoftware.umbrella.core.exceptions.UmbrellaException; import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
import de.srsoftware.umbrella.core.model.Account; import de.srsoftware.umbrella.core.model.*;
import de.srsoftware.umbrella.core.model.Token;
import de.srsoftware.umbrella.core.model.Transaction;
import de.srsoftware.umbrella.core.model.UmbrellaUser;
import org.json.JSONObject; import org.json.JSONObject;
import java.io.IOException; import java.io.IOException;
@@ -30,10 +27,8 @@ import static de.srsoftware.umbrella.core.ConnectionProvider.connect;
import static de.srsoftware.umbrella.core.ModuleRegistry.userService; import static de.srsoftware.umbrella.core.ModuleRegistry.userService;
import static de.srsoftware.umbrella.core.Util.mapValues; import static de.srsoftware.umbrella.core.Util.mapValues;
import static de.srsoftware.umbrella.core.constants.Path.JSON; import static de.srsoftware.umbrella.core.constants.Path.JSON;
import static de.srsoftware.umbrella.core.constants.Path.SEARCH;
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.invalidField; import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.invalidField;
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.missingField; import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.missingField;
import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
public class AccountingModule extends BaseHandler implements AccountingService { public class AccountingModule extends BaseHandler implements AccountingService {
private final AccountDb accountDb; private final AccountDb accountDb;
@@ -85,32 +80,27 @@ public class AccountingModule extends BaseHandler implements AccountingService {
} }
private boolean getAccount(UmbrellaUser user, long accountId, HttpExchange ex) throws IOException { private boolean getAccount(UmbrellaUser user, long accountId, HttpExchange ex) throws IOException {
var account = accountDb.loadAccount(accountId); var account = accountDb.loadAccount(accountId);
var transactions = accountDb.loadTransactions(account); var transactions = accountDb.loadTransactions(account);
var userMap = new HashMap<Long,UmbrellaUser>(); var userMap = new HashMap<Long,UmbrellaUser>();
var foundRequestingUser = false;
for (var i=0; i<transactions.size();i++){ for (var transaction : transactions){
var transaction = transactions.get(i); var source = transaction.source();
try { if (source.isId()) {
var userId = Long.parseLong(transaction.source()); var userId = source.id();
var u = userMap.get(userId); if (!userMap.containsKey(userId)) userMap.put(source.id(),userService().loadUser(userId));
if (u == null) userMap.put(userId,u=userService().loadUser(userId)); }
if (!foundRequestingUser) foundRequestingUser = user.equals(u); var destination = transaction.destination();
transaction = new Transaction(transaction.accountId(),transaction.date(),u.name(),transaction.destination(),transaction.amount(),transaction.purpose()); if (destination.isId()) {
transactions.set(i,transaction); var userId = destination.id();
} catch (NumberFormatException ignored){} if (!userMap.containsKey(userId)) userMap.put(destination.id(),userService().loadUser(userId));
try { }
var userId = Long.parseLong(transaction.destination());
var u = userMap.get(userId);
if (u == null) userMap.put(userId,u=userService().loadUser(userId));
if (!foundRequestingUser) foundRequestingUser = user.equals(u);
transaction = new Transaction(transaction.accountId(),transaction.date(),transaction.source(),u.name(),transaction.amount(),transaction.purpose());
transactions.set(i,transaction);
} catch (NumberFormatException ignored){}
} }
return sendContent(ex, Map.of( return sendContent(ex, Map.of(
Field.ACCOUNT,account.toMap(), Field.ACCOUNT,account.toMap(),
Field.TRANSACTIONS,transactions.stream().map(Transaction::toMap).toList() Field.TRANSACTIONS,transactions.stream().map(Transaction::toMap).toList(),
Field.USER_LIST,mapValues(userMap)
)); ));
} }
@@ -138,26 +128,24 @@ public class AccountingModule extends BaseHandler implements AccountingService {
if (!json.has(Field.DESTINATION)) throw missingField(Field.DESTINATION); if (!json.has(Field.DESTINATION)) throw missingField(Field.DESTINATION);
if (!(json.get(Field.DESTINATION) instanceof JSONObject destinationData)) throw invalidField(Field.DESTINATION,JSON); if (!(json.get(Field.DESTINATION) instanceof JSONObject destinationData)) throw invalidField(Field.DESTINATION,JSON);
String source = null, destination = null; IdOrString source = null, destination = null;
if (sourceData.has(Field.USER_ID)) { if (sourceData.has(Field.ID)) {
if (!(sourceData.get(Field.USER_ID) instanceof Number uid)) throw invalidField(String.join(".",Field.SOURCE,Field.USER_ID),Text.NUMBER); if (!(sourceData.get(Field.ID) instanceof Number uid)) throw invalidField(String.join(".",Field.SOURCE,Field.ID),Text.NUMBER);
var u = userService().loadUser(uid.longValue()); source = IdOrString.of(userService().loadUser(uid.longValue()));
source = ""+u.id();
} else { } else {
if (!sourceData.has(Field.DISPLAY)) throw missingField(String.join(".",Field.SOURCE,Field.DISPLAY)); if (!sourceData.has(Field.DISPLAY)) throw missingField(String.join(".",Field.SOURCE,Field.DISPLAY));
source = sourceData.getString(Field.DISPLAY); source = IdOrString.of(sourceData.getString(Field.DISPLAY));
if (source.isBlank()) throw invalidField(String.join(".",Field.SOURCE,Field.DISPLAY),Text.STRING); if (source.value().isBlank()) throw invalidField(String.join(".",Field.SOURCE,Field.DISPLAY),Text.STRING);
} }
if (destinationData.has(Field.USER_ID)) { if (destinationData.has(Field.ID)) {
if (!(destinationData.get(Field.USER_ID) instanceof Number uid)) throw invalidField(String.join(".",Field.DESTINATION,Field.USER_ID),Text.NUMBER); if (!(destinationData.get(Field.ID) instanceof Number uid)) throw invalidField(String.join(".",Field.DESTINATION,Field.ID),Text.NUMBER);
var u = userService().loadUser(uid.longValue()); destination = IdOrString.of(userService().loadUser(uid.longValue()));
destination = ""+u;
} else { } else {
if (!destinationData.has(Field.DISPLAY)) throw missingField(String.join(".",Field.DESTINATION,Field.DISPLAY)); if (!destinationData.has(Field.DISPLAY)) throw missingField(String.join(".",Field.DESTINATION,Field.DISPLAY));
destination = destinationData.getString(Field.DISPLAY); destination = IdOrString.of(destinationData.getString(Field.DISPLAY));
if (destination.isBlank()) throw invalidField(String.join(".",Field.DESTINATION,Field.DISPLAY),Text.STRING); if (destination.value().isBlank()) throw invalidField(String.join(".",Field.DESTINATION,Field.DISPLAY),Text.STRING);
} }

View File

@@ -147,7 +147,7 @@ public class SqliteDb extends BaseDb implements AccountDb {
try { try {
var timestamp = transaction.date().toEpochSecond(ZoneOffset.UTC); var timestamp = transaction.date().toEpochSecond(ZoneOffset.UTC);
Query.replaceInto(TABLE_TRANSACTIONS,Field.ACCOUNT,Field.TIMESTAMP,Field.SOURCE,Field.DESTINATION, Field.AMOUNT,Field.DESCRIPTION) Query.replaceInto(TABLE_TRANSACTIONS,Field.ACCOUNT,Field.TIMESTAMP,Field.SOURCE,Field.DESTINATION, Field.AMOUNT,Field.DESCRIPTION)
.values(transaction.accountId(),timestamp,transaction.source(),transaction.destination(),transaction.amount(),transaction.purpose()) .values(transaction.accountId(),timestamp,transaction.source().value(),transaction.destination().value(),transaction.amount(),transaction.purpose())
.execute(db).close(); .execute(db).close();
return transaction; return transaction;
} catch (SQLException e) { } catch (SQLException e) {

View File

@@ -0,0 +1,58 @@
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<String, Object> toMap() {
var map = new HashMap<String,Object>();
map.put(Field.VALUE,value);
if (isId()) map.put(Field.ID, id);
return map;
}
public String value(){
return value;
}
}

View File

@@ -5,20 +5,20 @@ import de.srsoftware.umbrella.core.constants.Field;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
import static de.srsoftware.umbrella.core.ModuleRegistry.userService; public record Transaction(long accountId, LocalDateTime date, IdOrString source, IdOrString destination, double amount, String purpose) implements Mappable {
public record Transaction(long accountId, LocalDateTime date, String source, String destination, double amount, String purpose) implements Mappable {
public static Transaction of(ResultSet rs) throws SQLException { public static Transaction of(ResultSet rs) throws SQLException {
var accountId = rs.getLong(Field.ACCOUNT); var accountId = rs.getLong(Field.ACCOUNT);
var timestamp = rs.getLong(Field.TIMESTAMP); var timestamp = rs.getLong(Field.TIMESTAMP);
var date = LocalDateTime.ofEpochSecond(timestamp,0, ZoneOffset.UTC); var date = LocalDateTime.ofEpochSecond(timestamp,0, ZoneOffset.UTC);
var source = rs.getString(Field.SOURCE); var source = IdOrString.of(rs.getString(Field.SOURCE));
var destination = rs.getString(Field.DESTINATION); var destination = IdOrString.of(rs.getString(Field.DESTINATION));
var amount = rs.getDouble(Field.AMOUNT); var amount = rs.getDouble(Field.AMOUNT);
var purpose = rs.getString(Field.DESCRIPTION); var purpose = rs.getString(Field.DESCRIPTION);
return new Transaction(accountId,date,source,destination,amount,purpose); return new Transaction(accountId,date,source,destination,amount,purpose);
@@ -26,25 +26,11 @@ public record Transaction(long accountId, LocalDateTime date, String source, Str
@Override @Override
public Map<String, Object> toMap() { public Map<String, Object> toMap() {
var source = this.source;
try {
var userId = Long.parseLong(source);
var user = userService().loadUser(userId);
source = user.name();
} catch (NumberFormatException ignored) {}
var destination = this.destination;
try {
var userId = Long.parseLong(destination);
var user = userService().loadUser(userId);
destination = user.name();
} catch (NumberFormatException ignored) {}
return Map.of( return Map.of(
Field.ACCOUNT, accountId, Field.ACCOUNT, accountId,
Field.DATE, date.toLocalDate(), Field.DATE, date.toLocalDate(),
Field.SOURCE, source, Field.SOURCE, source.toMap(),
Field.DESTINATION, destination, Field.DESTINATION, destination.toMap(),
Field.AMOUNT, amount, Field.AMOUNT, amount,
Field.PURPOSE, purpose Field.PURPOSE, purpose
); );

View File

@@ -5,6 +5,7 @@ package de.srsoftware.umbrella.core.model;
import static de.srsoftware.umbrella.core.constants.Field.*; import static de.srsoftware.umbrella.core.constants.Field.*;
import de.srsoftware.tools.Mappable; import de.srsoftware.tools.Mappable;
import de.srsoftware.umbrella.core.api.NamedThing;
import de.srsoftware.umbrella.core.api.Owner; import de.srsoftware.umbrella.core.api.Owner;
import de.srsoftware.umbrella.core.constants.Module; import de.srsoftware.umbrella.core.constants.Module;
import java.util.HashMap; import java.util.HashMap;

View File

@@ -4,18 +4,23 @@
import { error, yikes } from '../../warn.svelte'; import { error, yikes } from '../../warn.svelte';
import { t } from '../../translations.svelte'; import { t } from '../../translations.svelte';
let { id } = $props(); import EntryForm from './add_entry.svelte';
let account = $state(null);
let { id } = $props();
let account = $state(null);
let transactions = []; let transactions = [];
let users = {};
async function load(){ async function load(){
let url = api(`accounting/${id}`); let url = api(`accounting/${id}`);
let res = await get(url); let res = await get(url);
if (res.ok) { if (res.ok) {
yikes(); yikes();
let json = await res.json(); let json = await res.json();
transactions = json.transactions; transactions = json.transactions;
account = json.account; users = json.user_list;
account = json.account;
console.log(users);
} else error(res); } else error(res);
} }
@@ -28,9 +33,10 @@
<thead> <thead>
<tr> <tr>
<th>{t('date')}</th> <th>{t('date')}</th>
<th>{t('source')}</th> {#each Object.entries(users) as [id,user]}
<th>{t('destination')}</th> <th>{user.name}</th>
<th>{t('amount')}</th> {/each}
<th>{t('other party')}</th>
<th>{t('purpose')}</th> <th>{t('purpose')}</th>
</tr> </tr>
</thead> </thead>
@@ -38,13 +44,30 @@
{#each transactions as transaction, i} {#each transactions as transaction, i}
<tr> <tr>
<td>{transaction.date}</td> <td>{transaction.date}</td>
<td>{transaction.source}</td> {#each Object.entries(users) as [id,user]}
<td>{transaction.destination}</td> <td>
<td>{transaction.amount} {account.currency}</td> {#if id == transaction.source.id}
{-transaction.amount} {account.currency}
{/if}
{#if id == transaction.destination.id}
{transaction.amount} {account.currency}
{/if}
</td>
{/each}
<td>
{#if !transaction.source.id}
{transaction.source.value}
{/if}
{#if !transaction.destination.id}
{transaction.destination.value}
{/if}
</td>
<td>{transaction.purpose}</td> <td>{transaction.purpose}</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
</table> </table>
</fieldset> </fieldset>
<EntryForm {account} />
{/if} {/if}

View File

@@ -7,18 +7,19 @@
import { user } from '../../user.svelte'; import { user } from '../../user.svelte';
import Autocomplete from '../../Components/Autocomplete.svelte'; import Autocomplete from '../../Components/Autocomplete.svelte';
let { new_account = false } = $props(); let defaultAccount = {
id : 0,
name : '',
currency : ''
};
let { account = defaultAccount, new_account = false } = $props();
let entry = $state({ let entry = $state({
account : { account,
id : 0,
name : '',
currency : ''
},
date : new Date().toISOString().substring(0, 10), date : new Date().toISOString().substring(0, 10),
source : { source : {
display: user.name, display: user.name,
user_id: user.id id: user.id
}, },
destination : {}, destination : {},
amount : 0.0, amount : 0.0,
@@ -26,6 +27,19 @@
}); });
let router = useTinyRouter(); let router = useTinyRouter();
async function getUsers(text){
var url = api('user/search');
var res = await post(url,text);
if (res.ok){
yikes();
const input = await res.json();
return Object.values(input).map(user => { return {...user, display: user.name}});
} else {
error(res);
return {};
}
}
async function save(){ async function save(){
let data = { let data = {
...entry, ...entry,
@@ -68,10 +82,10 @@
<input type="date" value={entry.date} /> <input type="date" value={entry.date} />
<span>{t('source')}</span> <span>{t('source')}</span>
<Autocomplete bind:candidate={entry.source} /> <Autocomplete bind:candidate={entry.source} getCandidates={getUsers} />
<span>{t('destination')}</span> <span>{t('destination')}</span>
<Autocomplete bind:candidate={entry.destination} /> <Autocomplete bind:candidate={entry.destination} getCandidates={getUsers} />
<span>{t('amount')}</span> <span>{t('amount')}</span>
<span> <span>

View File

@@ -1,5 +1,5 @@
<script> <script>
import {api} from '../../urls.svelte' import {api, post} from '../../urls.svelte'
import { error, yikes } from '../../warn.svelte'; import { error, yikes } from '../../warn.svelte';
import {t} from '../../translations.svelte'; import {t} from '../../translations.svelte';
@@ -14,11 +14,7 @@
async function getCandidates(text){ async function getCandidates(text){
const url = api('user/search'); const url = api('user/search');
const resp = await fetch(url,{ const resp = await post(url,text);
credentials : 'include',
method : 'POST',
body : text
});
if (resp.ok){ if (resp.ok){
yikes(); yikes();
const input = await resp.json(); const input = await resp.json();

View File

@@ -35,7 +35,7 @@ export function post(url,data){
return fetch(url,{ return fetch(url,{
credentials : 'include', credentials : 'include',
method : 'POST', method : 'POST',
body : JSON.stringify(data) body : typeof data === 'string' ? data : JSON.stringify(data)
}); });
} }