Merge branch 'accounting' into dev
Build Docker Image / Docker-Build (push) Successful in 2m32s
Build Docker Image / Clean-Registry (push) Successful in 5s

This commit is contained in:
2026-04-23 10:37:56 +02:00
5 changed files with 111 additions and 33 deletions
@@ -12,6 +12,8 @@ public interface AccountDb {
Collection<Account> listAccounts(long userId);
Collection<String> listTags(long accountId, String source, String destination);
Account loadAccount(long accountId);
Transaction loadTransaction(long transactionId);
@@ -156,6 +156,25 @@ public class AccountingModule extends BaseHandler implements AccountingService {
return sendContent(ex, transaction);
}
private List<String> egalize(Set<String> tags, String key) {
var result = new HashSet<String>();
var lower = key.toLowerCase();
var len = key.length();
for (var tag : tags) {
if (tag.length() == key.length()) continue;
result.add(tag.toLowerCase().startsWith(lower) ? key + tag.substring(len) : tag);
}
return result.stream().sorted(String.CASE_INSENSITIVE_ORDER).toList();
}
private String extractParty(String field, JSONObject json){
if (!json.has(field)) return null;
if (!(json.get(field) instanceof JSONObject data)) return null;
if (data.has(Field.ID)) return data.get(Field.ID).toString();
if (data.has(Field.DISPLAY)) return data.get(Field.DISPLAY).toString();
return null;
}
private boolean getAccount(UmbrellaUser user, long accountId, HttpExchange ex) throws IOException {
LOG.log(WARNING,"Missing authorization check in AccountingModule.getAccount(…)!");
var account = accountDb.loadAccount(accountId);
@@ -294,20 +313,17 @@ public class AccountingModule extends BaseHandler implements AccountingService {
private boolean postSearchTags(long accountId, UmbrellaUser user, HttpExchange ex) throws IOException {
LOG.log(WARNING,"Missing authorization check in AccountingModule.getAccount(…)!");
var key = body(ex);
if (!key.trim().startsWith("{")) { // search tags that contain value of body
var tags = accountDb.searchTagsContaining(key, accountId);
if (tags.size() < 10) tags.addAll(tagService().search(key, user));
return sendContent(ex, egalize(tags, key));
}
private List<String> egalize(Set<String> tags, String key) {
var result = new HashSet<String>();
var lower = key.toLowerCase();
var len = key.length();
for (var tag : tags) {
if (tag.length() == key.length()) continue;
result.add(tag.toLowerCase().startsWith(lower) ? key + tag.substring(len) : tag);
}
return result.stream().sorted(String.CASE_INSENSITIVE_ORDER).toList();
// search tags for account with specified source and destination
var json = new JSONObject(key);
var src = extractParty(Field.SOURCE, json);
var dst = extractParty(Field.DESTINATION, json);
var tags = accountDb.listTags(accountId,src,dst);
return sendContent(ex,tags);
}
private boolean postToAccount(long accountId, Path path, UmbrellaUser user, HttpExchange ex) throws IOException {
@@ -150,6 +150,45 @@ public class SqliteDb extends BaseDb implements AccountDb {
}
}
@Override
public Collection<String> listTags(long accountId, String source, String destination) {
try {
var rs = select(TRANSACTION_ID,Field.TAG)
.from(TABLE_TRANSACTIONS)
.leftJoin(ID,TABLE_TAGS_TRANSACTIONS, TRANSACTION_ID)
.leftJoin(TAG_ID,TABLE_TAGS,ID)
.where(ACCOUNT,equal(accountId))
.where(SOURCE,equal(source))
.where(DESTINATION,equal(destination))
.sort(TRANSACTION_ID).exec(db);
Set<String> set = null;
Set<String> transactionTags = new TreeSet<String>(String.CASE_INSENSITIVE_ORDER);
Long lastTransaction = null;
while (rs.next()){
var currentTransaction = rs.getLong(TRANSACTION_ID);
if (lastTransaction == null) { // first row
transactionTags.add(rs.getString(TAG));
lastTransaction = currentTransaction;
} else if (lastTransaction == currentTransaction) {
transactionTags.add(rs.getString(TAG));
} else {
if (set == null) {
set = transactionTags;
} else {
set.retainAll(transactionTags);
}
transactionTags = new HashSet<>();
transactionTags.add(rs.getString(TAG));
lastTransaction = currentTransaction;
}
}
rs.close();
return set;
} catch (SQLException e){
throw failedToLoadMembers(accountId);
}
}
@Override
public Account loadAccount(long accountId) {
try {
+40 -19
View File
@@ -29,8 +29,35 @@
});
let router = useTinyRouter();
async function getTerminal(text,url){
var res = await post(url,text);
async function dst_selected(destination){
destination = JSON.parse(JSON.stringify(destination));
let source = JSON.parse(JSON.stringify(entry.source));
const url = api(`accounting/${entry.account.id}/tags`)
const res = await post(url,{source,destination});
if (res.ok) {
yikes();
const json = await res.json();
entry.tags = json;
} else error(res);
}
function focusOnEnter(ev,id){
if (ev.key == 'Enter') document.getElementById(id).focus();
}
async function getAccountTags(text){
if (!text) return [];
const url = api(`accounting/${entry.account.id}/tags`)
return await getProposals(text,url);
}
async function getDestinations(text){
const url = api('accounting/destinations');
return await getProposals(text,url);
}
async function getProposals(text,url){
const res = await post(url,text);
if (res.ok){
yikes();
const input = await res.json();
@@ -41,25 +68,19 @@
}
}
async function getAccountTags(text){
if (!text) return [];
var url = api(`accounting/${entry.account.id}/tags`)
return await getTerminal(text,url);
}
async function getDestinations(text){
var url = api('accounting/destinations');
return await getTerminal(text,url);
}
async function getPurposes(text) {
var url = api('accounting/purposes');
return await getTerminal(text,url);
const url = api('accounting/purposes');
return await getProposals(text,url);
}
async function getSources(text){
var url = api('accounting/sources');
return await getTerminal(text,url);
const url = api('accounting/sources');
return await getProposals(text,url);
}
function gotoTags(purpose){
document.getElementById('new_tag_input');
}
function mapDisplay(object){
@@ -131,15 +152,15 @@
<Autocomplete bind:candidate={entry.source} getCandidates={getSources} id="source-input" />
<span>{t('destination')}</span>
<Autocomplete bind:candidate={entry.destination} getCandidates={getDestinations} />
<Autocomplete bind:candidate={entry.destination} getCandidates={getDestinations} onSelect={dst_selected} />
<span>{t('amount')}</span>
<span>
<input type="number" bind:value={entry.amount} />&nbsp;{entry.account.currency}
<input type="number" bind:value={entry.amount} onkeyup={e => focusOnEnter(e,'purpose_input')} />&nbsp;{entry.account.currency}
</span>
<span>{t('purpose')}</span>
<Autocomplete bind:candidate={entry.purpose} getCandidates={getPurposes} />
<Autocomplete bind:candidate={entry.purpose} getCandidates={getPurposes} onCommit={gotoTags} id="purpose_input" />
<span>{t('tags')}</span>
<Tags getCandidates={getAccountTags} module={null} bind:tags={entry.tags} onEmptyCommit={save} />
+1 -1
View File
@@ -112,6 +112,6 @@
</span>
{/each}
<span class="tag editor">
<Autocomplete {getCandidates} {onCommit} {onSelect} />
<Autocomplete {getCandidates} {onCommit} {onSelect} id="new_tag_input" />
</span>
</div>