started to implement updates on transactions

Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
This commit is contained in:
2026-04-10 09:23:26 +02:00
parent ec3add70c6
commit 90a7c5dd18
21 changed files with 215 additions and 68 deletions

View File

@@ -12,6 +12,8 @@ public interface AccountDb {
Account loadAccount(long accountId);
Transaction loadTransaction(long transactionId);
List<Transaction> loadTransactions(Account account);
Account save(Account account);

View File

@@ -63,6 +63,30 @@ public class AccountingModule extends BaseHandler implements AccountingService {
}
}
@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);
@@ -131,6 +155,18 @@ 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 json = json(ex);
if (json.has(Field.DATE)){
var date = LocalDate.parse(json.getString(Field.DATE));
transaction.date(date);
accountDb.save(transaction);
}
return sendContent(ex,transaction);
}
private boolean postEntry(UmbrellaUser user, HttpExchange ex) throws IOException {
var json = json(ex);
if (!json.has(Field.ACCOUNT)) throw missingField(Field.ACCOUNT);

View File

@@ -6,6 +6,7 @@ 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.ID;
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.*;
import static de.srsoftware.umbrella.core.model.Translatable.t;
import static java.text.MessageFormat.format;
@@ -51,7 +52,7 @@ public class SqliteDb extends BaseDb implements AccountDb {
);""";
try {
sql = format(sql,TABLE_ACCOUNTS, Field.ID, Field.NAME, Field.OWNER, Field.CURRENCY);
sql = format(sql,TABLE_ACCOUNTS, ID, Field.NAME, Field.OWNER, Field.CURRENCY);
var stmt = db.prepareStatement(sql);
stmt.execute();
stmt.close();
@@ -68,7 +69,7 @@ public class SqliteDb extends BaseDb implements AccountDb {
);
""";
try {
sql = format(sql,TABLE_TAGS,Field.ID,Field.TAG);
sql = format(sql,TABLE_TAGS, ID,Field.TAG);
var stmt = db.prepareStatement(sql);
stmt.execute();
stmt.close();
@@ -107,7 +108,7 @@ public class SqliteDb extends BaseDb implements AccountDb {
{6} TEXT
);""";
try {
sql = format(sql,TABLE_TRANSACTIONS,Field.ACCOUNT,Field.TIMESTAMP,Field.SOURCE,Field.DESTINATION, Field.AMOUNT,Field.DESCRIPTION, Field.ID);
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();
@@ -127,7 +128,7 @@ public class SqliteDb extends BaseDb implements AccountDb {
while (rs.next()) accountIds.add(rs.getLong(1));
rs.close();
var accounts = new HashSet<Account>();
rs = select(ALL).from(TABLE_ACCOUNTS).where(Field.ID, Condition.in(accountIds.toArray())).exec(db);
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;
@@ -139,7 +140,7 @@ public class SqliteDb extends BaseDb implements AccountDb {
@Override
public Account loadAccount(long accountId) {
try {
var rs = select(ALL).from(TABLE_ACCOUNTS).where(Field.ID,equal(accountId)).exec(db);
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();
@@ -150,6 +151,20 @@ public class SqliteDb extends BaseDb implements AccountDb {
}
}
@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<Transaction> loadTransactions(Account account) {
try {
@@ -161,7 +176,7 @@ public class SqliteDb extends BaseDb implements AccountDb {
}
rs.close();
var transactionIds = transactions.keySet().toArray();
rs = select(ALL).from(TABLE_TAGS_TRANSACTIONS).leftJoin(Field.TAG_ID,TABLE_TAGS,Field.ID).where(Field.TRANSACTION_ID,in(transactionIds)).exec(db);
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));
@@ -191,9 +206,9 @@ public class SqliteDb extends BaseDb implements AccountDb {
@Override
public Transaction save(Transaction transaction) {
var timestamp = transaction.date().toEpochSecond(ZoneOffset.UTC);
if (transaction.id() == 0) {
try {
var timestamp = transaction.date().toEpochSecond(ZoneOffset.UTC);
var rs = Query.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();
@@ -202,8 +217,15 @@ public class SqliteDb extends BaseDb implements AccountDb {
} catch (SQLException e) {
throw failedToStoreObject(transaction);
}
} else { // TODO : implement update
throw UmbrellaException.failedToStoreObject(transaction);
} else {
try {
Query.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;
} catch (SQLException e) {
throw failedToStoreObject(transaction);
}
}
return saveTags(transaction);
}
@@ -213,7 +235,7 @@ public class SqliteDb extends BaseDb implements AccountDb {
var existingTags = new HashMap<String,Long>();
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(Field.ID));
while (rs.next()) existingTags.put(rs.getString(Field.TAG), rs.getLong(ID));
rs.close();
} catch (SQLException e){
throw failedToLoadMembers(transaction);
@@ -257,7 +279,7 @@ public class SqliteDb extends BaseDb implements AccountDb {
public Set<String> searchTagsContaining(String key, long accountId) {
try {
var tags = new HashSet<String>();
var rs = select(ALL).from(TABLE_TRANSACTIONS).leftJoin(Field.ID,TABLE_TAGS_TRANSACTIONS,Field.TRANSACTION_ID).leftJoin(Field.TAG_ID,TABLE_TAGS,Field.ID).where(Field.TAG,like(format("%{0}%",key))).exec(db);
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;

View File

@@ -53,9 +53,10 @@ public class Path {
public static final String STATE = "state";
public static final String STOP = "stop";
public static final String TAGS = "tags";
public static final String TAGGED = "tagged";
public static final String TOKEN = "token";
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";

View File

@@ -78,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";

View File

@@ -5,6 +5,7 @@ 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;
@@ -13,8 +14,53 @@ import java.util.Set;
import static java.text.MessageFormat.format;
public record Transaction(long id, long accountId, LocalDateTime date, IdOrString source, IdOrString destination, double amount, String purpose, Set<String> tags) implements Mappable {
public class Transaction implements Mappable {
private long accountId, id;
private LocalDateTime date;
private IdOrString source, destination;
private double amount;
private String purpose;
private Set<String> tags;
public Transaction(long id, long accountId, LocalDateTime date, IdOrString source, IdOrString destination, double amount, String purpose, Set<String> 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 LocalDateTime date(){
return date;
}
public Transaction date(LocalDateTime newVal){
date = newVal;
return this;
}
public void date(LocalDate date) {
this.date = this.date.withYear(date.getYear()).withMonth(date.getMonthValue()).withDayOfMonth(date.getDayOfMonth());
}
public IdOrString destination(){
return destination;
}
public long id(){
return id;
}
public static Transaction of(ResultSet rs) throws SQLException {
var accountId = rs.getLong(Field.ACCOUNT);
@@ -25,7 +71,19 @@ public record Transaction(long id, long accountId, LocalDateTime date, IdOrStrin
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, new HashSet<>());
return new Transaction(id, accountId, date, source, destination, amount, purpose, null);
}
public String purpose(){
return purpose;
}
public IdOrString source(){
return source;
}
public Set<String> tags(){
return tags;
}
@Override

View File

@@ -9,8 +9,9 @@
onclick = evt => { evt.preventDefault(); startEdit(); return false },
onSet = newVal => {return true;},
title = t('click_to_edit'),
type = 'div',
value = $bindable(null)
type = 'text',
value = $bindable(null),
wrapper = 'div'
} = $props();
let editing = $state(simple);
@@ -110,7 +111,7 @@
</style>
{#if editable && editing}
<input bind:value={editValue} onkeyup={typed} {title} autofocus />
<input bind:value={editValue} onkeyup={typed} {title} {type} autofocus />
{:else}
<svelte:element this={type} href={href} onclick={ignore} {onmousedown} {onmouseup} {ontouchstart} {ontouchend} {oncontextmenu} class={{editable}} {title} >{value}</svelte:element>
<svelte:element this={wrapper} href={href} onclick={ignore} {onmousedown} {onmouseup} {ontouchstart} {ontouchend} {oncontextmenu} class={{editable}} {title} >{value}</svelte:element>
{/if}

View File

@@ -5,6 +5,7 @@
import { t } from '../../translations.svelte';
import EntryForm from './add_entry.svelte';
import Transaction from './transaction.svelte';
let { id } = $props();
let account = $state(null);
@@ -67,31 +68,7 @@
</thead>
<tbody>
{#each transactions as transaction, i}
<tr>
<td>{transaction.date}</td>
{#each Object.entries(users) as [id,user]}
<td class="amount">
{#if id == transaction.source.id}
{(-transaction.amount).toFixed(2)}&nbsp;{account.currency}
{/if}
{#if id == transaction.destination.id}
{(+transaction.amount).toFixed(2)}&nbsp;{account.currency}
{/if}
</td>
{/each}
<td class="party">
{#if !transaction.source.id}
{transaction.source.value}
{/if}
{#if !transaction.destination.id}
{transaction.destination.value}
{/if}
</td>
<td class="purpose">{transaction.purpose}</td>
<td class="tags">
{transaction.tags.join(', ')}
</td>
</tr>
<Transaction {account} {transaction} {users} />
{/each}
<tr>
<td>

View File

@@ -0,0 +1,49 @@
<script>
import LineEditor from '../../Components/LineEditor.svelte';
import { api, patch } from '../../urls.svelte';
import { error, yikes } from '../../warn.svelte';
let { account, transaction, users } = $props();
async function update(changes){
let url = api('accounting/transaction/'+transaction.id);
let res = await patch(url,changes);
if (res.ok){
yikes();
return true;
}
error(res);
return false;
}
async function setDate(newDate){
return await update({date:newDate});
}
</script>
<tr>
<td>
<LineEditor type="date" wrapper="span" editable="true" value={transaction.date} onSet={setDate} />
</td>
{#each Object.entries(users) as [id,user]}
<td class="amount">
{#if id == transaction.source.id}
{(-transaction.amount).toFixed(2)}&nbsp;{account.currency}
{/if}
{#if id == transaction.destination.id}
{(+transaction.amount).toFixed(2)}&nbsp;{account.currency}
{/if}
</td>
{/each}
<td class="party">
{#if !transaction.source.id}
{transaction.source.value}
{/if}
{#if !transaction.destination.id}
{transaction.destination.value}
{/if}
</td>
<td class="purpose">{transaction.purpose}</td>
<td class="tags">
{transaction.tags.join(', ')}
</td>
</tr>

View File

@@ -36,11 +36,11 @@
<span class="symbol {home?'':'inactive'}" onclick={toggleHome}></span>
<span class="symbol {work?'':'inactive'}" onclick={toggleWork} ></span>
</div>
<LineEditor type="span" editable={true} value={address.box} onSet={newVal => onSet(address.box,newVal)} title={t('post_box')} />
<LineEditor type="span" editable={true} value={address.ext} onSet={newVal => onSet(address.ext,newVal)} title={t('extended_address')} />
<LineEditor type="span" editable={true} value={address.street} onSet={newVal => onSet(address.street,newVal)} title={t('street')} />
<LineEditor type="span" editable={true} value={address.post_code} onSet={newVal => onSet(address.post_code,newVal)} title={t('post_code')} />
<LineEditor type="span" editable={true} value={address.locality} onSet={newVal => onSet(address.locality,newVal)} title={t('locality')} />
<LineEditor type="span" editable={true} value={address.region} onSet={newVal => onSet(address.region,newVal)} title={t('region')} />
<LineEditor type="span" editable={true} value={address.country} onSet={newVal => onSet(address.country,newVal)} title={t('country')} />
<LineEditor wrapper="span" editable={true} value={address.box} onSet={newVal => onSet(address.box,newVal)} title={t('post_box')} />
<LineEditor wrapper="span" editable={true} value={address.ext} onSet={newVal => onSet(address.ext,newVal)} title={t('extended_address')} />
<LineEditor wrapper="span" editable={true} value={address.street} onSet={newVal => onSet(address.street,newVal)} title={t('street')} />
<LineEditor wrapper="span" editable={true} value={address.post_code} onSet={newVal => onSet(address.post_code,newVal)} title={t('post_code')} />
<LineEditor wrapper="span" editable={true} value={address.locality} onSet={newVal => onSet(address.locality,newVal)} title={t('locality')} />
<LineEditor wrapper="span" editable={true} value={address.region} onSet={newVal => onSet(address.region,newVal)} title={t('region')} />
<LineEditor wrapper="span" editable={true} value={address.country} onSet={newVal => onSet(address.country,newVal)} title={t('country')} />
</div>

View File

@@ -33,5 +33,5 @@
{#if value}
<span class="symbol {home?'':'inactive'}" onclick={toggleHome}></span>
<span class="symbol {work?'':'inactive'}" onclick={toggleWork} ></span>
<LineEditor type="span" editable={true} {value} {onSet} /><br/>
<LineEditor wrapper="span" editable={true} {value} {onSet} /><br/>
{/if}

View File

@@ -19,7 +19,7 @@
{#if field.value.includes('\\n')}
<MultiLineEditor type="div" editable={true} value={field.value.replaceAll('\\n','\n')} {onSet} title={t(field.name)+' '+t('right_click_to_edit')} />
{:else}
<LineEditor type="div" editable={true} value={field.value} {onSet} title={t(field.name)+' '+t('click_to_edit')} />
<LineEditor editable={true} value={field.value} {onSet} title={t(field.name)+' '+t('click_to_edit')} />
{/if}
</div>
{/if}

View File

@@ -14,5 +14,5 @@
</script>
{#if value}
<LineEditor type="span" editable={true} {value} {onSet} title={t('formatted_name')}/>
<LineEditor wrapper="span" editable={true} {value} {onSet} title={t('formatted_name')}/>
{/if}

View File

@@ -16,18 +16,18 @@
<div class="name">
{#if n.prefix}
<LineEditor type="span" editable={true} value={n.prefix} onSet={newVal => onSet(n.prefix,newVal)} title={t('name_prefix')} />
<LineEditor wrapper="span" editable={true} value={n.prefix} onSet={newVal => onSet(n.prefix,newVal)} title={t('name_prefix')} />
{/if}
{#if n.given}
<LineEditor type="span" editable={true} value={n.given} onSet={newVal => onSet(n.given,newVal)} title={t('given_name')} />
<LineEditor wrapper="span" editable={true} value={n.given} onSet={newVal => onSet(n.given,newVal)} title={t('given_name')} />
{/if}
{#if n.additional}
<LineEditor type="span" editable={true} value={n.additional} onSet={newVal => onSet(n.additional,newVal)} title={t('additional_name')} />
<LineEditor wrapper="span" editable={true} value={n.additional} onSet={newVal => onSet(n.additional,newVal)} title={t('additional_name')} />
{/if}
{#if n.family}
<LineEditor type="span" editable={true} value={n.family} onSet={newVal => onSet(n.family,newVal)} title={t('family_name')} />
<LineEditor wrapper="span" editable={true} value={n.family} onSet={newVal => onSet(n.family,newVal)} title={t('family_name')} />
{/if}
{#if n.suffix}
<LineEditor type="span" editable={true} value={n.suffix} onSet={newVal => onSet(n.suffix,newVal)} title={t('<name_suffix></name_suffix>')} />
<LineEditor wrapper="span" editable={true} value={n.suffix} onSet={newVal => onSet(n.suffix,newVal)} title={t('<name_suffix></name_suffix>')} />
{/if}
</div>

View File

@@ -39,5 +39,5 @@
<span class="symbol {cell?'':'inactive'}" onclick={toggleCell} ></span>
<span class="symbol {home?'':'inactive'}" onclick={toggleHome}></span>
<span class="symbol {work?'':'inactive'}" onclick={toggleWork} ></span>
<LineEditor type="span" editable={true} {value} {onSet} /><br/>
<LineEditor wrapper="span" editable={true} {value} {onSet} /><br/>
{/if}

View File

@@ -14,5 +14,5 @@
</script>
{#if value}
<LineEditor type="span" editable={true} {value} {onSet} title={t('organization')}/>
<LineEditor wrapper="span" editable={true} {value} {onSet} title={t('organization')}/>
{/if}

View File

@@ -14,5 +14,5 @@
</script>
{#if value}
<LineEditor type="div" editable={true} {value} {onSet} title={t('url')}/>
<LineEditor editable={true} {value} {onSet} title={t('url')}/>
{/if}

View File

@@ -263,7 +263,7 @@
<div class="items">
{#if location}
<h3>
<LineEditor editable={true} bind:value={location.name} type="span" onSet={newName => patchLocation(location,'name',newName)} />
<LineEditor editable={true} bind:value={location.name} wrapper="span" onSet={newName => patchLocation(location,'name',newName)} />
<button class="symbol" title={t('delete_object',{object:t('location')})} onclick={e => deleteLocation(location)}></button>
{#if location.parent_location_id}
<button class="symbol" title={t('move_to_top')} onclick={e => moveToTop(location)}></button>

View File

@@ -68,7 +68,7 @@
</script>
{#if item}
<LineEditor type="h3" editable={true} value={item.name} onSet={v => update('name',v)} />
<LineEditor wrapper="h3" editable={true} value={item.name} onSet={v => update('name',v)} />
<button class="clone symbol" title={t('clone')} onclick={doClone}></button>
<div>
{@html item.description.rendered}
@@ -80,7 +80,7 @@
{t('ID')}
</td>
<td>
<LineEditor type="span" editable={true} value={item.code} onSet={v => update('code',v)} />
<LineEditor wrapper="span" editable={true} value={item.code} onSet={v => update('code',v)} />
</td>
</tr>
{#each item.properties.toSorted(byName) as prop}

View File

@@ -156,7 +156,7 @@
{#if !deleted}
<li draggable="true" {ondrop} ondragover={e => e.preventDefault()} {ondragstart} class="task {states[task.status]?.toLowerCase()}">
<LineEditor bind:value={task.name} onclick={openTask} editable={true} onSet={setName} type="a" href={`/task/${task.id}/view`} />
<LineEditor bind:value={task.name} onclick={openTask} editable={true} onSet={setName} wrapper="a" href={`/task/${task.id}/view`} />
{#if task.est_time}
<span class="estimated_time">({+task.est_time}&nbsp;h)</span>
{/if}

View File

@@ -165,7 +165,7 @@
</span>
{/each}
</div>
<LineEditor value={page.title} type="h2" {editable} onSet={t => patchTitle(t)} />
<LineEditor value={page.title} wrapper="h2" {editable} onSet={t => patchTitle(t)} />
{#if page.version != page.versions[0]}
<span class="warn">{t('not_recent_version')}</span>
{/if}