diff --git a/abrechenbarkeit.lua b/abrechenbarkeit.lua
index c175526..7de97ac 100755
--- a/abrechenbarkeit.lua
+++ b/abrechenbarkeit.lua
@@ -17,18 +17,36 @@
along with this program. If not, see .
]] --
+-- replace german chars with a better %w that allows unicode
+local matchers = {
+ time = "(%d+)",
+ user = "([%w_@ -öäüÖÄÜßẞ]+)",
+ amount = "(-?%d+)",
+ comment = "([%w_ -öäüÖÄÜßẞ]*)",
+ barcode = "([%w_-]*)",
+ name = "([%w_ -öäüÖÄÜßẞ]*)",
+}
+local matchers_global = (function()
+ local s = {}
+ for k, v in pairs(matchers) do s[k] = ("^%s$"):format(v) end
+ return s
+end)()
+
local function escape(s)
return s:gsub("<", "<"):gsub("<", "<")
end
local function urldecode(s)
if s == nil then return nil end
- return s:gsub("+", " "):gsub("%%20", " ")
+ local t, _ = s:gsub("+", " "):gsub("%%(%x%x)",
+ function(cap) return string.char(tonumber(cap, 16)) end)
+ return t
end
local function urlencode(s)
if s == nil then return nil end
- return s:gsub(" ", "%%20")
+ return s:gsub("[^%w]",
+ function(cap) return string.format("%02x", string.byte(cap, 1)) end)
end
local function parse_query(q)
@@ -72,7 +90,7 @@ local function load_translations(langs)
if l ~= "" then
local key, value = string.match(l, "^([^=]+)=([^=]*)")
if key ~= nil and value ~= nil then
- t[key] = value
+ t["+" .. key] = value
end
end
end
@@ -93,18 +111,23 @@ local script = io.open("script.js"):read("a")
local function format(template, params)
params = params or {}
if template == nil then return "NIL TEMPLATE" end
- return string.gsub(template, "{([%w_\\.!]+)}", function(n)
- local raw = n:sub(1, 1) ~= "!"
- if not raw then n = n:sub(2) end
- local s = format(params[n] or translations[n] or "NIL PARAM", params)
- return raw and s or escape(s)
+ local s, _ = string.gsub(template, "{([%w\\+_\\.!]+)}", function(n)
+ local esc = n:sub(1, 1) == "!"
+ if esc then n = n:sub(2) end
+ local s = params[n] or translations[n] or "NIL PARAM"
+ if not esc then s = format(s, params) end
+ return esc and escape(s) or s
end)
+ return s
end
local function format_amount(amount, tag, classes)
- local s = string.format("%.02f%s", amount / 100, config.unit or "€")
+ local s = string.format("%s%.02f%s", amount > 0 and "+" or "", amount / 100, config.unit or "€")
if tag == nil then return s end
- return string.format([[<%s class="amount-%s %s">%s%s>"]], tag, amount >= 0 and "pos" or "neg", classes or "", s, tag)
+ return format(
+ [[<{tag} class="amount-{sign} {classes}">{content}{tag}>]],
+ { tag = tag, sign = amount >= 0 and "pos" or "neg", classes = classes or "", content = s }
+ )
end
local function get_user_theme(username)
@@ -123,10 +146,23 @@ local function get_user_theme(username)
end
local function format_duration(t)
- if t > 86400 then return string.format("%d day%s", t / 86400, math.floor(t / 86400) ~= 1 and "s" or "") end
- if t > 3600 then return string.format("%d hour%s", t / 3600, math.floor(t / 3600) ~= 1 and "s" or "") end
- if t > 60 then return string.format("%d minute%s", t / 60, math.floor(t / 60) ~= 1 and "s" or "") end
- return string.format("%d seconds", t)
+ local unit = nil
+ local n = nil
+ if t > 86400 then
+ n = math.floor(t / 86400)
+ unit = "day"
+ elseif t > 3600 then
+ n = math.floor(t / 3600)
+ unit = "hour"
+ elseif t > 60 then
+ n = math.floor(t / 60)
+ unit = "minute"
+ else
+ n = t
+ unit = "second"
+ end
+ return format("{+time.delta_past}",
+ { n = n, unit = translations["+time." .. unit .. (n ~= 1 and "s" or "")] })
end
local function respond(status, title, body)
@@ -150,11 +186,11 @@ local function respond(status, title, body)
- {appname}
+ {+appname}
- {log}
- {products}
- {about}
+ {+log}
+ {+products}
+ {+about}
]], {
title = escape(title),
@@ -168,7 +204,7 @@ local function respond(status, title, body)
end
local function error_box(message)
- return string.format([[
Error: %s
]], message)
+ return string.format([[
Error: %s
]], escape(message))
end
local function respond_error(message)
@@ -198,9 +234,9 @@ local function read_log()
if l == "" or l == nil then
return nil
end
- local time, username, amount, pcode, pcount, comment = string.match(l,
- "(%d+),([%w_ -]+),(-?%d+),([%w_-]*),(-?%d*),([%w_ -]*)")
- return tonumber(time), username, tonumber(amount), pcode, tonumber(pcount), comment
+ local time, user_src, user_dst, amount, pcode, pcount, comment = string.match(l,
+ format("{time},{user},{user},{amount},{barcode},{amount},{comment}", matchers))
+ return tonumber(time), user_src, user_dst, tonumber(amount), pcode, tonumber(pcount), comment
end
end
@@ -215,22 +251,23 @@ local function read_products()
if l == "" or l == nil then
return nil
end
- local barcode, price, name, owner = string.match(l, "([%w_-]+),(-?%d+),([%w_ -]*),([%w_ -]*)")
- return barcode, tonumber(price), name, owner
+ local barcode, price, user, name = string.match(l, "{barcode},{amount},{user},{name}")
+ return barcode, tonumber(price), user, name
end
end
local function balances()
local users = {}
- for _, username, amount, _, _, _ in read_log() do
- users[username] = (users[username] or 0) + amount
+ for _, user_src, user_dst, amount, _, _, _ in read_log() do
+ users[user_src] = (users[user_src] or 0) - amount
+ users[user_dst] = (users[user_dst] or 0) + amount
end
return users
end
local function product_balances()
local products = {}
- for _, _, _, pcode, pcount, _ in read_log() do
+ for _, _, _, _, pcode, pcount, _ in read_log() do
if pcode ~= nil and pcount ~= nil then
products[pcode] = (products[pcode] or 0) + pcount
end
@@ -240,19 +277,25 @@ end
local function last_txns()
local users = {}
- for time, username, _, _, _, _ in read_log() do
- users[username] = time
+ for time, user_src, user_dst, _, _, _, _ in read_log() do
+ users[user_src] = time
+ users[user_dst] = time
end
return users
end
local function get_active_users()
local user_balances = {}
- for time, username, amount, _, _, _ in read_log() do
- user_balances[username] = {
+ for time, user_src, user_dst, amount, _, _, _ in read_log() do
+ user_balances[user_src] = {
time = time,
- username = username,
- balance = (user_balances[username] or { balance = 0 }).balance + amount
+ user_src = user_src,
+ balance = (user_balances[user_src] or { balance = 0 }).balance - amount
+ }
+ user_balances[user_dst] = {
+ time = time,
+ user_dst = user_dst,
+ balance = (user_balances[user_dst] or { balance = 0 }).balance + amount
}
end
@@ -267,62 +310,57 @@ end
local function r_user_post(username)
local data = form_data()
+ local user_src = data.user_src or username
+ local user_dst = data.user_dst
local amount = tonumber(data.amount)
+ local pcode = data.pcode
+ local pcount = tonumber(data.pcount)
local comment = data.comment
- local pcode = nil
- local pcount = nil
- local powner = nil
- local powner_comment = nil
- if data.pcode then
- for p_barcode, p_amount, p_name, p_owner in read_products() do
- if p_barcode == data.pcode then
- powner = p_owner
+ if pcode then
+ local exists = false
+ for p_barcode, p_amount, p_user, p_name in read_products() do
+ if p_barcode == pcode then
pcount = (tonumber(data.pcount) or 1) * (data.negate_pcount ~= nil and -1 or 1)
- pcode = p_barcode
- if amount == nil then amount = pcount * p_amount end
- if comment == nil then
- comment = string.format("%s %d %s", pcount < 0 and "Buy" or "Restock",
- math.abs(pcount), p_name)
-
- powner_comment = string.format("%s %d %s %s %s",
- pcount < 0 and "Sell" or "Restock",
- math.abs(pcount), p_name,
- pcount < 0 and "to" or "by",
- username)
- end
+ amount = amount or pcount * p_amount
+ user_dst = user_dst or p_user
+ comment = comment or
+ string.format("%s %d %s", pcount < 0 and "Buy" or "Restock", math.abs(pcount or 0), p_name)
+ exists = true
end
end
- if amount == nil then
+ if not exists then
return error_box("unknown product")
end
end
+ user_dst = user_dst or "@global"
if amount == nil then
return error_box("amount invalid")
end
- if comment == nil or comment:match("^[%w_ -]*$") == nil then
+ if comment == nil or comment:match(matchers_global.comment) == nil then
return error_box("comment invalid")
end
+ if user_src == nil or user_src:match(matchers_global.user) == nil then
+ return error_box("source user invalid")
+ end
+ if user_dst == nil or user_dst:match(matchers_global.user) == nil then
+ return error_box("destination user invalid")
+ end
local log = io.open("log", "a+")
if log == nil then
return error_box("failed to open log")
end
local time = os.time()
- -- subtract from buyer
- log:write(string.format("%d,%s,%d,%s,%s,%s\n", time, username, amount, pcode or "", pcount or "", comment))
- -- add to owner
- if powner then
- -- count is always zero as doesn't affect stock
- log:write(string.format("%d,%s,%d,%s,%s,%s\n", time, powner, -amount, pcode or "", "", powner_comment))
- end
+ log:write(string.format("%d,%s,%s,%d,%s,%s,%s\n",
+ time, user_src, user_dst, amount, pcode or "", pcount or "", comment))
log:flush()
log:close()
return format([[
-
]], {
sign = amount >= 0 and "pos" or "neg",
amount = format_amount(amount, "strong"),
- comment = escape(comment),
+ comment = comment,
sound = config.transaction_sound or ""
})
end
@@ -340,12 +378,12 @@ local function r_user(username)
balance = balance or 0
if notif then print(notif) end
if new_user then
- print([[
]])
end
@@ -353,44 +391,46 @@ local function r_user(username)
print([[
]])
for _, type in ipairs({ 1, -1 }) do
for _, amount in ipairs({ 50, 100, 150, 200, 500, 1000 }) do
- print(string.format([[
-
- ]], amount * type, ({ [-1] = "-", [1] = "+" })[type], amount / 100,
- ({ [-1] = "neg", [1] = "pos" })[type]))
+ local a = amount * type
+ print(format([[]], {
+ a_raw = a,
+ amount = format_amount(a),
+ sign = a < 0 and "neg" or "pos"
+ }))
end
end
print("
")
print(format([[
]]))
print("")
@@ -399,46 +439,52 @@ end
local function r_log(filter)
return respond(200, "Abrechnungen", function()
- print([[
-
Time
-
Username
-
Amount
-
P.-Barcode
-
P.-Count
-
Comment
-
Actions
-
]])
+ print([[
+
{+field.time}
+
{+field.username}
+
{+field.amount}
+
{+field.barcode}
+
{+field.count}
+
{+field.comment}
+
{+log.actions}
+
]]))
print("")
- for time, username, amount, pcode, pcount, comment in read_log() do
- if filter == nil or filter == username then
- print(string.format([[
+ for time, user_src, user_dst, amount, pcode, pcount, comment in read_log() do
+ if filter == nil or filter == user_src or filter == user_dst then
+ print(format([[
-
%s (%s ago)
-
%s
-
%.02f€
-
%s
-
%s %s
-
%s
+
{time} ({time_delta})
+
{user_src} → {user_dst
+ {amount}
+
{pcode}
+
{pcount}
+
{comment}
-
- ]],
- os.date("!%Y-%m-%dT%H:%M:%SZ", time), format_duration(os.time() - time),
- escape(username),
- amount >= 0 and "pos" or "neg", amount / 100,
- escape(pcode) or "",
- pcount and (pcount < 0 and "buy" or "stock") or "", pcount and tostring(math.abs(pcount)) or "",
- escape(comment),
- escape(username),
- -amount,
- escape(comment)
- ))
+ ]], {
+ time = os.date("!%Y-%m-%dT%H:%M:%SZ", time),
+ time_delta = format_duration(os.time() - time),
+ user_src = escape(user_src),
+ user_dst = escape(user_dst),
+ amount = format_amount(amount, "td"),
+ pcode = escape(pcode),
+ pcount = (pcount and (pcount < 0 and "buy " or "stock ") or "") ..
+ (pcount and tostring(math.abs(pcount)) or ""),
+ comment = escape(comment),
+ revert_amount = -amount,
+ revert_pcount = -pcount,
+ }))
end
end
print("")
@@ -448,40 +494,35 @@ end
local function r_index()
return respond(200, "Abrechenbarkeit", function()
- print([[
+ print(format([[
- ]])
- print([[]]) -- for printing
+ ]]))
+ print([[]]) -- for printing
print([[
]])
for _, user in ipairs(get_active_users()) do
- print(string.format([[
-
")
end)
end
-local function validate_username(username)
- -- disallow leading or traling whitespace
- return username ~= nil
- and username:match("^([%w_ -]+)$") ~= nil
- and username:match("^%s") == nil
- and username:match("%s$") == nil
-end
-
local function r_create_user()
local username = query.create_user
- if not validate_username(username) then
+ if username:match(matchers_global.user) == nil then
return respond_error("invalid username " .. username)
end
return redirect(string.format("/%s", urlencode(username)))
@@ -490,10 +531,7 @@ end
local function r_products_post()
local data = form_data()
local barcode = data.barcode
- if barcode == nil then
- return error_box("barcode unset")
- end
- if barcode:match("^[%w_-]*$") == nil then
+ if barcode == nil or barcode:match("^[%w_-]*$") == nil then
return error_box("barcode invalid")
end
if data.delete then
@@ -501,9 +539,9 @@ local function r_products_post()
if new_products == nil then
return error_box("failed to open new products")
end
- for a_barcode, price, name, owner in read_products() do
+ for a_barcode, price, user, name in read_products() do
if barcode ~= a_barcode then
- new_products:write(string.format("%s,%d,%s,%s\n", a_barcode, price, name, owner))
+ new_products:write(string.format("%s,%d,%s,%s\n", a_barcode, price, user, name))
end
end
new_products:flush()
@@ -512,21 +550,21 @@ local function r_products_post()
else
local price = tonumber(data.price)
local name = data.name
+ local user = data.user
if price == nil then
return error_box("price invalid")
end
- if name:match("^[%w_ -]*$") == nil then
+ if name == nil or name:match(matchers_global.name) == nil then
return error_box("name invalid")
end
+ if user == nil or user:match(matchers_global.user) == nil then
+ return error_box("user invalid")
+ end
local products = io.open("products", "a+")
if products == nil then
return error_box("failed to open products")
end
- local owner = data.owner or ""
- if name:match("^[%w_ -]*$") == nil then
- return error_box("owner invalid")
- end
- products:write(string.format("%s,%d,%s,%s\n", barcode, price, name, owner))
+ products:write(string.format("%s,%d,%s,%s\n", barcode, price, user, name))
products:flush()
products:close()
end
@@ -548,8 +586,8 @@ local function r_products()
-
-
+
+
@@ -568,19 +606,23 @@ local function r_products()
Price
Barcode
Count
-
Owner
+
User
]])
local pbals = product_balances()
- for barcode, price, name, owner in read_products() do
- print(string.format([[
-
%s
%.02f€
%s
%s
%s
- ]],
- name,
- -price >= 0 and "pos" or "neg", -price / 100,
- barcode,
- pbals[barcode] or "0",
- owner
- ))
+ for barcode, price, user, name in read_products() do
+ print(string.format([[
+
{!name}
+ {price}
+
{!barcode}
+
{!count}
+
{!user}
+
]], {
+ name = name,
+ price = format_amount(-price),
+ barcode = barcode,
+ count = pbals[barcode] or "0",
+ user = user,
+ }))
end
print("
")
end)
@@ -588,13 +630,17 @@ end
local function r_about()
respond(200, "About Abrechenbarkeit", function()
- print([[
-
About Abrechenbarkeit
-
Abrechenbarkeit is a simple trust-based ledger for keeping track of money spent on product.
-
Abrechenbarkeit is free software. It is licensed exclusively GNU Affero General Public License Version 3 only.
+ ]], {
+ issues = [[]],
+ codeberg = [[]],
+ ae = [[]],
+ }))
end)
end
@@ -603,7 +649,7 @@ local function extract_username()
return respond_error("no path")
end
local username = urldecode(path:sub(2))
- if username == nil or username:match("^([%w_ -]+)$") == nil then
+ if username == nil or username:match(matchers_global.user) == nil then
return nil
end
return username
@@ -623,7 +669,7 @@ if path == "/" then
end
else
local username = extract_username()
- if username == nil or not validate_username(username) then
+ if username == nil then
return respond_error("username invalid")
elseif query.log then
return r_log(username)
diff --git a/locale/de.ini b/locale/de.ini
index babbbd9..584baf7 100644
--- a/locale/de.ini
+++ b/locale/de.ini
@@ -20,19 +20,19 @@ products.form.title=Produkt Liste
products=Produkte
time.day=Tag
time.days=Tage
-time.delta_past=vor %d %s
+time.delta_past=vor {n} {unit}
time.hour=Stunde
time.hours=Stunden
time.minute=Minute
time.minutes=Minuten
time.second=Sekunde
time.seconds=Sekunden
-user.balance=Kontostand:
+user.balance=Kontostand
user.form.buy.submit=Kaufen
user.form.buy=Produkt Kaufen
user.form.restock.submit=Wiederauffüllen
user.form.restock=Produkt wiederauffüllen
user.form.transaction.submit=Aktualisieren
user.form.transaction=Transaktion erstellen
-user.last_txn=Letzte Transaktion %s.
-user.view_log=User Abrechnung
+user.last_txn=Letzte Transaktion {time}.
+user.view_log=Benutzerprotokoll einsehen
diff --git a/locale/en.ini b/locale/en.ini
index a178e9b..df4d70b 100644
--- a/locale/en.ini
+++ b/locale/en.ini
@@ -7,9 +7,15 @@ field.comment=Comment
field.count=Count
field.name=Name
field.price=Price
+field.time=Time
field.upstream_price=Upstream Price
field.username=Username
index.form.create_user.submit=Continue
+about.desc=Abrechenbarkeit is a simple trust-based ledger for keeping track of money spent on product.
+about.title=About Abrechenbarkeit
+about.license=Abrechenbarkeit is free software. It is licensed exclusively GNU Affero General Public License Version 3 only.
+about.source=The source code is published on {codeberg}Codeberg{ae}. This is also where {issues}issues with this software{ae} should be reported.
+about.thanks=Thanks for choosing Abrechenbarkeit.
index.form.create_user=User Creation
log.actions.revert=Revert
log.actions=Actions
@@ -20,7 +26,7 @@ products.form.title=Product List
products=Products
time.day=day
time.days=days
-time.delta_past=%d %s ago
+time.delta_past={n} {unit} ago
time.hour=hour
time.hours=hours
time.minute=minute
@@ -37,3 +43,4 @@ user.form.transaction=Create Transaction
user.last_txn=Last transaction added {time}.
user.view_log=View user log
user.lazy_creation=This user account does not exist yet. It will only be created after the first transaction.
+user.form.transaction.success=Transaction successful
diff --git a/readme.md b/readme.md
index 4785d95..19ee9a0 100644
--- a/readme.md
+++ b/readme.md
@@ -12,7 +12,7 @@ useful for development or proxyless deployments.
## Data Files
-- `log` stores the transaction log as CSV (`time,user,amount,comment`)
-- `products` stores the product list as CSV (`barcode,price,name`)
+- `log` stores the transaction log as CSV (`time,user_a,user_b,amount,pcode,pcount,comment`)
+- `products` stores the product list as CSV (`barcode,price,user,name`)
- `config` stores configuration parameters as ESV (`key=value`)
- `transaction_sound`: URL to sound played when creating a transaction