A wealth figure that ignores the house is not a wealth figure. Assets without a market feed - a house, a car, a private loan - are now added by hand on the Wealth page with a stated value, a currency and the day the estimate was made; a negative value records a liability. They are registry entities in assets.finance like everything else, join the per-currency totals immediately, and a currency held only in an asset earns its own line.
412 lines
10 KiB
Go
412 lines
10 KiB
Go
package journal
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"finance-duck/internal/domain"
|
|
)
|
|
|
|
// Grammar: kind { on its own line, followed by field: JSON values, then }.
|
|
// JSON values may span lines. Blank lines and full-line # or // comments are
|
|
// permitted between fields and blocks. Strings use JSON escaping, including \n.
|
|
// registryFiles are the non-monthly journal files, in the order they are read
|
|
// and written. A block's file is its kind pluralized, so this list and the
|
|
// kinds accepted by parseDocument must stay in step.
|
|
var registryFiles = []string{"accounts.finance", "categories.finance", "tags.finance", "merchants.finance", "instruments.finance", "assets.finance"}
|
|
|
|
type fieldSpan struct{ start, end int }
|
|
type block struct {
|
|
kind, id string
|
|
line int
|
|
lines []string
|
|
fields map[string]fieldSpan
|
|
value any
|
|
}
|
|
type piece struct {
|
|
text string
|
|
block *block
|
|
}
|
|
type document struct {
|
|
path string
|
|
pieces []piece
|
|
}
|
|
|
|
func comment(s string) bool {
|
|
s = strings.TrimSpace(s)
|
|
return s == "" || strings.HasPrefix(s, "#") || strings.HasPrefix(s, "//")
|
|
}
|
|
func decodeStrict(raw []byte, value any) error {
|
|
check := json.NewDecoder(bytes.NewReader(raw))
|
|
check.UseNumber()
|
|
if err := checkJSON(check); err != nil {
|
|
return err
|
|
}
|
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(value); err != nil {
|
|
return err
|
|
}
|
|
var extra any
|
|
if err := dec.Decode(&extra); err != io.EOF {
|
|
return fmt.Errorf("expected one JSON value")
|
|
}
|
|
return nil
|
|
}
|
|
func checkJSON(dec *json.Decoder) error {
|
|
token, err := dec.Token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
delimiter, ok := token.(json.Delim)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
switch delimiter {
|
|
case '{':
|
|
keys := map[string]bool{}
|
|
for dec.More() {
|
|
key, err := dec.Token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
name, ok := key.(string)
|
|
if !ok {
|
|
return fmt.Errorf("expected JSON object key")
|
|
}
|
|
if keys[name] {
|
|
return fmt.Errorf("duplicate JSON key %q", name)
|
|
}
|
|
keys[name] = true
|
|
if err = checkJSON(dec); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
case '[':
|
|
for dec.More() {
|
|
if err = checkJSON(dec); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
default:
|
|
return fmt.Errorf("unexpected JSON delimiter")
|
|
}
|
|
_, err = dec.Token()
|
|
return err
|
|
}
|
|
func fieldsOf(value any) (map[string]json.RawMessage, []string, error) {
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
m := map[string]json.RawMessage{}
|
|
if err = json.Unmarshal(raw, &m); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
typ := reflect.TypeOf(value)
|
|
keys := []string{}
|
|
for i := range typ.NumField() {
|
|
key := strings.Split(typ.Field(i).Tag.Get("json"), ",")[0]
|
|
if _, ok := m[key]; ok {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
return m, keys, nil
|
|
}
|
|
func parseDocument(path string, raw []byte) (*document, error) {
|
|
fail := func(line int, err any) (*document, error) { return nil, fmt.Errorf("%s:%d: %v", path, line, err) }
|
|
if !utf8.Valid(raw) {
|
|
return fail(1, "file is not valid UTF-8")
|
|
}
|
|
lines := strings.SplitAfter(string(raw), "\n")
|
|
doc := &document{path: path}
|
|
pending := ""
|
|
for i := 0; i < len(lines); {
|
|
trimmed := strings.TrimSpace(lines[i])
|
|
if comment(trimmed) {
|
|
pending += lines[i]
|
|
i++
|
|
continue
|
|
}
|
|
if pending != "" {
|
|
doc.pieces = append(doc.pieces, piece{text: pending})
|
|
pending = ""
|
|
}
|
|
header := strings.Fields(trimmed)
|
|
if len(header) != 2 || header[1] != "{" {
|
|
return fail(i+1, "expected 'account|category|tag|merchant|instrument|asset|transaction {'")
|
|
}
|
|
kind := header[0]
|
|
var value any
|
|
switch kind {
|
|
case "account":
|
|
value = &domain.Account{}
|
|
case "category":
|
|
value = &domain.Category{}
|
|
case "tag":
|
|
value = &domain.Tag{}
|
|
case "merchant":
|
|
value = &domain.Merchant{}
|
|
case "instrument":
|
|
value = &domain.Instrument{}
|
|
case "asset":
|
|
value = &domain.Asset{}
|
|
case "transaction":
|
|
value = &domain.Transaction{}
|
|
default:
|
|
return fail(i+1, "unknown block kind "+kind)
|
|
}
|
|
fieldTypes := map[string]reflect.StructField{}
|
|
typ := reflect.TypeOf(value).Elem()
|
|
for n := range typ.NumField() {
|
|
field := typ.Field(n)
|
|
fieldTypes[strings.Split(field.Tag.Get("json"), ",")[0]] = field
|
|
}
|
|
start := i
|
|
i++
|
|
fields := map[string]fieldSpan{}
|
|
closed := false
|
|
for i < len(lines) {
|
|
s := strings.TrimSpace(lines[i])
|
|
if s == "}" {
|
|
i++
|
|
closed = true
|
|
break
|
|
}
|
|
if comment(s) {
|
|
i++
|
|
continue
|
|
}
|
|
colon := strings.Index(s, ":")
|
|
if colon <= 0 {
|
|
return fail(i+1, "expected field: JSON")
|
|
}
|
|
key := strings.TrimSpace(s[:colon])
|
|
if strings.ContainsAny(key, " \t\"{}") {
|
|
return fail(i+1, "invalid field name")
|
|
}
|
|
if _, exists := fields[key]; exists {
|
|
return fail(i+1, "duplicate field "+key)
|
|
}
|
|
fieldStart := i
|
|
jsonText := strings.TrimSpace(s[colon+1:])
|
|
for !json.Valid([]byte(jsonText)) {
|
|
if jsonText != "" {
|
|
var probe any
|
|
err := json.Unmarshal([]byte(jsonText), &probe)
|
|
if err != nil && !strings.Contains(err.Error(), "unexpected end of JSON input") {
|
|
return fail(fieldStart+1, "field "+key+": "+err.Error())
|
|
}
|
|
}
|
|
i++
|
|
if i >= len(lines) {
|
|
return fail(fieldStart+1, "unterminated JSON value for "+key)
|
|
}
|
|
jsonText += "\n" + strings.TrimSuffix(lines[i], "\n")
|
|
}
|
|
fieldType, known := fieldTypes[key]
|
|
if !known {
|
|
return fail(fieldStart+1, "unknown field "+key)
|
|
}
|
|
fieldValue := reflect.New(fieldType.Type)
|
|
if err := decodeStrict([]byte(jsonText), fieldValue.Interface()); err != nil {
|
|
return fail(fieldStart+1, "field "+key+": "+err.Error())
|
|
}
|
|
reflect.ValueOf(value).Elem().FieldByIndex(fieldType.Index).Set(fieldValue.Elem())
|
|
fields[key] = fieldSpan{start: fieldStart - start, end: i - start}
|
|
i++
|
|
}
|
|
if !closed {
|
|
return fail(start+1, "unterminated block")
|
|
}
|
|
b := &block{kind: kind, line: start + 1, lines: append([]string{}, lines[start:i]...), fields: fields}
|
|
switch v := value.(type) {
|
|
case *domain.Account:
|
|
b.id = v.ID
|
|
b.value = *v
|
|
case *domain.Category:
|
|
b.id = v.ID
|
|
b.value = *v
|
|
case *domain.Tag:
|
|
b.id = v.ID
|
|
b.value = *v
|
|
case *domain.Instrument:
|
|
b.id = v.ID
|
|
b.value = *v
|
|
case *domain.Asset:
|
|
b.id = v.ID
|
|
b.value = *v
|
|
case *domain.Merchant:
|
|
if v.Aliases == nil {
|
|
v.Aliases = []string{}
|
|
}
|
|
if v.DefaultTagIDs == nil {
|
|
v.DefaultTagIDs = []string{}
|
|
}
|
|
b.id = v.ID
|
|
b.value = *v
|
|
case *domain.Transaction:
|
|
if v.Enrichment.TagIDs == nil {
|
|
v.Enrichment.TagIDs = []string{}
|
|
}
|
|
b.id = v.Facts.ID
|
|
b.value = *v
|
|
}
|
|
doc.pieces = append(doc.pieces, piece{block: b})
|
|
}
|
|
if pending != "" {
|
|
doc.pieces = append(doc.pieces, piece{text: pending})
|
|
}
|
|
return doc, nil
|
|
}
|
|
func renderNew(kind string, value any) ([]byte, error) {
|
|
fields, keys, err := fieldsOf(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out strings.Builder
|
|
out.WriteString(kind + " {\n")
|
|
for _, key := range keys {
|
|
out.WriteString(" " + key + ": " + string(fields[key]) + "\n")
|
|
}
|
|
out.WriteString("}\n")
|
|
return []byte(out.String()), nil
|
|
}
|
|
func (b *block) render(value any) ([]byte, error) {
|
|
if reflect.DeepEqual(b.value, value) {
|
|
return []byte(strings.Join(b.lines, "")), nil
|
|
}
|
|
fields, keys, err := fieldsOf(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
oldFields, _, err := fieldsOf(b.value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
starts := map[int]string{}
|
|
for key, f := range b.fields {
|
|
starts[f.start] = key
|
|
}
|
|
var out strings.Builder
|
|
for i := 0; i < len(b.lines); i++ {
|
|
if i == len(b.lines)-1 {
|
|
for _, key := range keys {
|
|
if _, ok := b.fields[key]; !ok {
|
|
out.WriteString(" " + key + ": " + string(fields[key]) + "\n")
|
|
}
|
|
}
|
|
}
|
|
key, ok := starts[i]
|
|
if !ok {
|
|
out.WriteString(b.lines[i])
|
|
continue
|
|
}
|
|
f := b.fields[key]
|
|
next, exists := fields[key]
|
|
if exists {
|
|
if bytes.Equal(oldFields[key], next) {
|
|
out.WriteString(strings.Join(b.lines[i:f.end+1], ""))
|
|
} else {
|
|
out.WriteString(" " + key + ": " + string(next) + "\n")
|
|
}
|
|
}
|
|
i = f.end
|
|
}
|
|
return []byte(out.String()), nil
|
|
}
|
|
func datasetFiles(d domain.Dataset) map[string]map[string]piece {
|
|
files := map[string]map[string]piece{}
|
|
for _, p := range registryFiles {
|
|
files[p] = map[string]piece{}
|
|
}
|
|
add := func(path, kind, id string, value any) {
|
|
if files[path] == nil {
|
|
files[path] = map[string]piece{}
|
|
}
|
|
files[path][id] = piece{block: &block{kind: kind, id: id, value: value}}
|
|
}
|
|
for _, v := range d.Accounts {
|
|
add("accounts.finance", "account", v.ID, v)
|
|
}
|
|
for _, v := range d.Categories {
|
|
add("categories.finance", "category", v.ID, v)
|
|
}
|
|
for _, v := range d.Tags {
|
|
add("tags.finance", "tag", v.ID, v)
|
|
}
|
|
for _, v := range d.Merchants {
|
|
add("merchants.finance", "merchant", v.ID, v)
|
|
}
|
|
for _, v := range d.Instruments {
|
|
add("instruments.finance", "instrument", v.ID, v)
|
|
}
|
|
for _, v := range d.Assets {
|
|
add("assets.finance", "asset", v.ID, v)
|
|
}
|
|
for _, v := range d.Transactions {
|
|
month := v.Facts.BookingDate[:7]
|
|
add("journal/"+month[:4]+"/"+month+".finance", "transaction", v.Facts.ID, v)
|
|
}
|
|
return files
|
|
}
|
|
func renderFiles(d domain.Dataset, docs map[string]*document) (map[string][]byte, error) {
|
|
wanted := datasetFiles(d)
|
|
for path := range docs {
|
|
if wanted[path] == nil {
|
|
wanted[path] = map[string]piece{}
|
|
}
|
|
}
|
|
out := map[string][]byte{}
|
|
for path, blocks := range wanted {
|
|
var buf bytes.Buffer
|
|
if doc := docs[path]; doc != nil {
|
|
for _, p := range doc.pieces {
|
|
if p.block == nil {
|
|
buf.WriteString(p.text)
|
|
continue
|
|
}
|
|
b := p.block
|
|
if next, ok := blocks[b.id]; ok {
|
|
raw, err := b.render(next.block.value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
buf.Write(raw)
|
|
delete(blocks, b.id)
|
|
} else {
|
|
for _, line := range b.lines {
|
|
if comment(line) {
|
|
buf.WriteString(line)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ids := make([]string, 0, len(blocks))
|
|
for id := range blocks {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Strings(ids)
|
|
for _, id := range ids {
|
|
if buf.Len() > 0 && !bytes.HasSuffix(buf.Bytes(), []byte("\n")) {
|
|
buf.WriteByte('\n')
|
|
}
|
|
p := blocks[id]
|
|
raw, err := renderNew(p.block.kind, p.block.value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
buf.Write(raw)
|
|
}
|
|
out[path] = buf.Bytes()
|
|
}
|
|
return out, nil
|
|
}
|