feat: first version of go rewrite complete

This commit is contained in:
Erik Örtenberg 2026-10-11 00:53:13 +02:00
parent b138ea7fdd
commit 555bf29551
16 changed files with 1007 additions and 272 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
test-data/

174
archived-python/faktura.py Executable file
View File

@ -0,0 +1,174 @@
#!/usr/bin/python
import csv
from functools import reduce
import re
import sys
if len(sys.argv) < 2:
path2file = "konto.csv"
else:
path2file = sys.argv[1]
def float2(value, default=0.0):
if value is None:
return default
try:
return float(value)
except ValueError:
return default
print("Detta program rapporterar skillnader mellan verifikat som refererar till varandra genom beskrivningen av verifikatet. Den utgår från krediter och hittar matchande debeter")
lines = []
result = []
with open(path2file, "r") as file:
lines = file.readlines()
startLine = 12
# READING
for i, line in enumerate(lines):
if "Konto" in line and "Verifikation" in line:
startLine = i
break
with open(path2file, "w") as file:
file.writelines(lines[startLine:-1])
with open(path2file, "r") as file:
csvreader = csv.DictReader(file, delimiter=";")
for row in csvreader:
result.append(row)
with open(path2file, "w") as file:
file.writelines(lines)
# FILE RESTORED, PARSE DATA
krediter = {}
debeter = {}
verfs = {}
sums = {}
header = result.pop(0)
for line in result:
if not line['"Verifikation"']:
continue
verf = line['"Verifikation"'].replace(" ","")
if verf not in verfs.keys():
verfs[verf] = line
verfs[verf]["Corrections"] = []
verfs[verf]["Value"] = float2(line['"Debet"'].replace(",",".").replace(" ",""))
verfs[verf]["Value"] -= float2(line['"Kredit"'].replace(",",".").replace(" ",""))
else:
verfs[verf]["Value"] -= float2(line['"Kredit"'].replace(",",".").replace(" ",""))
verfs[verf]["Value"] += float2(line['"Debet"'].replace(",",".").replace(" ",""))
## ELIMINATE CORRECTIONS
verfs = dict(reversed(sorted(verfs.items(), key = lambda x: int(x[0][1:]))))
newVerfs = dict(verfs)
corrections = []
for verf, line in verfs.items():
if "Korrigering" in line['"Beskrivning"']:
corrections.append(verf)
toDelete = []
missingVerfs = []
for verf, line in verfs.items():
if verf in reduce(lambda x,y: x + y, list(map(lambda x: [x[0]] + x[1], toDelete)),[]):
continue
if "Korrigering" in line['"Beskrivning"']:
print(line['"Beskrivning"'])
toRemove = re.findall("(A\\d+)(?:\\D){0,1}",line['"Beskrivning"'])[0]
if toRemove not in verfs.keys():
missingVerfs.append(toRemove)
else:
newVerfs[toRemove]["Corrections"].append(verf)
newVerfs[toRemove]["Corrections"].extend(newVerfs[verf]["Corrections"])
newVerfs[toRemove]["Value"] += line["Value"]
### check if toRemove verf should be removed
if abs(newVerfs[toRemove]["Value"]) < 0.0001:
toDelete.append((toRemove, newVerfs[toRemove]["Corrections"]))
elif len(line["Corrections"]) > 0:
toDelete.append((verf, [None] + line["Corrections"]))
verfs = newVerfs
if len(missingVerfs) > 0:
print("")
print(f"VARNING! {missingVerfs} saknas i verifikat listan men refereras till av korrektioner!")
print("")
if len(toDelete) > 0:
print(f"Tar bort {len(toDelete)} korrigeringspar...")
for v, ks in toDelete:
if ks[0] == None:
print(f" Verifikaten {ks[1:]} delkorrigerar {v}")
ks = ks[1:]
else:
print(f" Verifikaten {ks} korrigerar bort hela {v}")
del verfs[v]
for k in ks:
del verfs[k]
## FINISH PARSING DATA
for verf, line in verfs.items():
if line["Value"] < 0:
krediter[verf] = line
elif line["Value"] > 0:
debeter[verf] = line
# FIND DIFFS AND LOG INTO DATA STRUCTURE
print("Beräknar skillnader mellan utbetalningar och utlägg")
for kVerf, kLine in krediter.items():
sums[kVerf] = {"value": kLine["Value"], "debets": [], "date": kLine['"Datum"']}
toDel = []
for dVerf, dLine in debeter.items():
debetVal = dLine["Value"]
[company, invoiceNr] = re.findall("( \\w?\\d+)(?:\\D){0,1}",dLine['"Beskrivning"'])
kVerf = None
for v, kLine in krediter.items():
if invoiceNr in kLine['"Beskrivning"'] and company in kLine['"Beskrivning"']:
kVerf = v
if kVerf == None:
continue
if debetVal == 0:
continue
if kVerf not in sums:
if kVerf not in corrections:
print(f" I debeterande verifikat {dVerf} berörs {kVerf}, men {kVerf} hittas inte. (saknas kostnadsställe?)")
continue
sumVal = sums[kVerf]["value"]
if abs(sumVal + debetVal) < 0.0001:
sums[kVerf]["value"] = 0
debetVal = 0
toDel.append(dVerf)
elif sumVal + debetVal < 0:
sums[kVerf]["value"] += debetVal
debetVal = 0
toDel.append(dVerf)
else:
sums[kVerf]["value"] = 0
debetVal = debetVal + sumVal
sums[kVerf]["debets"] = sums[kVerf]["debets"] + [dVerf]
if abs(debetVal) > 0.0001:
debeter[dVerf]["Value"] = debetVal
for dVerf in list(set(toDel)):
del debeter[dVerf]
# PRINT!
print("---------------------")
print("Nedan är alla utlägg/kredit verifikat och dess tillhörande betalningar.")
for verf, a in sums.items():
if abs(a["value"]) > 0.0001:
print(" VARNING! Ofullständig matchning i ",verf, ": ", a)
else:
print(f"{verf}: {a}")
print("")
print("Här kommer omatchade debeter")
for debet, debetLine in debeter.items():
print(debet + ": " + debetLine['"Beskrivning"'] + " -- ", debetLine["Value"])

View File

@ -1,272 +0,0 @@
A214
Utlägg för överlämning - Filip Wallin (DAG)
-1 155,00
-1 155,00
A215
Utlägg för möteskostnader - Tobias Olsson (DAG)
-202,30
-1 357,30
A216
Utlägg för representationskläder - Viktor Käll (DAG)
-7 103,20
-8 460,50
A218
Utlägg för representationskläder, 1800kr ligger inom godkänd budget för representationskläder av Styret - Salam Hani (DAG)
-1 800,00
-10 260,50
A219
Utlägg för representationskläder, 1800kr ligger inom godkänd budget för representationskläder av Styret - Gustav Bruhn (DAG)
-1 800,00
-12 060,50
A217
Utlägg för representationskläder - Viktor Käll (DAG)
-1 749,30
-13 809,80
A300
utlägg A216
7 103,20
-6 706,60
A302
utlägg A215
202,30
-6 504,30
A304
utlägg A219
1 800,00
-4 704,30
A305
utlägg A218
1 800,00
-2 904,30
A306
utlägg A214
1 155,00
-1 749,30
A307
utlägg A217
1 749,30
0,00
A3777
Utlägg för aspning - Viktor Käll (DAG)
-1 183,30
-1 183,30
A1223
Utlägg för möteskostnader - Gustav Bruhn (DAG)
-271,14
-1 454,44
A1222
Utlägg för möteskostnader - Viktor Käll (DAG)
-265,18
-1 719,62
A1265
Betalning av utlägg A1222 (DAG)
265,18
-1 454,44
A1266
Betalning av utlägg A1223
271,14
-1 183,30
A1440
Utlägg för möteskostnader - Tobias Olsson (DAG)
-263,02
-1 446,32
A1844
Utlägg för teambuilding - Alfred Karlsson (DAG)
-564,00
-2 010,32
A1846
Utlägg för teambuilding - Viktor Käll (DAG)
-943,68
-2 954,00
A1848
Utlägg för matinköp afterschool - Alfred Karlsson (DAG)
-772,89
-3 726,89
A1847
Utlägg för matinköp afterschool - Filip Wallin (DAG)
-375,40
-4 102,29
A1845
Utlägg för möteskostnader - Tim Karlsson (DAG)
-236,33
-4 338,62
A1850
Betalning av utlägg A1846 (DAG)
943,68
-3 394,94
A1851
Betalning av utlägg A1847 (DAG)
375,40
-3 019,54
A1905
Betalning av utlägg A1848 (DAG)
772,89
-2 246,65
A1906
Betalning av utlägg A1844 (DAG)
564,00
-1 682,65
A1907
Betalning av utlägg A1440 (DAG)
263,02
-1 419,63
A1908
Betalning av utlägg A1845 (DAG)
236,33
-1 183,30
A1992
Utlägg för möteskostnader - Viktor Käll (DAG)
-261,92
-1 445,22
A1993
Utlägg för afterschool - Salam Hani (DAG)
-83,46
-1 528,68
A1994
A1993
83,46
-1 445,22
A1995
A1992
261,92
-1 183,30
A2525
Utlägg för möteskostnader - Viktor Käll (DAG)
-110,80
-1 294,10
A2479
Utlägg för alkoholinköp pub - Salam Hani (DAG)
-2 789,20
-4 083,30
A2526
Utlägg för möteskostnader - Tim Karlsson (DAG)
-213,85
-4 297,15
A2813
Utlägg för teambuilding - Viktor Käll (DAG)
-1 750,00
-6 047,15
A2527
Utlägg för möteskostnader - Alfred Karlsson (DAG)
-257,09
-6 304,24
A2833
Betalning av utlägg A2479 (Kommitté)
2 789,20
-3 515,04
A3774
Utlägg för möteskostnader - Tobias Olsson (DAG)
-196,44
-3 711,48
A3305
Utlägg för möteskostnader - Salam Hani (DAG)
-280,00
-3 991,48
A2830
Betalning av utlägg A2527 (DAG)
257,09
-3 734,39
A2831
Betalning av utlägg A2526 (Kommitté)
213,85
-3 520,54
A2832
Betalning av utlägg A2525 (Kommitté)
110,80
-3 409,74
A2829
Betalning av utlägg A2813 (DAG)
1 750,00
-1 659,74
A3306
Utlägg för möteskostnader - Viktor Käll (DAG)
-280,00
-1 939,74
A3307
Utlägg för aspning - Alfred Karlsson (DAG)
-1 063,78
-3 003,52
A3308
Utlägg för aspning - Filip Wallin (DAG)
-939,50
-3 943,02
A3309
Utlägg för möteskostnader - Tobias Olsson (DAG)
-280,00
-4 223,02
A3310
Utlägg för möteskostnader - Viktor Käll (DAG)
-239,14
-4 462,16
A3749
Utlägg för [matinköp t.ex. afterschool] - Filip Wallin (DAG)
-3 817,17
-8 279,33
A3746
Utlägg för [arrangemangskostnader] - Filip Wallin (DAG)
-4 370,00
-12 649,33
A3313
Betalning av utlägg A3308 (DAG)
939,50
-11 709,83
A3314
Betalning av utlägg A3306 (DAG)
280,00
-11 429,83
A3315
Betalning av utlägg A3310 (DAG)
239,19
-11 190,64
A3316
Betalning av utlägg A3305 (DAG)
280,00
-10 910,64
A3317
Betalning av utlägg A3309 (DAG)
280,00
-10 630,64
A3318
Betalning av utlägg A3307 (DAG)
1 063,78
-9 566,86
A3751
Utlägg för möteskostnader - Viktor Käll (DAG)
-202,95
-9 769,81
A3842
Utlägg för [arrangemangskostnader] - Alfred Karlsson (DAG)
-3 693,40
-13 463,21
A3745
Utlägg för möteskostnader - Salam Hani (DAG)
-222,25
-13 685,46
A3796
Betalning av utlägg A3751 (DAG)
202,95
-13 482,51
A3797
Betalning av utlägg A3777 (DAG)
1 183,30
-12 299,21
A3798
Betalning av utlägg A3749 (DAG)
3 817,17
-8 482,04
A3799
Betalning av utlägg A3745 (DAG)
222,25
-8 259,79
A3800
Betalning av utlägg A3746 (DAG)
4 370,00
-3 889,79
A3801
Betalning av utlägg A3774 (DAG)
196,44
-3 693,35
A3843
Betalning av utlägg A3842 (DAG)
3 693,40
0,05

BIN
go-rewrite/bookkeep Executable file

Binary file not shown.

212
go-rewrite/bookkeep.zsh Normal file
View File

@ -0,0 +1,212 @@
#compdef bookkeep
compdef _bookkeep bookkeep
# zsh completion for bookkeep -*- shell-script -*-
__bookkeep_debug()
{
local file="$BASH_COMP_DEBUG_FILE"
if [[ -n ${file} ]]; then
echo "$*" >> "${file}"
fi
}
_bookkeep()
{
local shellCompDirectiveError=1
local shellCompDirectiveNoSpace=2
local shellCompDirectiveNoFileComp=4
local shellCompDirectiveFilterFileExt=8
local shellCompDirectiveFilterDirs=16
local shellCompDirectiveKeepOrder=32
local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace keepOrder
local -a completions
__bookkeep_debug "\n========= starting completion logic =========="
__bookkeep_debug "CURRENT: ${CURRENT}, words[*]: ${words[*]}"
# The user could have moved the cursor backwards on the command-line.
# We need to trigger completion from the $CURRENT location, so we need
# to truncate the command-line ($words) up to the $CURRENT location.
# (We cannot use $CURSOR as its value does not work when a command is an alias.)
words=("${=words[1,CURRENT]}")
__bookkeep_debug "Truncated words[*]: ${words[*]},"
lastParam=${words[-1]}
lastChar=${lastParam[-1]}
__bookkeep_debug "lastParam: ${lastParam}, lastChar: ${lastChar}"
# For zsh, when completing a flag with an = (e.g., bookkeep -n=<TAB>)
# completions must be prefixed with the flag
setopt local_options BASH_REMATCH
if [[ "${lastParam}" =~ '-.*=' ]]; then
# We are dealing with a flag with an =
flagPrefix="-P ${BASH_REMATCH}"
fi
# Prepare the command to obtain completions
requestComp="${words[1]} __complete ${words[2,-1]}"
if [ "${lastChar}" = "" ]; then
# If the last parameter is complete (there is a space following it)
# We add an extra empty parameter so we can indicate this to the go completion code.
__bookkeep_debug "Adding extra empty parameter"
requestComp="${requestComp} \"\""
fi
__bookkeep_debug "About to call: eval ${requestComp}"
# Use eval to handle any environment variables and such
out=$(eval ${requestComp} 2>/dev/null)
__bookkeep_debug "completion output: ${out}"
# Extract the directive integer following a : from the last line
local lastLine
while IFS='\n' read -r line; do
lastLine=${line}
done < <(printf "%s\n" "${out[@]}")
__bookkeep_debug "last line: ${lastLine}"
if [ "${lastLine[1]}" = : ]; then
directive=${lastLine[2,-1]}
# Remove the directive including the : and the newline
local suffix
(( suffix=${#lastLine}+2))
out=${out[1,-$suffix]}
else
# There is no directive specified. Leave $out as is.
__bookkeep_debug "No directive found. Setting do default"
directive=0
fi
__bookkeep_debug "directive: ${directive}"
__bookkeep_debug "completions: ${out}"
__bookkeep_debug "flagPrefix: ${flagPrefix}"
if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then
__bookkeep_debug "Completion received error. Ignoring completions."
return
fi
local activeHelpMarker="_activeHelp_ "
local endIndex=${#activeHelpMarker}
local startIndex=$((${#activeHelpMarker}+1))
local hasActiveHelp=0
while IFS='\n' read -r comp; do
# Check if this is an activeHelp statement (i.e., prefixed with $activeHelpMarker)
if [ "${comp[1,$endIndex]}" = "$activeHelpMarker" ];then
__bookkeep_debug "ActiveHelp found: $comp"
comp="${comp[$startIndex,-1]}"
if [ -n "$comp" ]; then
compadd -x "${comp}"
__bookkeep_debug "ActiveHelp will need delimiter"
hasActiveHelp=1
fi
continue
fi
if [ -n "$comp" ]; then
# If requested, completions are returned with a description.
# The description is preceded by a TAB character.
# For zsh's _describe, we need to use a : instead of a TAB.
# We first need to escape any : as part of the completion itself.
comp=${comp//:/\\:}
local tab="$(printf '\t')"
comp=${comp//$tab/:}
__bookkeep_debug "Adding completion: ${comp}"
completions+=${comp}
lastComp=$comp
fi
done < <(printf "%s\n" "${out[@]}")
# Add a delimiter after the activeHelp statements, but only if:
# - there are completions following the activeHelp statements, or
# - file completion will be performed (so there will be choices after the activeHelp)
if [ $hasActiveHelp -eq 1 ]; then
if [ ${#completions} -ne 0 ] || [ $((directive & shellCompDirectiveNoFileComp)) -eq 0 ]; then
__bookkeep_debug "Adding activeHelp delimiter"
compadd -x "--"
hasActiveHelp=0
fi
fi
if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then
__bookkeep_debug "Activating nospace."
noSpace="-S ''"
fi
if [ $((directive & shellCompDirectiveKeepOrder)) -ne 0 ]; then
__bookkeep_debug "Activating keep order."
keepOrder="-V"
fi
if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then
# File extension filtering
local filteringCmd
filteringCmd='_files'
for filter in ${completions[@]}; do
if [ ${filter[1]} != '*' ]; then
# zsh requires a glob pattern to do file filtering
filter="\*.$filter"
fi
filteringCmd+=" -g $filter"
done
filteringCmd+=" ${flagPrefix}"
__bookkeep_debug "File filtering command: $filteringCmd"
_arguments '*:filename:'"$filteringCmd"
elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then
# File completion for directories only
local subdir
subdir="${completions[1]}"
if [ -n "$subdir" ]; then
__bookkeep_debug "Listing directories in $subdir"
pushd "${subdir}" >/dev/null 2>&1
else
__bookkeep_debug "Listing directories in ."
fi
local result
_arguments '*:dirname:_files -/'" ${flagPrefix}"
result=$?
if [ -n "$subdir" ]; then
popd >/dev/null 2>&1
fi
return $result
else
__bookkeep_debug "Calling _describe"
if eval _describe $keepOrder "completions" completions $flagPrefix $noSpace; then
__bookkeep_debug "_describe found some completions"
# Return the success of having called _describe
return 0
else
__bookkeep_debug "_describe did not find completions."
__bookkeep_debug "Checking if we should do file completion."
if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then
__bookkeep_debug "deactivating file completion"
# We must return an error code here to let zsh know that there were no
# completions found by _describe; this is what will trigger other
# matching algorithms to attempt to find completions.
# For example zsh can match letters in the middle of words.
return 1
else
# Perform file completion
__bookkeep_debug "Activating file completion"
# We must return the result of this command, so it must be the
# last command, or else we must store its result to return it.
_arguments '*:filename:_files'" ${flagPrefix}"
fi
fi
fi
}
# don't run the completion function when being source-ed or eval-ed
if [ "$funcstack[1]" = "_bookkeep" ]; then
_bookkeep
fi

99
go-rewrite/cmd/outlays.go Normal file
View File

@ -0,0 +1,99 @@
package cmd
import (
"fmt"
"log/slog"
"math"
"slices"
"github.com/fatih/color"
"github.com/rodaine/table"
"github.com/spf13/cobra"
"kryddan.xyz/bookkeep/process"
"kryddan.xyz/bookkeep/vismaparse"
)
func printFaults(faults []process.FaultyOutlay) {
headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc()
columnFmt := color.New(color.FgYellow).SprintfFunc()
tbl := table.New("Verification", "Description", "Related Verf.", "Error").
WithPadding(2).
WithHeaderFormatter(headerFmt).
WithFirstColumnFormatter(columnFmt)
totalChange := 0
for _, fault := range faults {
localChange := fault.Row.Debet - fault.Row.Kredit
totalChange += localChange
localDecimal := int(math.Max(float64(localChange%100), float64(-localChange%100)))
difference := fmt.Sprintf("%6d.%02d", localChange/100, localDecimal)
description := fault.Row.Description
if len(description) > 80 {
description = description[:76] + "..."
}
if len(fault.ContributingVerifications) == 0 {
tbl.AddRow(fault.Row.Verification, description, "", difference)
} else {
tbl.AddRow(fault.Row.Verification, description, fault.ContributingVerifications, difference)
}
}
totalDecimal := int(math.Max(float64(totalChange%100), float64(-totalChange%100)))
totalChangeString := fmt.Sprintf("%6d.%02d", totalChange/100, totalDecimal)
tbl.AddRow("Total", "", "", totalChangeString)
tbl.Print()
}
func OutlayCmd() *cobra.Command {
unitsFlagName := "unit"
accountNumberFlagName := "account"
cmd := &cobra.Command{
Use: "outlays [file]",
Short: "This command analyses outlays in the provided file",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
filePath := args[0]
units, err := cmd.Flags().GetStringArray(unitsFlagName)
if err != nil {
slog.Error("get --units flag", slog.Any("err", err))
}
accountNumber, err := cmd.Flags().GetInt(accountNumberFlagName)
if err != nil {
slog.Error("get --units flag", slog.Any("err", err))
}
org, err := vismaparse.LoadSheet(filePath)
if err != nil {
slog.Error("load sheet failed", slog.Any("err", err))
return err
}
for _, account := range org.Accounts {
if account.AccountNumber == accountNumber {
change := 0
for unit, perUnitAccount := range process.PerOrgUnit(account) {
if len(units) != 0 && !slices.Contains(units, unit) {
continue
}
faults, ok := process.ValidateOutlays(perUnitAccount)
if ok {
continue
}
fmt.Printf("\n----- Faulty verifications in %s -----\n", unit)
for _, fault := range faults {
change += fault.Row.Debet
change -= fault.Row.Kredit
}
printFaults(faults)
}
}
}
return nil
},
}
cmd.Flags().StringArray(unitsFlagName, []string{""}, "The organizational units to analyse. Omit to include all")
cmd.Flags().Int(accountNumberFlagName, 2820, "The account number which tracks outlays")
return cmd
}

42
go-rewrite/cmd/root.go Normal file
View File

@ -0,0 +1,42 @@
/*
Copyright © 2026 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"os"
"github.com/spf13/cobra"
)
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "bookkeep",
Short: "A book keeping helper to validate certain accounts",
Long: `bookkeep is a CLI library that validates book keeping accounts.
to quickly create a Cobra application.`,
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
rootCmd.AddCommand()
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
// Here you will define your flags and configuration settings.
// Cobra supports persistent flags, which, if defined here,
// will be global for your application.
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.vismascripts.yaml)")
// Cobra also supports local flags, which will only run
// when this action is called directly.
rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
outlays := OutlayCmd()
rootCmd.AddCommand(outlays)
}

16
go-rewrite/go.mod Normal file
View File

@ -0,0 +1,16 @@
module kryddan.xyz/bookkeep
go 1.27.1
require github.com/spf13/cobra v1.10.2
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/rodaine/table v1.4.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/sys v0.42.0 // indirect
)

24
go-rewrite/go.sum Normal file
View File

@ -0,0 +1,24 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/rodaine/table v1.4.0 h1:sbGLYXgrc15Db32nuFRamNXP3QdfTxTxXIvdM3fsjEA=
github.com/rodaine/table v1.4.0/go.mod h1:2XS7TM7slleLUM1km5Zsr9Ks+F2p7Y8F5ycDQq2Z7hk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

11
go-rewrite/install-zsh.sh Executable file
View File

@ -0,0 +1,11 @@
#!/bin/bash
./bookkeep completion zsh > _bookkeep
sudo mkdir -p /usr/local/share/zsh/site-functions
sudo cp _bookkeep /usr/local/share/zsh/site-functions
mkdir -p ~/.zsh/completions
cp _bookkeep ~/.zsh/completions
echo 'fpath=(~/.zsh/completions $fpath)' >> ~/.zshrc
echo 'autoload -U compinit && compinit' >> ~/.zshrc
rm _bookkeep

10
go-rewrite/main.go Normal file
View File

@ -0,0 +1,10 @@
/*
Copyright © 2026 NAME HERE <EMAIL ADDRESS>
*/
package main
import "kryddan.xyz/bookkeep/cmd"
func main() {
cmd.Execute()
}

View File

@ -0,0 +1,116 @@
package process
import (
"cmp"
"slices"
"strings"
"kryddan.xyz/bookkeep/types"
)
func PerOrgUnit(account types.BookkeepingAccount) map[string]types.BookkeepingAccount {
out := map[string]types.BookkeepingAccount{}
for _, row := range account.Rows {
unit := strings.ToLower(*cmp.Or(row.OrganizationUnit, new("")))
if _, ok := out[unit]; !ok {
out[unit] = types.BookkeepingAccount{
Name: account.Name,
AccountNumber: account.AccountNumber,
Rows: []types.BookkeepingAccountRow{},
}
}
v := out[unit]
v.Rows = append(v.Rows, row)
out[unit] = v
}
return out
}
type FaultyOutlay struct {
Row types.BookkeepingAccountRow
ContributingVerifications []types.Verification
}
func ValidateOutlays(account types.BookkeepingAccount) ([]FaultyOutlay, bool) {
rows := sortRows(account.Rows)
slices.Reverse(rows)
talliedVerifications := map[string]types.BookkeepingAccountRow{}
originalVerifications := map[string]types.BookkeepingAccountRow{}
for _, row := range rows {
if r, ok := talliedVerifications[row.Verification.String()]; ok {
r.Debet += row.Debet
r.Kredit += row.Kredit
talliedVerifications[row.Verification.String()] = r
originalVerifications[row.Verification.String()] = r
continue
}
talliedVerifications[row.Verification.String()] = row
originalVerifications[row.Verification.String()] = row
}
backlinks := map[string][]string{}
for rowVerf, row := range originalVerifications {
if len(row.LinkedVerifications) == 0 {
continue
}
if row.Kredit == 0 && row.Debet == 0 {
continue
}
alteredRow := talliedVerifications[rowVerf]
if alteredRow.Kredit == 0 && alteredRow.Debet == 0 {
continue
}
alterValue := func(old int, adjustment int) (int, int) {
v := old + adjustment
if v > 0 {
return v, 0
} else {
return 0, -v
}
}
for _, linked := range row.LinkedVerifications {
linkedRow := talliedVerifications[linked.String()]
if linkedRow.Debet != 0 && row.Kredit != 0 {
linkedRow.Debet, row.Kredit = alterValue(linkedRow.Debet, -row.Kredit)
} else if linkedRow.Kredit != 0 && row.Debet != 0 {
linkedRow.Kredit, row.Debet = alterValue(linkedRow.Kredit, -row.Debet)
}
backlinks[linked.String()] = append(backlinks[linked.String()], row.Verification.String())
talliedVerifications[linked.String()] = linkedRow
}
talliedVerifications[row.Verification.String()] = row
}
faults := []FaultyOutlay{}
for verf, row := range talliedVerifications {
if row.Debet != 0 || row.Kredit != 0 {
contributingVerfs := []types.Verification{}
bls := backlinks[verf]
for _, v := range bls {
contributingVerfs = append(contributingVerfs, talliedVerifications[v].Verification)
}
faults = append(faults, FaultyOutlay{
Row: row,
ContributingVerifications: contributingVerfs,
})
}
}
return faults, len(faults) == 0
}
func sortRows(rows []types.BookkeepingAccountRow) []types.BookkeepingAccountRow {
slices.SortFunc(rows, func(a, b types.BookkeepingAccountRow) int {
if a.Verification.Series != b.Verification.Series {
return 0
}
return a.Verification.Number - b.Verification.Number
})
return rows
}

41
go-rewrite/types/types.go Normal file
View File

@ -0,0 +1,41 @@
package types
import (
"fmt"
"time"
)
type Verification struct {
Series string
Number int
}
type Invoice struct {
Number int
}
type BookkeepingAccountRow struct {
Verification Verification
Date time.Time
Description string
Project string
OrganizationUnit *string
LinkedVerifications []Verification
LinkedInvoices []Invoice
Debet int
Kredit int
}
type BookkeepingAccount struct {
Name string
AccountNumber int
Rows []BookkeepingAccountRow
}
type Organization struct {
Accounts []BookkeepingAccount
}
func (v Verification) String() string {
return fmt.Sprintf("%s%d", v.Series, v.Number)
}

View File

@ -0,0 +1,261 @@
package vismaparse
import (
"encoding/csv"
"errors"
"fmt"
"log/slog"
"os"
"regexp"
"strconv"
"strings"
"time"
"kryddan.xyz/bookkeep/types"
)
var (
ErrInvalidRow error = errors.New("invalid input row")
ErrInvalidTable error = errors.New("invalid input table")
)
type accountRange struct {
High int
Low int
}
func LoadSheet(path string) (types.Organization, error) {
f, err := os.Open(path)
if err != nil {
return types.Organization{}, err
}
defer func() {
// Close the spreadsheet.
if err := f.Close(); err != nil {
fmt.Println(err)
}
}()
csvReader := csv.NewReader(f)
csvReader.Comma = ';'
csvReader.LazyQuotes = true
output, err := csvReader.ReadAll()
if err != nil {
return types.Organization{}, err
}
trimmed := trimHeaders(output)
ranges := accountRanges(trimmed)
accounts := []types.BookkeepingAccount{}
for _, r := range ranges {
account, err := constructBookkeepingAccount(trimmed[r.Low:r.High])
if err != nil {
slog.Error("construct bookeeping account", slog.Any("err", err))
continue
}
accounts = append(accounts, account)
}
return types.Organization{Accounts: accounts}, nil
}
func trimHeaders(input [][]string) [][]string {
for i, line := range input {
if strings.Contains(line[0], "Konto") {
return input[i+1:]
}
}
return input
}
func accountRanges(input [][]string) []accountRange {
var low int
ranges := []accountRange{}
for i, line := range input {
if line[0] != "" && low == 0 {
low = i
} else if line[0] != "" {
ranges = append(
ranges,
accountRange{
High: i - 1,
Low: low,
},
)
low = i
}
}
return ranges
}
func constructBookkeepingAccount(input [][]string) (types.BookkeepingAccount, error) {
if len(input) < 2 || input[0][0] == "" {
return types.BookkeepingAccount{}, ErrInvalidTable
}
splitAccountString := strings.SplitN(input[0][0], " ", 2)
accountNumber, err := strconv.Atoi(splitAccountString[0])
if err != nil {
return types.BookkeepingAccount{}, err
}
rows := make([]types.BookkeepingAccountRow, 0, len(input))
for _, line := range input[2:] {
row, err := cosntructBookeepingAccountRow(line)
if err != nil {
if errors.Is(err, ErrInvalidRow) {
continue
}
return types.BookkeepingAccount{}, err
}
rows = append(rows, row)
}
return types.BookkeepingAccount{
AccountNumber: accountNumber,
Name: splitAccountString[1],
Rows: rows,
}, nil
}
const (
ACCOUNT_INDEX int32 = 0
VERIFICATION_INDEX int32 = 1
DATE_INDEX int32 = 2
DESCRIPTION_INDEX int32 = 3
PROJECT_INDEX int32 = 7
KOSTNADSSTALLE_INDEX int32 = 8
DEBET_INDEX int32 = 10
KREDIT_INDEX int32 = 11
)
// index : value
// 0 : Account
// 1 : Verification
// 2 : Date
// 3 : Description
// 4 : Transaction text
// 5-6: VAT
// 7 : Project
// 8 : Kostnadsställe
// 9 : Motpart
// 10: Debet
// 11: Kredit
// 12: Beloppp
// 13: Saldo
func cosntructBookeepingAccountRow(input []string) (types.BookkeepingAccountRow, error) {
if len(input) <= 11 || input[VERIFICATION_INDEX] == "" {
return types.BookkeepingAccountRow{}, ErrInvalidRow
}
verification, err := parseVerification(input[VERIFICATION_INDEX])
if err != nil {
return types.BookkeepingAccountRow{}, err
}
debet, err := parseAmount(input[DEBET_INDEX])
if err != nil {
return types.BookkeepingAccountRow{}, err
}
kredit, err := parseAmount(input[KREDIT_INDEX])
if err != nil {
return types.BookkeepingAccountRow{}, err
}
date, err := time.Parse(time.DateOnly, input[DATE_INDEX])
if err != nil {
return types.BookkeepingAccountRow{}, err
}
linkedVerifications, err := parseLinkedVerifications(input[DESCRIPTION_INDEX])
if err != nil {
return types.BookkeepingAccountRow{}, err
}
return types.BookkeepingAccountRow{
Verification: verification,
LinkedVerifications: linkedVerifications,
Project: input[PROJECT_INDEX],
Description: input[DESCRIPTION_INDEX],
OrganizationUnit: &input[KOSTNADSSTALLE_INDEX],
Debet: debet,
Kredit: kredit,
Date: date,
}, nil
}
func parseVerification(input string) (types.Verification, error) {
r := regexp.MustCompile(`(?P<label>[a-zA-Z])\W*(?P<number>\d+)`)
verification := types.Verification{}
matches := r.FindStringSubmatch(input)
if len(matches) < 3 {
return types.Verification{}, errors.New("not enought matches")
}
for i, name := range r.SubexpNames() {
switch name {
case "label":
verification.Series = matches[i]
case "number":
verfNumber, err := strconv.Atoi(matches[i])
if err != nil {
return types.Verification{}, err
}
verification.Number = verfNumber
}
}
return verification, nil
}
func parseAmount(input string) (int, error) {
parts := strings.Split(input, ",")
if len(parts) != 2 {
// allow invalid parts
return 0, nil
}
integer, err := strconv.Atoi(strings.ReplaceAll(parts[0], " ", ""))
if err != nil {
return 0, err
}
decimal, err := strconv.Atoi(parts[1])
if err != nil {
return 0, err
}
return integer*100 + decimal, nil
}
func parseLinkedVerifications(input string) ([]types.Verification, error) {
r := regexp.MustCompile(`(?P<label>[a-zA-Z])\W*(?P<number>\d+)`)
allMatches := r.FindAllStringSubmatch(input, -1)
out := []types.Verification{}
for _, matches := range allMatches {
verification := types.Verification{}
if len(matches) < 3 {
return nil, errors.New("not enought matches")
}
for i, name := range r.SubexpNames() {
switch name {
case "label":
verification.Series = matches[i]
case "number":
verfNumber, err := strconv.Atoi(matches[i])
if err != nil {
return nil, err
}
verification.Number = verfNumber
}
}
out = append(out, verification)
}
return out, nil
}