src/transaction/pane.js
/* Financial Transaction Pane
**
** This outline pane allows a user to interact with a transaction
** downloaded from a bank statement, annotting it with classes and comments,
** trips, etc
*/
const UI = require('solid-ui')
const $rdf = require('rdflib')
const ns = UI.ns
module.exports = {
// icon: (module.__dirname || __dirname) + '22-pixel-068010-3d-transparent-glass-icon-alphanumeric-dollar-sign.png',
icon: UI.icons.iconBase + 'noun_106746.svg',
name: 'transaction',
audience: [ns.solid('PowerUser')],
// Does the subject deserve this pane?
label: function (subject, context) {
var kb = context.session.store
var t = kb.findTypeURIs(subject)
if (t['http://www.w3.org/2000/10/swap/pim/qif#Transaction']) return '$$'
if (kb.any(subject, UI.ns.qu('amount'))) return '$$$' // In case schema not picked up
// if (t['http://www.w3.org/2000/10/swap/pim/qif#Period']) return "period $"
if (t['http://www.w3.org/ns/pim/trip#Trip']) return 'Trip $'
return null // No under other circumstances (while testing at least!)
},
render: function (subject, context) {
const dom = context.dom
var kb = context.session.store
var fetcher = kb.fetcher
var Q = $rdf.Namespace('http://www.w3.org/2000/10/swap/pim/qif#')
var TRIP = $rdf.Namespace('http://www.w3.org/ns/pim/trip#')
var div = dom.createElement('div')
div.setAttribute('class', 'transactionPane')
var mention = function mention (message, style) {
if (style === undefined) style = 'color: grey'
var pre = dom.createElement('pre')
pre.setAttribute('style', style)
div.appendChild(pre)
pre.appendChild(dom.createTextNode(message))
}
var complain = function complain (message) {
return mention(message, 'color: #100; background-color: #fee')
}
/*
var thisPane = this
var rerender = function (div) {
var parent = div.parentNode
var div2 = thisPane.render(subject, dom)
parent.replaceChild(div2, div)
}
*/
// //////////////////////////////////////////////////////////////////////////////
var plist = kb.statementsMatching(subject)
var qlist = kb.statementsMatching(undefined, undefined, subject)
var t = kb.findTypeURIs(subject)
// var me = UI.authn.currentUser()
var predicateURIsDone = {}
var donePredicate = function (pred) {
predicateURIsDone[pred.uri] = true
}
var setPaneStyle = function (account) {
var mystyle = 'padding: 0.5em 1.5em 1em 1.5em; '
if (account) {
var backgroundColor = kb.any(account, UI.ns.ui('backgroundColor'))
if (backgroundColor) {
mystyle += 'background-color: ' + backgroundColor.value + '; '
}
}
div.setAttribute('style', mystyle)
}
// setPaneStyle()
// Functions for displaying lists of transactions
// Click on the transaction line to expand it into a pane
// Shift-click to expand without collapsing others
var d2 = function (n) {
if (n === undefined) return ''
var s = '' + n
if (s.indexOf('.') >= 0) {
return s.split('.')[0] + '.' + (s.split('.')[1] + '00').slice(0, 2)
}
return s + '.00'
}
var numericCell = function numericCell (amount, suppressZero) {
var td = dom.createElement('td')
if (!(0.0 + amount === 0.0 && suppressZero)) {
td.textContent = d2(amount)
}
td.setAttribute('style', 'width: 6em; text-align: right; ')
return td
}
var headerCell = function headerCell (str) {
var td = dom.createElement('th')
td.textContent = str
td.setAttribute('style', 'text-align: right; ')
return td
}
var oderByDate = function (x, y) {
const dx = kb.any(x, ns.qu('date'))
const dy = kb.any(y, ns.qu('date'))
if (dx !== undefined && dy !== undefined) {
if (dx.value < dy.value) return -1
if (dx.value > dy.value) return 1
}
if (x.uri < y.uri) return -1 // Arbitrary but repeatable
if (x.uri > y.uri) return 1
return 0
}
var insertedPane = function (context, subject, paneName) {
var p = context.session.paneRegistry.byName(paneName)
var d = p.render(subject, context)
d.setAttribute('style', 'border: 0.1em solid green;')
return d
}
var expandAfterRow = function (dom, row, subject, paneName, solo) {
var siblings = row.parentNode.children
if (solo) {
for (var j = siblings.length - 1; j >= 0; j--) {
if (siblings[j].expanded) {
siblings[j].parentNode.removeChild(siblings[j].expanded)
siblings[j].expanded = false
}
}
}
var tr = dom.createElement('tr')
var td = tr.appendChild(dom.createElement('td'))
td.setAttribute(
'style',
'width: 98%; padding: 1em; border: 0.1em solid grey;'
)
var cols = row.children.length
if (row.nextSibling) {
row.parentNode.insertBefore(tr, row.nextSibling)
} else {
row.parentNode.appendChild(tr)
}
row.expanded = tr
td.setAttribute('colspan', '' + cols)
td.appendChild(insertedPane(context, subject, paneName))
}
var expandAfterRowOrCollapse = function (
dom,
row,
subject,
paneName,
solo
) {
if (row.expanded) {
row.parentNode.removeChild(row.expanded)
row.expanded = false
} else {
expandAfterRow(dom, row, subject, paneName, solo)
}
}
var transactionTable = function (dom, list, filter) {
var table = dom.createElement('table')
table.setAttribute(
'style',
'padding-left: 0.5em; padding-right: 0.5em; font-size: 9pt; width: 85%;'
)
var transactionRow = function (dom, x) {
var tr = dom.createElement('tr')
var setTRStyle = function (tr, account) {
// var mystyle = "padding: 0.5em 1.5em 1em 1.5em; "
var mystyle =
"'padding-left: 0.5em; padding-right: 0.5em; padding-top: 0.1em;"
if (account) {
var backgroundColor = kb.any(account, UI.ns.ui('backgroundColor'))
if (backgroundColor) {
mystyle += 'background-color: ' + backgroundColor.value + '; '
}
}
tr.setAttribute('style', mystyle)
}
var account = kb.any(x, ns.qu('toAccount'))
setTRStyle(tr, account)
var c0 = tr.appendChild(dom.createElement('td'))
var date = kb.any(x, ns.qu('date'))
c0.textContent = date ? date.value.slice(0, 10) : '???'
c0.setAttribute('style', 'width: 7em;')
var c1 = tr.appendChild(dom.createElement('td'))
c1.setAttribute('style', 'width: 36em;')
var payee = kb.any(x, ns.qu('payee'))
c1.textContent = payee ? payee.value : '???'
var a1 = c1.appendChild(dom.createElement('a'))
a1.textContent = ' ➜'
a1.setAttribute('href', x.uri)
var c3 = tr.appendChild(dom.createElement('td'))
var amount = kb.any(x, ns.qu('in_USD'))
c3.textContent = amount ? d2(amount.value) : '???'
c3.setAttribute('style', 'width: 6em; text-align: right; ') // @@ decimal alignment?
tr.addEventListener(
'click',
function (e) {
// solo unless shift key
expandAfterRowOrCollapse(dom, tr, x, 'transaction', !e.shiftKey)
},
false
)
return tr
}
var list2 = filter ? list.filter(filter) : list.slice() // don't sort a paramater passed in place
list2.sort(oderByDate)
for (var i = 0; i < list2.length; i++) {
table.appendChild(transactionRow(dom, list2[i]))
}
return table
}
// Render a single transaction
// This works only if enough metadata about the properties can drive the RDFS
// (or actual type statements whichtypically are NOT there on)
if (
t['http://www.w3.org/2000/10/swap/pim/qif#Transaction'] ||
kb.any(subject, ns.qu('toAccount'))
) {
// var trip = kb.any(subject, WF('trip'))
donePredicate(ns.rdf('type'))
var account = kb.any(subject, UI.ns.qu('toAccount'))
setPaneStyle(account)
if (!account) {
complain(
'(Error: There is no bank account known for this transaction <' +
subject.uri +
'>,\n -- every transaction needs one.)'
)
}
var store = null
var statement = kb.any(subject, UI.ns.qu('accordingTo'))
if (statement === undefined) {
complain(
'(Error: There is no back link to the original data source foir this transaction <' +
subject.uri +
">,\nso I can't tell how to annotate it.)"
)
} else {
store =
statement !== undefined
? kb.any(statement, UI.ns.qu('annotationStore'))
: null
if (store === undefined) {
complain(
'(There is no annotation document for this statement\n<' +
statement.uri +
'>,\nso you cannot classify this transaction.)'
)
}
}
var nav = dom.createElement('div')
nav.setAttribute('style', 'float:right')
div.appendChild(nav)
var navLink = function (pred, label) {
donePredicate(pred)
var obj = kb.any(subject, pred)
if (!obj) return
var a = dom.createElement('a')
a.setAttribute('href', obj.uri)
a.setAttribute('style', 'float:right')
nav.appendChild(a).textContent = label || UI.utils.label(obj)
nav.appendChild(dom.createElement('br'))
}
navLink(UI.ns.qu('toAccount'))
navLink(UI.ns.qu('accordingTo'), 'Statement')
navLink(TRIP('trip'))
// Basic data:
var table = dom.createElement('table')
div.appendChild(table)
var preds = ['date', 'payee', 'amount', 'in_USD', 'currency'].map(Q)
var inner = preds
.map(function (p) {
donePredicate(p)
var value = kb.any(subject, p)
var s = value ? UI.utils.labelForXML(value) : ''
return (
'<tr><td style="text-align: right; padding-right: 0.6em">' +
UI.utils.labelForXML(p) +
'</td><td style="font-weight: bold;">' +
s +
'</td></tr>'
)
})
.join('\n')
table.innerHTML = inner
var complainIfBad = function (ok, body) {
if (ok) {
// setModifiedDate(store, kb, store)
// rerender(div) // deletes the new trip form
} else complain('Sorry, failed to save your change:\n' + body)
}
// What trips do we know about?
// Classify:
if (store) {
kb.fetcher.nowOrWhenFetched(store.uri, subject, function (ok, body) {
if (!ok) complain('Cannot load store ' + store + ' ' + body)
var calendarYear = kb.any(store, ns.qu('calendarYear'))
var renderCatgorySelectors = function () {
div.insertBefore(
UI.widgets.makeSelectForNestedCategory(
dom,
kb,
subject,
UI.ns.qu('Classified'),
store,
complainIfBad
),
table.nextSibling
)
}
if (kb.any(undefined, ns.rdfs('subClassOf'), ns.qu.Classified)) {
renderCatgorySelectors()
} else if (calendarYear) {
fetcher
.load(calendarYear)
.then(function (_xhrs) {
fetcher
.load(kb.each(calendarYear, ns.rdfs('seeAlso')))
.then(function () {
renderCatgorySelectors()
})
.catch(function (e) {
console.log('Error loading background data: ' + e)
})
})
.catch(function (e) {
console.log('Error loading calendarYear: ' + e)
})
} else {
console.log("Can't get categories, because no calendarYear")
}
div.appendChild(
UI.widgets.makeDescription(
dom,
kb,
subject,
UI.ns.rdfs('comment'),
store,
complainIfBad
)
)
var trips = kb
.statementsMatching(undefined, TRIP('trip'), undefined, store)
.map(function (st) {
return st.object
}) // @@ Use rdfs
var trips2 = kb.each(undefined, UI.ns.rdf('type'), TRIP('Trip'))
trips = trips.concat(trips2).sort() // @@ Unique
var sortedBy = function (kb, list, pred, reverse) {
const l2 = list.map(function (x) {
var key = kb.any(x, pred)
key = key ? key.value : '9999-12-31'
return [key, x]
})
l2.sort()
if (reverse) l2.reverse()
return l2.map(function (pair) {
return pair[1]
})
}
trips = sortedBy(kb, trips, UI.ns.cal('dtstart'), true) // Reverse chron
if (trips.length > 1) {
div.appendChild(
UI.widgets.makeSelectForOptions(
dom,
kb,
subject,
TRIP('trip'),
trips,
{
multiple: false,
nullLabel: '-- what trip? --',
mint: 'New Trip *',
mintClass: TRIP('Trip'),
mintStatementsFun: function (trip) {
var is = []
is.push(
$rdf.st(trip, UI.ns.rdf('type'), TRIP('Trip'), trip.doc())
)
return is
}
},
store,
complainIfBad
)
)
}
div.appendChild(dom.createElement('br'))
// Add in simple comments about the transaction
var outliner = context.getOutliner(dom)
donePredicate(ns.rdfs('comment')) // Done above
UI.widgets.attachmentList(dom, subject, div)
donePredicate(ns.wf('attachment'))
div
.appendChild(dom.createElement('tr'))
.setAttribute('style', 'height: 1em') // spacer
// Remaining properties
outliner.appendPropertyTRs(div, plist, false, function (
pred,
_inverse
) {
return !(pred.uri in predicateURIsDone)
})
outliner.appendPropertyTRs(div, qlist, true, function (
pred,
_inverse
) {
return !(pred.uri in predicateURIsDone)
})
}) // fetch
}
// end of render tranasaction instance
// ////////////////////////////////////////////////////////////////////
//
// Render the transactions in a Trip
//
} else if (t['http://www.w3.org/ns/pim/trip#Trip']) {
/*
var query = new $rdf.Query(UI.utils.label(subject))
var vars = [ 'date', 'transaction', 'comment', 'type', 'in_USD']
var v = {}
vars.map(function(x){query.vars.push(v[x]=$rdf.variable(x))}) // Only used by UI
query.pat.add(v['transaction'], TRIP('trip'), subject)
var opt = kb.formula()
opt.add(v['transaction'], ns.rdf('type'), v['type']); // Issue: this will get stored supertypes too
query.pat.optional.push(opt)
query.pat.add(v['transaction'], UI.ns.qu('date'), v['date'])
var opt = kb.formula()
opt.add(v['transaction'], ns.rdfs('comment'), v['comment'])
query.pat.optional.push(opt)
//opt = kb.formula()
query.pat.add(v['transaction'], UI.ns.qu('in_USD'), v['in_USD'])
//query.pat.optional.push(opt)
var tableDiv = UI.widgets.renderTableViewPane(dom, {'query': query, 'onDone': calculations} )
div.appendChild(tableDiv)
*/
var calculations = function () {
var total = {}
var yearTotal = {}
var yearCategoryTotal = {}
var trans = kb.each(undefined, TRIP('trip'), subject)
var bankStatements = trans.map(function (t) {
return t.doc()
})
kb.fetcher
.load(bankStatements)
.then(function () {
trans.map(function (t) {
var date = kb.the(t, ns.qu('date'))
var year = date ? ('' + date.value).slice(0, 4) : '????'
var ty = kb.the(t, ns.rdf('type')) // @@ find most specific type
// complain(" -- one trans: "+t.uri + ' -> '+kb.any(t, UI.ns.qu('in_USD')))
if (!ty) ty = UI.ns.qu('ErrorNoType')
if (ty && ty.uri) {
var tyuri = ty.uri
if (!yearTotal[year]) yearTotal[year] = 0.0
if (!yearCategoryTotal[year]) yearCategoryTotal[year] = {}
if (!total[tyuri]) total[tyuri] = 0.0
if (!yearCategoryTotal[year][tyuri]) {
yearCategoryTotal[year][tyuri] = 0.0
}
var lit = kb.any(t, UI.ns.qu('in_USD'))
if (!lit) {
complain('@@ No amount in USD: ' + lit + ' for ' + t)
}
if (lit) {
var amount = parseFloat(lit.value)
total[tyuri] += amount
yearCategoryTotal[year][tyuri] += amount
yearTotal[year] += amount
}
}
})
var types = []
var grandTotal = 0.0
var years = []
var i
for (var y in yearCategoryTotal) {
// @@ TODO: Write away the need for exception on next line
// eslint-disable-next-line no-prototype-builtins
if (yearCategoryTotal.hasOwnProperty(y)) {
years.push(y)
}
}
years.sort()
var ny = years.length
var cell
var table = div.appendChild(dom.createElement('table'))
table.setAttribute(
'style',
'font-size: 120%; margin-left:auto; margin-right:1em; margin-top: 1em; border: 0.05em solid gray; padding: 1em;'
)
if (ny > 1) {
var header = table.appendChild(dom.createElement('tr'))
header.appendChild(headerCell(''))
for (i = 0; i < ny; i++) {
header.appendChild(headerCell(years[i]))
}
header.appendChild(headerCell('total'))
}
for (var uri in total) {
// @@ TODO: Write away the need for exception on next line
// eslint-disable-next-line no-prototype-builtins
if (total.hasOwnProperty(uri)) {
types.push(uri)
grandTotal += total[uri]
}
}
types.sort()
var row, label, z
for (var j = 0; j < types.length; j++) {
var cat = kb.sym(types[j])
row = table.appendChild(dom.createElement('tr'))
label = row.appendChild(dom.createElement('td'))
label.textContent = UI.utils.label(cat)
if (ny > 1) {
for (i = 0; i < ny; i++) {
z = yearCategoryTotal[years[i]][types[j]]
cell = row.appendChild(numericCell(z, true))
}
}
row.appendChild(numericCell(total[types[j]], true))
}
// Trailing totals
if (types.length > 1) {
row = table.appendChild(dom.createElement('tr'))
row.appendChild(headerCell('total'))
if (ny > 1) {
for (i = 0; i < ny; i++) {
z = yearTotal[years[i]]
cell = row.appendChild(numericCell(z ? d2(z) : ''))
}
}
cell = row.appendChild(numericCell(grandTotal))
cell.setAttribute(
'style',
'font-weight: bold; text-align: right;'
)
}
var tab = transactionTable(dom, trans)
tab.setAttribute(
'style',
'margin-left:auto; margin-right:1em; margin-top: 1em; border: padding: 1em;'
)
div.appendChild(tab)
UI.widgets.attachmentList(dom, subject, div)
})
.catch(function (e) {
complain('Error loading transactions: ' + e)
})
}
calculations()
} // if
return div
}
}
// ends