Merge branch 'accounting'
Build Docker Image / Docker-Build (push) Successful in 2m36s
Build Docker Image / Clean-Registry (push) Successful in 4s

This commit is contained in:
2026-05-04 20:57:29 +02:00
11 changed files with 102 additions and 10 deletions
@@ -5,11 +5,14 @@ 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.Optional;
import java.util.Set;
public interface AccountDb {
void dropTransactionTag(long transactionId, String tag);
Optional<Transaction> lastTransaction(long accountId, String source, String dest, double amount);
Collection<Account> listAccounts(long userId);
Collection<String> listTags(long accountId, String source, String destination);
@@ -295,6 +295,22 @@ public class AccountingModule extends BaseHandler implements AccountingService {
return sendContent(ex,newAccount != null ? newAccount : transaction);
}
public boolean postGetLastTransaction(long accountId, UmbrellaUser user, HttpExchange ex) throws IOException {
var json = json(ex);
if (!json.has(Field.SOURCE)) throw missingField(Field.SOURCE);
if (!(json.get(Field.SOURCE) instanceof JSONObject src)) throw invalidField(Field.SOURCE,JSON);
var source = src.get(src.has(Field.ID) ? Field.ID : Field.DISPLAY).toString();
if (!json.has(Field.DESTINATION)) throw missingField(Field.DESTINATION);
if (!(json.get(Field.DESTINATION) instanceof JSONObject dst)) throw invalidField(Field.SOURCE,JSON);
var dest = dst.get(dst.has(Field.ID) ? Field.ID : Field.DISPLAY).toString();
if (!json.has(Field.AMOUNT)) throw missingField(Field.AMOUNT);
if (!(json.get(Field.AMOUNT) instanceof Number amt)) throw invalidField(Field.AMOUNT,Text.NUMBER);
var amount = amt.doubleValue();
var transaction = accountDb.lastTransaction(accountId, source, dest, amount);
return transaction.isPresent() ? sendContent(ex,transaction.get()) : notFound(ex);
}
public boolean postSearchDestinations(UmbrellaUser user, HttpExchange ex) throws IOException {
return sendContent(ex,searchOptions(user, Field.DESTINATION, body(ex)));
}
@@ -327,7 +343,9 @@ public class AccountingModule extends BaseHandler implements AccountingService {
}
private boolean postToAccount(long accountId, Path path, UmbrellaUser user, HttpExchange ex) throws IOException {
return switch (path.pop()) {
var head = path.pop();
return switch (head) {
case PURPOSES -> postGetLastTransaction(accountId,user,ex);
case TAGS -> postSearchTags(accountId,user,ex);
case null, default -> super.doPost(path,ex);
};
@@ -2,6 +2,7 @@
package de.srsoftware.umbrella.accounting;
import static de.srsoftware.tools.NotImplemented.notImplemented;
import static de.srsoftware.tools.Optionals.nullable;
import static de.srsoftware.tools.jdbc.Condition.*;
import static de.srsoftware.tools.jdbc.Query.*;
import static de.srsoftware.tools.jdbc.Query.SelectQuery.ALL;
@@ -130,6 +131,29 @@ public class SqliteDb extends BaseDb implements AccountDb {
}
}
@Override
public Optional<Transaction> lastTransaction(long accountId, String source, String dest, double amount) {
try {
var rs = select(ALL).from(TABLE_TRANSACTIONS)
.where(ACCOUNT,equal(accountId)).where(SOURCE,equal(source)).where(DESTINATION,equal(dest)).where(AMOUNT,equal(amount))
.sort(ID+" DESC")
.limit(1)
.exec(db);
Transaction ta = null;
if (rs.next()) ta = Transaction.of(rs);
rs.close();
if (ta != null){
var tags = ta.tags();
rs = select(TAG).from(TABLE_TAGS_TRANSACTIONS).leftJoin(TAG_ID,TABLE_TAGS,ID).where(TRANSACTION_ID,equal(ta.id())).exec(db);
while (rs.next()) tags.add(rs.getString(1));
rs.close();
}
return nullable(ta);
} catch (SQLException e) {
throw failedToSearchDb(t(Text.ACCOUNTING));
}
}
@Override
public HashSet<Account> listAccounts(long userId) {
try {
@@ -271,7 +295,7 @@ public class SqliteDb extends BaseDb implements AccountDb {
}
} else if (transaction.isDirty()) {
try {
if (transaction.amount() == 0) {
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)
@@ -6,6 +6,9 @@ import de.srsoftware.umbrella.core.constants.Field;
import java.util.HashMap;
import java.util.Map;
import static de.srsoftware.tools.Optionals.isSet;
import static de.srsoftware.tools.Optionals.nullIfEmpty;
public class IdOrString implements Mappable {
private final Long id;
private final String value;
@@ -52,6 +55,10 @@ public class IdOrString implements Mappable {
return map;
}
public boolean isEmpty() {
return id == null && !isSet(value);
}
@Override
public String toString() {
return value;
+3 -3
View File
@@ -144,12 +144,12 @@
</style>
<span>
<span class="autocomplete">
<input type="text" bind:value={candidate.display} {onkeyup} autofocus={autofocus} {id} />
{#if candidates && candidates.length > 0}
<ul bind:this={list_elem}>
<ul bind:this={list_elem} class="suggestions">
{#each candidates as candidate,i}
<li class="option {selected==i?'highlight':''}" onclick={e => selected = i} ondblclick={e => select(i)}>{candidate.display}</li>
<li class="option {selected==i?'highlight':''}" onclick={e => select(i)} ondblclick={e => select(i)}>{candidate.display}</li>
{/each}
</ul>
{/if}
@@ -77,6 +77,12 @@
.amount{ text-align: right }
</style>
<svelte:head>
{#if account}
<title>Umbrella {account.name}</title>
{/if}
</svelte:head>
{#if filter.length > 0}
<fieldset>
<legend>{t('filter by tags')}</legend>
@@ -42,7 +42,10 @@
}
function focusOnEnter(ev,id){
if (ev.key == 'Enter') document.getElementById(id).focus();
if (ev.key == 'Enter') {
proposePurpose();
document.getElementById(id).focus();
}
}
async function getAccountTags(text){
@@ -93,6 +96,20 @@
}
}
async function proposePurpose(){
const source = entry.source;
const destination = entry.destination;
const amount = entry.amount;
const url = api(`accounting/${account.id}/purposes`);
const res = await post(url,{source,destination,amount});
if (res.ok) {
yikes();
var lastTransaction = await res.json();
entry.purpose = { display: lastTransaction.purpose};
entry.tags = lastTransaction.tags;
} else error(res);
}
async function save(){
let data = {
...entry,
@@ -32,6 +32,10 @@
onMount(load);
</script>
<svelte:head>
<title>Umbrella {t('accounts')}</title>
</svelte:head>
<fieldset>
<span></span>
+6 -1
View File
@@ -542,7 +542,8 @@ select.autocomplete{
}
@media screen and (max-width: 600px) {
.grid2{
.grid2,
.grid3{
display: grid;
grid-template-columns: auto;
}
@@ -582,6 +583,10 @@ select.autocomplete{
#app nav.expanded .timetracking{
grid-column-end: span 2;
}
.autocomplete .suggestions > *{
font-size: 1.5em;
}
}
fieldset.vcard{
@@ -711,6 +711,10 @@ select.autocomplete{
#app nav.expanded .timetracking{
grid-column-end: span 2;
}
.autocomplete .suggestions > *{
font-size: 1.5em;
}
}
fieldset.vcard{
@@ -701,6 +701,10 @@ select.autocomplete{
#app nav.expanded .timetracking{
grid-column-end: span 2;
}
.autocomplete .suggestions > *{
font-size: 1.5em;
}
}
fieldset.vcard{