init
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
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.
|
||||
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|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 "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.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 []string{"accounts.finance", "categories.finance", "tags.finance", "merchants.finance"} {
|
||||
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.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
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
package journal
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"finance-duck/internal/domain"
|
||||
)
|
||||
|
||||
var ErrConflict = errors.New("journal revision conflict")
|
||||
var ErrClosed = errors.New("journal is closed")
|
||||
var ErrImmutable = errors.New("existing transaction facts are immutable")
|
||||
var monthlyPath = regexp.MustCompile(`^journal/([0-9]{4})/([0-9]{4})-(0[1-9]|1[0-2])\.finance$`)
|
||||
|
||||
const maxFileBytes = 64 << 20
|
||||
const walName = ".commit"
|
||||
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
dir string
|
||||
lock *os.File
|
||||
closed bool
|
||||
}
|
||||
type snapshot struct {
|
||||
data domain.Dataset
|
||||
revision string
|
||||
docs map[string]*document
|
||||
raw map[string][]byte
|
||||
}
|
||||
type walEntry struct {
|
||||
Path string `json:"path"`
|
||||
Before string `json:"before"`
|
||||
After string `json:"after"`
|
||||
Stage string `json:"stage"`
|
||||
}
|
||||
type manifest struct {
|
||||
Version int `json:"version"`
|
||||
Revision string `json:"revision"`
|
||||
Files []walEntry `json:"files"`
|
||||
}
|
||||
|
||||
func Open(dir string) (*Store, error) {
|
||||
root, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = privateDir(root); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fd, err := syscall.Open(filepath.Join(root, ".lock"), syscall.O_RDWR|syscall.O_CREAT|syscall.O_NOFOLLOW|syscall.O_CLOEXEC|syscall.O_NONBLOCK, 0600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("journal lock: %w", err)
|
||||
}
|
||||
lock := os.NewFile(uintptr(fd), "journal lock")
|
||||
info, err := lock.Stat()
|
||||
if err != nil {
|
||||
lock.Close()
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
lock.Close()
|
||||
return nil, fmt.Errorf("journal lock must be a regular file")
|
||||
}
|
||||
if err = lock.Chmod(0600); err != nil {
|
||||
lock.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err = syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
lock.Close()
|
||||
return nil, fmt.Errorf("journal is already locked: %w", err)
|
||||
}
|
||||
s := &Store{dir: root, lock: lock}
|
||||
fail := func(err error) (*Store, error) { s.Close(); return nil, err }
|
||||
if err = s.recover(); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
snap, err := s.snapshot()
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if len(snap.raw) == 0 {
|
||||
if _, err = s.Commit(snap.revision, domain.NewDataset()); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
func (s *Store) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
err := syscall.Flock(int(s.lock.Fd()), syscall.LOCK_UN)
|
||||
closeErr := s.lock.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
func (s *Store) Load() (domain.Dataset, string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return domain.Dataset{}, "", ErrClosed
|
||||
}
|
||||
if err := s.recover(); err != nil {
|
||||
return domain.Dataset{}, "", err
|
||||
}
|
||||
snap, err := s.snapshot()
|
||||
if err != nil {
|
||||
return domain.Dataset{}, "", err
|
||||
}
|
||||
return domain.Clone(snap.data), snap.revision, nil
|
||||
}
|
||||
func (s *Store) Commit(expectedRevision string, next domain.Dataset) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return "", ErrClosed
|
||||
}
|
||||
if err := s.recover(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
before, err := s.snapshot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if before.revision != expectedRevision {
|
||||
return "", ErrConflict
|
||||
}
|
||||
if err = domain.Validate(next); err != nil {
|
||||
return "", err
|
||||
}
|
||||
existing := map[string]domain.Facts{}
|
||||
for _, t := range next.Transactions {
|
||||
existing[t.Facts.ID] = t.Facts
|
||||
}
|
||||
for _, t := range before.data.Transactions {
|
||||
f, ok := existing[t.Facts.ID]
|
||||
if !ok || !reflect.DeepEqual(f, t.Facts) {
|
||||
return "", fmt.Errorf("%w: %s", ErrImmutable, t.Facts.ID)
|
||||
}
|
||||
}
|
||||
output, err := renderFiles(next, before.docs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for path, raw := range output {
|
||||
if len(raw) > maxFileBytes {
|
||||
return "", fmt.Errorf("%s: exceeds 64 MiB file limit", path)
|
||||
}
|
||||
}
|
||||
newRevision := revision(output)
|
||||
if newRevision == before.revision {
|
||||
return newRevision, nil
|
||||
}
|
||||
// Staging never changes canonical files. A synced manifest is the commit point:
|
||||
// once present, every reader/reopen finishes the entire validated generation.
|
||||
temp := filepath.Join(s.dir, ".prepare")
|
||||
if err = removePrivateTree(temp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = privateDir(temp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = removePrivateTree(temp)
|
||||
}
|
||||
}()
|
||||
m := manifest{Version: 1, Revision: before.revision, Files: []walEntry{}}
|
||||
paths := sortedPaths(output)
|
||||
for i, path := range paths {
|
||||
stage := fmt.Sprintf("%06d", i)
|
||||
if err = writeSynced(filepath.Join(temp, stage), output[path]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
old := ""
|
||||
if raw, ok := before.raw[path]; ok {
|
||||
old = hash(raw)
|
||||
}
|
||||
m.Files = append(m.Files, walEntry{Path: path, Before: old, After: hash(output[path]), Stage: stage})
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw) > maxFileBytes {
|
||||
return "", fmt.Errorf("commit manifest exceeds 64 MiB file limit")
|
||||
}
|
||||
if err = writeSynced(filepath.Join(temp, "manifest.json"), raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = syncDir(temp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Detect edits made while the new generation was being prepared.
|
||||
current, err := s.readFiles()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if revision(current) != before.revision {
|
||||
return "", ErrConflict
|
||||
}
|
||||
if err = os.Rename(temp, filepath.Join(s.dir, walName)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
committed = true
|
||||
if err = syncDir(s.dir); err != nil {
|
||||
return "", fmt.Errorf("commit pending recovery: %w", err)
|
||||
}
|
||||
if err = s.recover(); err != nil {
|
||||
return "", fmt.Errorf("commit pending recovery: %w", err)
|
||||
}
|
||||
return newRevision, nil
|
||||
}
|
||||
func hash(raw []byte) string { sum := sha256.Sum256(raw); return hex.EncodeToString(sum[:]) }
|
||||
func sortedPaths[V any](m map[string]V) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
func revision(raw map[string][]byte) string {
|
||||
hashes := map[string]string{}
|
||||
for p, b := range raw {
|
||||
hashes[p] = hash(b)
|
||||
}
|
||||
return revisionHashes(hashes)
|
||||
}
|
||||
func revisionHashes(hashes map[string]string) string {
|
||||
h := sha256.New()
|
||||
for _, p := range sortedPaths(hashes) {
|
||||
io.WriteString(h, p)
|
||||
h.Write([]byte{0})
|
||||
io.WriteString(h, hashes[p])
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
func validPath(path string) bool {
|
||||
switch path {
|
||||
case "accounts.finance", "categories.finance", "tags.finance", "merchants.finance":
|
||||
return true
|
||||
}
|
||||
parts := monthlyPath.FindStringSubmatch(path)
|
||||
return len(parts) > 0 && parts[1] == parts[2] && parts[1] != "0000"
|
||||
}
|
||||
func privateDir(path string) error {
|
||||
for ancestor := filepath.Clean(path); ; ancestor = filepath.Dir(ancestor) {
|
||||
info, err := os.Lstat(ancestor)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err == nil && (info.Mode()&os.ModeSymlink != 0 || !info.IsDir()) {
|
||||
return fmt.Errorf("%s: expected real directory, not symlink", ancestor)
|
||||
}
|
||||
if filepath.Dir(ancestor) == ancestor {
|
||||
break
|
||||
}
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
if err = os.MkdirAll(path, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err = os.Lstat(path)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return fmt.Errorf("%s: expected real directory, not symlink", path)
|
||||
}
|
||||
return os.Chmod(path, 0700)
|
||||
}
|
||||
func syncDir(path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return f.Sync()
|
||||
}
|
||||
func readSecure(path string) ([]byte, error) {
|
||||
fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC|syscall.O_NONBLOCK, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := os.NewFile(uintptr(fd), path)
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("%s: not a regular file", path)
|
||||
}
|
||||
if info.Size() > maxFileBytes {
|
||||
return nil, fmt.Errorf("%s: exceeds 64 MiB file limit", path)
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(f, maxFileBytes+1))
|
||||
if len(raw) > maxFileBytes {
|
||||
return nil, fmt.Errorf("%s: exceeds 64 MiB file limit", path)
|
||||
}
|
||||
return raw, err
|
||||
}
|
||||
func writeSynced(path string, raw []byte) error {
|
||||
fd, err := syscall.Open(path, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f := os.NewFile(uintptr(fd), path)
|
||||
if _, err = f.Write(raw); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err = f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
func removePrivateTree(path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return fmt.Errorf("%s: unsafe staging directory", path)
|
||||
}
|
||||
return os.RemoveAll(path)
|
||||
}
|
||||
func (s *Store) readFiles() (map[string][]byte, error) {
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasSuffix(entry.Name(), ".finance") && !validPath(entry.Name()) {
|
||||
return nil, fmt.Errorf("%s:1: unexpected registry filename", entry.Name())
|
||||
}
|
||||
}
|
||||
raw := map[string][]byte{}
|
||||
for _, path := range []string{"accounts.finance", "categories.finance", "tags.finance", "merchants.finance"} {
|
||||
b, err := readSecure(filepath.Join(s.dir, path))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s:1: %w", path, err)
|
||||
}
|
||||
raw[path] = b
|
||||
}
|
||||
root := filepath.Join(s.dir, "journal")
|
||||
info, err := os.Lstat(root)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return raw, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return nil, fmt.Errorf("journal:1: expected real directory")
|
||||
}
|
||||
err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
relative, err := filepath.Rel(s.dir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relative = filepath.ToSlash(relative)
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("%s:1: symbolic links are prohibited", relative)
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(relative, ".finance") {
|
||||
return nil
|
||||
}
|
||||
if !validPath(relative) {
|
||||
return fmt.Errorf("%s:1: expected journal/YYYY/YYYY-MM.finance", relative)
|
||||
}
|
||||
b, err := readSecure(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s:1: %w", relative, err)
|
||||
}
|
||||
raw[relative] = b
|
||||
return nil
|
||||
})
|
||||
return raw, err
|
||||
}
|
||||
func (s *Store) snapshot() (*snapshot, error) {
|
||||
raw, err := s.readFiles()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snap, err := decodeSnapshot(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Non-cooperating editors do not take our process lock. Do not publish a
|
||||
// mixed-generation read if files changed while parsing and validating.
|
||||
current, err := s.readFiles()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if revision(current) != snap.revision {
|
||||
return nil, ErrConflict
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
func decodeSnapshot(raw map[string][]byte) (*snapshot, error) {
|
||||
snap := &snapshot{raw: raw, docs: map[string]*document{}, revision: revision(raw), data: domain.Dataset{Accounts: []domain.Account{}, Categories: []domain.Category{}, Tags: []domain.Tag{}, Merchants: []domain.Merchant{}, Transactions: []domain.Transaction{}}}
|
||||
if len(raw) == 0 {
|
||||
snap.data = domain.NewDataset()
|
||||
return snap, nil
|
||||
}
|
||||
locations := map[string]string{}
|
||||
for _, path := range sortedPaths(raw) {
|
||||
doc, err := parseDocument(path, raw[path])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snap.docs[path] = doc
|
||||
for _, p := range doc.pieces {
|
||||
if p.block == nil {
|
||||
continue
|
||||
}
|
||||
b := p.block
|
||||
location := fmt.Sprintf("%s:%d", path, b.line)
|
||||
if previous, ok := locations[b.id]; ok {
|
||||
return nil, fmt.Errorf("%s: duplicate ID %q (first at %s)", location, b.id, previous)
|
||||
}
|
||||
locations[b.id] = location
|
||||
expected := b.kind + "s.finance"
|
||||
if b.kind == "category" {
|
||||
expected = "categories.finance"
|
||||
}
|
||||
if b.kind == "transaction" {
|
||||
t := b.value.(domain.Transaction)
|
||||
if len(t.Facts.BookingDate) < 7 {
|
||||
return nil, fmt.Errorf("%s: invalid booking date", location)
|
||||
}
|
||||
month := t.Facts.BookingDate[:7]
|
||||
expected = "journal/" + month[:4] + "/" + month + ".finance"
|
||||
}
|
||||
if path != expected {
|
||||
return nil, fmt.Errorf("%s: %s block belongs in %s", location, b.kind, expected)
|
||||
}
|
||||
switch v := b.value.(type) {
|
||||
case domain.Account:
|
||||
snap.data.Accounts = append(snap.data.Accounts, v)
|
||||
case domain.Category:
|
||||
snap.data.Categories = append(snap.data.Categories, v)
|
||||
case domain.Tag:
|
||||
snap.data.Tags = append(snap.data.Tags, v)
|
||||
case domain.Merchant:
|
||||
snap.data.Merchants = append(snap.data.Merchants, v)
|
||||
case domain.Transaction:
|
||||
snap.data.Transactions = append(snap.data.Transactions, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := domain.Validate(snap.data); err != nil {
|
||||
for _, id := range sortedPaths(locations) {
|
||||
if strings.Contains(err.Error(), fmt.Sprintf("%q", id)) {
|
||||
return nil, fmt.Errorf("%s: %w", locations[id], err)
|
||||
}
|
||||
}
|
||||
path := "categories.finance"
|
||||
if _, ok := raw[path]; !ok {
|
||||
path = sortedPaths(raw)[0]
|
||||
}
|
||||
return nil, fmt.Errorf("%s:1: %w", path, err)
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
func (s *Store) recover() error {
|
||||
wal := filepath.Join(s.dir, walName)
|
||||
info, err := os.Lstat(wal)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return fmt.Errorf("unsafe recovery directory")
|
||||
}
|
||||
raw, err := readSecure(filepath.Join(wal, "manifest.json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var m manifest
|
||||
if err = decodeStrict(raw, &m); err != nil {
|
||||
return fmt.Errorf("recovery manifest: %w", err)
|
||||
}
|
||||
if m.Version != 1 || len(m.Files) == 0 {
|
||||
return fmt.Errorf("unsupported or empty recovery manifest")
|
||||
}
|
||||
staged := map[string][]byte{}
|
||||
seen := map[string]bool{}
|
||||
for i, e := range m.Files {
|
||||
if !validPath(e.Path) || e.Stage != fmt.Sprintf("%06d", i) || seen[e.Path] {
|
||||
return fmt.Errorf("unsafe recovery entry %q", e.Path)
|
||||
}
|
||||
seen[e.Path] = true
|
||||
b, err := readSecure(filepath.Join(wal, e.Stage))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hash(b) != e.After {
|
||||
return fmt.Errorf("recovery checksum mismatch: %s", e.Path)
|
||||
}
|
||||
staged[e.Path] = b
|
||||
}
|
||||
if _, err = decodeSnapshot(staged); err != nil {
|
||||
return fmt.Errorf("invalid staged generation: %w", err)
|
||||
}
|
||||
current, err := s.readFiles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
baseline := map[string]string{}
|
||||
for p, b := range current {
|
||||
baseline[p] = hash(b)
|
||||
}
|
||||
for _, e := range m.Files {
|
||||
actual := baseline[e.Path]
|
||||
if actual != e.Before && actual != e.After {
|
||||
return fmt.Errorf("%w: %s edited during pending commit; staged data retained", ErrConflict, e.Path)
|
||||
}
|
||||
if e.Before == "" {
|
||||
delete(baseline, e.Path)
|
||||
} else {
|
||||
baseline[e.Path] = e.Before
|
||||
}
|
||||
}
|
||||
if revisionHashes(baseline) != m.Revision {
|
||||
return fmt.Errorf("%w: files added or removed during pending commit; staged data retained", ErrConflict)
|
||||
}
|
||||
for _, e := range m.Files {
|
||||
if b, ok := current[e.Path]; ok && hash(b) == e.After {
|
||||
continue
|
||||
}
|
||||
target := filepath.Join(s.dir, filepath.FromSlash(e.Path))
|
||||
parent := filepath.Dir(target)
|
||||
if parent != s.dir {
|
||||
if err = privateDir(filepath.Join(s.dir, "journal")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = privateDir(parent); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = syncDir(filepath.Join(s.dir, "journal")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = syncDir(s.dir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
temporary := target + ".pending"
|
||||
if info, statErr := os.Lstat(temporary); statErr == nil {
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("unsafe pending file %s", temporary)
|
||||
}
|
||||
if err = os.Remove(temporary); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !errors.Is(statErr, os.ErrNotExist) {
|
||||
return statErr
|
||||
}
|
||||
if err = writeSynced(temporary, staged[e.Path]); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.Rename(temporary, target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = syncDir(parent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Removing the manifest first would make a partially deleted WAL ambiguous.
|
||||
// Atomically retire the whole directory after every target and directory sync.
|
||||
retired := filepath.Join(s.dir, ".retired")
|
||||
if err = removePrivateTree(retired); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.Rename(wal, retired); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = syncDir(s.dir); err != nil {
|
||||
return err
|
||||
}
|
||||
return removePrivateTree(retired)
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
package journal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"finance-duck/internal/domain"
|
||||
)
|
||||
|
||||
func fixtureDataset(t *testing.T) (domain.Dataset, []byte) {
|
||||
t.Helper()
|
||||
d := domain.NewDataset()
|
||||
d.Accounts = []domain.Account{{ID: "acc_main", DisplayName: "Main", Currency: "EUR", Active: true}, {ID: "acc_save", DisplayName: "Savings", Currency: "EUR", Active: true}, {ID: "acc_usd", DisplayName: "Dollars", Currency: "USD", Active: true}, {ID: "acc_gbp", DisplayName: "Pounds", Currency: "GBP", Active: true}}
|
||||
d.Categories = append(d.Categories, domain.Category{ID: "cat_grocery", Name: "Groceries", Kind: "expense", ParentID: "cat_expenses"})
|
||||
d.Tags = []domain.Tag{{ID: "tag_food", Name: "Food"}, {ID: "tag_recurring", Name: "Recurring"}}
|
||||
d.Merchants = []domain.Merchant{{ID: "mer_cafe", Name: "Café", Aliases: []string{"Cafe", "Café GmbH"}, DefaultCategoryID: "cat_grocery", DefaultTagIDs: []string{"tag_food"}}}
|
||||
slices.SortFunc(d.Accounts, func(a, b domain.Account) int { return strings.Compare(a.ID, b.ID) })
|
||||
entries, err := os.ReadDir("testdata")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var all bytes.Buffer
|
||||
for _, entry := range entries {
|
||||
if !strings.HasSuffix(entry.Name(), ".finance") {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", entry.Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc, err := parseDocument(entry.Name(), raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range doc.pieces {
|
||||
if p.block != nil {
|
||||
d.Transactions = append(d.Transactions, p.block.value.(domain.Transaction))
|
||||
}
|
||||
}
|
||||
all.Write(raw)
|
||||
}
|
||||
if err = domain.Validate(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d, all.Bytes()
|
||||
}
|
||||
func openTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return s
|
||||
}
|
||||
func loadTestStore(t *testing.T, s *Store) (domain.Dataset, string) {
|
||||
t.Helper()
|
||||
d, r, err := s.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d, r
|
||||
}
|
||||
func commitTestStore(t *testing.T, s *Store, revision string, d domain.Dataset) string {
|
||||
t.Helper()
|
||||
r, err := s.Commit(revision, d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
func writeTestFile(t *testing.T, path string, raw []byte) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, raw, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func readTestFile(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func TestFixturesRoundTripAndCommentsSurviveEnrichmentEdit(t *testing.T) {
|
||||
d, fixtureRaw := fixtureDataset(t)
|
||||
s := openTestStore(t)
|
||||
_, r := loadTestStore(t, s)
|
||||
commitTestStore(t, s, r, d)
|
||||
monthly := filepath.Join(s.dir, "journal", "2026", "2026-01.finance")
|
||||
writeTestFile(t, monthly, fixtureRaw)
|
||||
loaded, r := loadTestStore(t, s)
|
||||
if !reflect.DeepEqual(domain.Clone(d), loaded) {
|
||||
t.Fatal("fixture semantics changed on load")
|
||||
}
|
||||
if next := commitTestStore(t, s, r, loaded); next != r {
|
||||
t.Fatal("no-op changed revision")
|
||||
}
|
||||
if !bytes.Equal(readTestFile(t, monthly), fixtureRaw) {
|
||||
t.Fatal("no-op rewrote fixture bytes")
|
||||
}
|
||||
loaded.Transactions[3].Enrichment.CategoryID = "cat_grocery"
|
||||
loaded.Transactions[3].Enrichment.TagIDs = []string{"tag_food"}
|
||||
loaded.Transactions[3].Enrichment.Classification = domain.Provenance{Source: "manual"}
|
||||
commitTestStore(t, s, r, loaded)
|
||||
updated := readTestFile(t, monthly)
|
||||
beforeDoc, err := parseDocument("before", fixtureRaw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
afterDoc, err := parseDocument("after", updated)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, p := range beforeDoc.pieces {
|
||||
q := afterDoc.pieces[i]
|
||||
if p.block == nil {
|
||||
if p.text != q.text {
|
||||
t.Fatal("outside comment changed")
|
||||
}
|
||||
continue
|
||||
}
|
||||
b, a := p.block, q.block
|
||||
if b.id != "tx_fixture_04" {
|
||||
if strings.Join(b.lines, "") != strings.Join(a.lines, "") {
|
||||
t.Fatalf("untouched block %s rewritten", b.id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
oldSpan, newSpan := b.fields["facts"], a.fields["facts"]
|
||||
if strings.Join(b.lines[oldSpan.start:oldSpan.end+1], "") != strings.Join(a.lines[newSpan.start:newSpan.end+1], "") {
|
||||
t.Fatal("multiline immutable facts rewritten")
|
||||
}
|
||||
for _, line := range b.lines {
|
||||
if comment(line) && !bytes.Contains(updated, []byte(line)) {
|
||||
t.Fatal("inner comment lost")
|
||||
}
|
||||
}
|
||||
}
|
||||
reloaded, revision := loadTestStore(t, s)
|
||||
if !reflect.DeepEqual(reloaded, loaded) {
|
||||
t.Fatal("enrichment edit failed to persist")
|
||||
}
|
||||
if got := commitTestStore(t, s, revision, reloaded); got != revision {
|
||||
t.Fatal("second round trip is not stable")
|
||||
}
|
||||
}
|
||||
func TestParserRejectsMalformedFieldsAtTheirSourceLine(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, raw string
|
||||
line int
|
||||
}{
|
||||
{"syntax", "tag {\n id: \"tag_a\"\n name: not-json\n}\n", 3},
|
||||
{"unknown", "tag {\n id: \"tag_a\"\n surprise: true\n}\n", 3},
|
||||
{"duplicate field", "tag {\n id: \"tag_a\"\n id: \"tag_b\"\n}\n", 3},
|
||||
{"duplicate nested key", "transaction {\n facts: {\"id\":\"tx_a\",\"id\":\"tx_b\"}\n}\n", 2},
|
||||
{"wrong type", "tag {\n id: 123\n}\n", 2},
|
||||
{"unclosed", "tag {\n id: \"tag_a\"\n", 1},
|
||||
{"unknown block", "mystery {\n}\n", 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := parseDocument("bad.finance", []byte(tc.raw))
|
||||
if err == nil || !strings.Contains(err.Error(), fmt.Sprintf("bad.finance:%d:", tc.line)) {
|
||||
t.Fatalf("expected source line %d, got %v", tc.line, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestExternalInvalidFileRejectsWholeDatasetAndRetainsEditedBytes(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
d, r := loadTestStore(t, s)
|
||||
d.Tags = append(d.Tags, domain.Tag{ID: "tag_valid", Name: "Valid"})
|
||||
r = commitTestStore(t, s, r, d)
|
||||
path := filepath.Join(s.dir, "tags.finance")
|
||||
original := readTestFile(t, path)
|
||||
invalid := append(append([]byte{}, original...), []byte("\ntag {\n id: \"tag_bad\"\n name: broken\n}\n")...)
|
||||
writeTestFile(t, path, invalid)
|
||||
loaded, revision, err := s.Load()
|
||||
if err == nil || !strings.Contains(err.Error(), "tags.finance:") {
|
||||
t.Fatalf("expected file/line failure, got %v", err)
|
||||
}
|
||||
if len(loaded.Categories) != 0 || revision != "" {
|
||||
t.Fatal("returned partial or previously cached dataset")
|
||||
}
|
||||
if _, err = s.Commit(r, d); err == nil {
|
||||
t.Fatal("commit replaced invalid external edit")
|
||||
}
|
||||
if !bytes.Equal(readTestFile(t, path), invalid) {
|
||||
t.Fatal("invalid external edit was destroyed")
|
||||
}
|
||||
writeTestFile(t, path, original)
|
||||
restored, restoredRevision := loadTestStore(t, s)
|
||||
if restoredRevision != r || !reflect.DeepEqual(restored, domain.Clone(d)) {
|
||||
t.Fatal("corrected external file did not restore journal")
|
||||
}
|
||||
}
|
||||
func TestExternalSemanticErrorIncludesFileAndLine(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
d, _ := fixtureDataset(t)
|
||||
_, r := loadTestStore(t, s)
|
||||
commitTestStore(t, s, r, d)
|
||||
path := filepath.Join(s.dir, "journal", "2026", "2026-01.finance")
|
||||
raw := readTestFile(t, path)
|
||||
raw = bytes.Replace(raw, []byte(`"account_id":"acc_main"`), []byte(`"account_id":"acc_missing"`), 1)
|
||||
writeTestFile(t, path, raw)
|
||||
if _, _, err := s.Load(); err == nil || !strings.Contains(err.Error(), "journal/2026/2026-01.finance:1:") {
|
||||
t.Fatalf("missing transaction source location: %v", err)
|
||||
}
|
||||
}
|
||||
func TestStaleRevisionIncludesCommentOnlyExternalEdits(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
d, r := loadTestStore(t, s)
|
||||
path := filepath.Join(s.dir, "accounts.finance")
|
||||
raw := append([]byte("# user's independent edit\n"), readTestFile(t, path)...)
|
||||
writeTestFile(t, path, raw)
|
||||
d.Tags = append(d.Tags, domain.Tag{ID: "tag_new", Name: "New"})
|
||||
if _, err := s.Commit(r, d); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("stale commit: %v", err)
|
||||
}
|
||||
if !bytes.Equal(readTestFile(t, path), raw) {
|
||||
t.Fatal("external comment lost")
|
||||
}
|
||||
current, newRevision := loadTestStore(t, s)
|
||||
current.Tags = d.Tags
|
||||
r = commitTestStore(t, s, newRevision, current)
|
||||
if _, err := s.Commit(newRevision, current); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("stale app revision: %v", err)
|
||||
}
|
||||
if r == newRevision {
|
||||
t.Fatal("actual mutation did not advance revision")
|
||||
}
|
||||
}
|
||||
func TestFactsImmutableButRegistryAndEnrichmentRemainEditable(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
d, _ := fixtureDataset(t)
|
||||
_, r := loadTestStore(t, s)
|
||||
r = commitTestStore(t, s, r, d)
|
||||
d, r = loadTestStore(t, s)
|
||||
for _, which := range []string{"amount", "description", "remove"} {
|
||||
t.Run(which, func(t *testing.T) {
|
||||
next := domain.Clone(d)
|
||||
switch which {
|
||||
case "amount":
|
||||
next.Transactions[0].Facts.Amount = "-999.00"
|
||||
case "description":
|
||||
next.Transactions[0].Facts.RawDescription = "Edited"
|
||||
case "remove":
|
||||
next.Transactions = next.Transactions[1:]
|
||||
}
|
||||
if _, err := s.Commit(r, next); !errors.Is(err, ErrImmutable) {
|
||||
t.Fatalf("fact mutation accepted or wrong error: %v", err)
|
||||
}
|
||||
_, after := loadTestStore(t, s)
|
||||
if after != r {
|
||||
t.Fatal("rejected fact mutation changed revision")
|
||||
}
|
||||
})
|
||||
}
|
||||
next := domain.Clone(d)
|
||||
next.Accounts[0].DisplayName = "Renamed"
|
||||
next.Transactions[0].Enrichment.CategoryID = "cat_grocery"
|
||||
next.Transactions[0].Enrichment.Classification.Source = "manual"
|
||||
f := next.Transactions[0].Facts
|
||||
f.ID = "tx_february"
|
||||
f.Fingerprint = "fp_february"
|
||||
f.BookingDate = "2026-02-01"
|
||||
next.Transactions = append(next.Transactions, domain.Transaction{Facts: f, Enrichment: domain.Fallback(f)})
|
||||
commitTestStore(t, s, r, next)
|
||||
loaded, _ := loadTestStore(t, s)
|
||||
if !reflect.DeepEqual(loaded, domain.Clone(next)) {
|
||||
t.Fatal("permitted changes not persisted")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(s.dir, "journal", "2026", "2026-02.finance")); err != nil {
|
||||
t.Fatalf("missing deterministic monthly path: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// stageCrash writes exactly the durable intent and payload that survive power
|
||||
// loss, optionally installing a prefix of the targets before abandoning it.
|
||||
func stageCrash(t *testing.T, s *Store, next domain.Dataset, installed int) (map[string][]byte, map[string][]byte) {
|
||||
t.Helper()
|
||||
before, err := s.snapshot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output, err := renderFiles(next, before.docs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wal := filepath.Join(s.dir, walName)
|
||||
if err = os.Mkdir(wal, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := manifest{Version: 1, Revision: before.revision, Files: []walEntry{}}
|
||||
for i, path := range sortedPaths(output) {
|
||||
stage := fmt.Sprintf("%06d", i)
|
||||
old := ""
|
||||
if raw, ok := before.raw[path]; ok {
|
||||
old = hash(raw)
|
||||
}
|
||||
m.Files = append(m.Files, walEntry{Path: path, Before: old, After: hash(output[path]), Stage: stage})
|
||||
writeTestFile(t, filepath.Join(wal, stage), output[path])
|
||||
if i < installed {
|
||||
writeTestFile(t, filepath.Join(s.dir, path), output[path])
|
||||
}
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTestFile(t, filepath.Join(wal, "manifest.json"), raw)
|
||||
return before.raw, output
|
||||
}
|
||||
func TestRecoveryCompletesEveryInterruptedGeneration(t *testing.T) {
|
||||
for _, installed := range []int{0, 2, 100} {
|
||||
t.Run(fmt.Sprintf("installed_%d", installed), func(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
d, _ := fixtureDataset(t)
|
||||
_, output := stageCrash(t, s, d, installed)
|
||||
dir := s.dir
|
||||
if err := s.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reopened, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
loaded, r := loadTestStore(t, reopened)
|
||||
if r != revision(output) || !reflect.DeepEqual(loaded, domain.Clone(d)) {
|
||||
t.Fatal("recovered generation is incomplete")
|
||||
}
|
||||
for path, want := range output {
|
||||
if !bytes.Equal(readTestFile(t, filepath.Join(dir, path)), want) {
|
||||
t.Fatalf("recovery omitted %s", path)
|
||||
}
|
||||
}
|
||||
if _, err = os.Stat(filepath.Join(dir, walName)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("recovery intent not retired")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestRecoveryValidatesAllPayloadsBeforeChangingAnyFile(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
d, _ := fixtureDataset(t)
|
||||
before, _ := stageCrash(t, s, d, 0)
|
||||
writeTestFile(t, filepath.Join(s.dir, walName, "000004"), []byte("corrupt final payload"))
|
||||
if _, _, err := s.Load(); err == nil {
|
||||
t.Fatal("corrupt staged generation accepted")
|
||||
}
|
||||
for path, want := range before {
|
||||
if !bytes.Equal(readTestFile(t, filepath.Join(s.dir, path)), want) {
|
||||
t.Fatalf("partially installed corrupt generation at %s", path)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(s.dir, walName)); err != nil {
|
||||
t.Fatal("recovery evidence discarded")
|
||||
}
|
||||
}
|
||||
func TestRecoveryRefusesConflictingManualEdit(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
d, _ := fixtureDataset(t)
|
||||
stageCrash(t, s, d, 2)
|
||||
path := filepath.Join(s.dir, "accounts.finance")
|
||||
edit := append([]byte("# edit after crash\n"), readTestFile(t, path)...)
|
||||
writeTestFile(t, path, edit)
|
||||
if _, _, err := s.Load(); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("expected recovery conflict, got %v", err)
|
||||
}
|
||||
if !bytes.Equal(readTestFile(t, path), edit) {
|
||||
t.Fatal("recovery destroyed conflicting manual edit")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(s.dir, walName)); err != nil {
|
||||
t.Fatal("pending generation discarded")
|
||||
}
|
||||
}
|
||||
func TestLockPermissionsAndSymlinks(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
if other, err := Open(s.dir); err == nil {
|
||||
other.Close()
|
||||
t.Fatal("second process lock acquired")
|
||||
}
|
||||
for _, path := range []string{s.dir, filepath.Join(s.dir, ".lock"), filepath.Join(s.dir, "categories.finance")} {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := os.FileMode(0600)
|
||||
if info.IsDir() {
|
||||
want = 0700
|
||||
}
|
||||
if info.Mode().Perm() != want {
|
||||
t.Fatalf("%s mode %o, want %o", path, info.Mode().Perm(), want)
|
||||
}
|
||||
}
|
||||
target := filepath.Join(t.TempDir(), "private.finance")
|
||||
writeTestFile(t, target, []byte("do not overwrite"))
|
||||
path := filepath.Join(s.dir, "tags.finance")
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := s.Load(); err == nil {
|
||||
t.Fatal("followed registry symlink")
|
||||
}
|
||||
if string(readTestFile(t, target)) != "do not overwrite" {
|
||||
t.Fatal("symlink target modified")
|
||||
}
|
||||
if err := s.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := s.Load(); !errors.Is(err, ErrClosed) {
|
||||
t.Fatalf("closed load: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryRejectsSemanticallyInvalidStagedGeneration(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
d, _ := fixtureDataset(t)
|
||||
before, _ := stageCrash(t, s, d, 0)
|
||||
manifestPath := filepath.Join(s.dir, walName, "manifest.json")
|
||||
var m manifest
|
||||
if err := json.Unmarshal(readTestFile(t, manifestPath), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, e := range m.Files {
|
||||
if e.Path != "categories.finance" {
|
||||
continue
|
||||
}
|
||||
invalid := []byte("# required categories removed\n")
|
||||
writeTestFile(t, filepath.Join(s.dir, walName, e.Stage), invalid)
|
||||
m.Files[i].After = hash(invalid)
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTestFile(t, manifestPath, raw)
|
||||
if _, _, err = s.Load(); err == nil {
|
||||
t.Fatal("semantically invalid recovery generation accepted")
|
||||
}
|
||||
for path, want := range before {
|
||||
if !bytes.Equal(readTestFile(t, filepath.Join(s.dir, path)), want) {
|
||||
t.Fatalf("invalid recovery modified %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestPreparedButUncommittedGenerationRemainsInvisible(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
original, r := loadTestStore(t, s)
|
||||
d, _ := fixtureDataset(t)
|
||||
stageCrash(t, s, d, 0)
|
||||
if err := os.Rename(filepath.Join(s.dir, walName), filepath.Join(s.dir, ".prepare")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reopened, err := Open(s.dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
loaded, got := loadTestStore(t, reopened)
|
||||
if got != r || !reflect.DeepEqual(loaded, original) {
|
||||
t.Fatal("uncommitted staging became visible")
|
||||
}
|
||||
d.Tags = append(d.Tags, domain.Tag{ID: "tag_next", Name: "Next"})
|
||||
commitTestStore(t, reopened, r, d)
|
||||
}
|
||||
func TestUnexpectedMonthlyLayoutAndRegistryFilesAreNotIgnored(t *testing.T) {
|
||||
for _, path := range []string{"unexpected.finance", "journal/2026/2025-01.finance", "journal/2026/2026-13.finance"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
writeTestFile(t, filepath.Join(s.dir, path), []byte("# misplaced file\n"))
|
||||
if _, _, err := s.Load(); err == nil || !strings.Contains(err.Error(), path+":1:") {
|
||||
t.Fatalf("misplaced plaintext file was ignored: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestNullListsPreserveUntouchedExternalBlockBytes(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
raw := []byte("# deliberate hand formatting\nmerchant {\n id: \"mer_empty\"\n name: \"Empty defaults\"\n aliases: null\n default_tag_ids: null\n use_defaults: false\n}\n")
|
||||
path := filepath.Join(s.dir, "merchants.finance")
|
||||
writeTestFile(t, path, raw)
|
||||
d, r := loadTestStore(t, s)
|
||||
commitTestStore(t, s, r, d)
|
||||
if !bytes.Equal(raw, readTestFile(t, path)) {
|
||||
t.Fatal("loading empty lists rewrote untouched block")
|
||||
}
|
||||
d.Merchants[0].Name = "Renamed"
|
||||
commitTestStore(t, s, r, d)
|
||||
expected := bytes.Replace(raw, []byte(" name: \"Empty defaults\""), []byte(" name: \"Renamed\""), 1)
|
||||
if !bytes.Equal(expected, readTestFile(t, path)) {
|
||||
t.Fatal("renaming merchant rewrote unrelated fields or comments")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizedCommitCannotPublishUnreadableRecoveryIntent(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
original, r := loadTestStore(t, s)
|
||||
next := domain.Clone(original)
|
||||
next.Accounts = append(next.Accounts, domain.Account{ID: "acc_large", DisplayName: strings.Repeat("x", maxFileBytes), Currency: "EUR", Active: true})
|
||||
if _, err := s.Commit(r, next); err == nil {
|
||||
t.Fatal("oversized canonical file accepted")
|
||||
}
|
||||
loaded, got := loadTestStore(t, s)
|
||||
if got != r || !reflect.DeepEqual(loaded, original) {
|
||||
t.Fatal("oversized rejection changed canonical journal")
|
||||
}
|
||||
for _, name := range []string{walName, ".prepare"} {
|
||||
if _, err := os.Stat(filepath.Join(s.dir, name)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("oversized rejection left %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := s.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reopened, err := Open(s.dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
loaded, got = loadTestStore(t, reopened)
|
||||
if got != r || !reflect.DeepEqual(loaded, original) {
|
||||
t.Fatal("oversized rejection prevented clean reopen")
|
||||
}
|
||||
next = domain.Clone(original)
|
||||
next.Tags = append(next.Tags, domain.Tag{ID: "tag_after", Name: "After failed commit"})
|
||||
commitTestStore(t, reopened, r, next)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Fixture 01-basic-card
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {"id":"tx_fixture_01","source":"csv","account_id":"acc_main","booking_date":"2026-01-01","amount":"-12.34","currency":"EUR","raw_description":"Ordinary card payment","fingerprint":"fp_fixture_1"}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {"kind":"expense","tag_ids":[],"classification":{"source":"fallback"},"category_id":"cat_expenses_unclassified"}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Fixture 02-double-quotes
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {"id":"tx_fixture_02","source":"csv","account_id":"acc_main","booking_date":"2026-01-02","amount":"-12.34","currency":"EUR","raw_description":"Cafe \"Zur Sonne\"","fingerprint":"fp_fixture_2"}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {"kind":"expense","tag_ids":[],"classification":{"source":"fallback"},"category_id":"cat_expenses_unclassified"}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Fixture 03-backslashes
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {"id":"tx_fixture_03","source":"csv","account_id":"acc_main","booking_date":"2026-01-03","amount":"-12.34","currency":"EUR","raw_description":"Invoice C:\\archive\\2026","fingerprint":"fp_fixture_3"}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {"kind":"expense","tag_ids":[],"classification":{"source":"fallback"},"category_id":"cat_expenses_unclassified"}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Fixture 04-multiline
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {
|
||||
"id": "tx_fixture_04",
|
||||
"source": "csv",
|
||||
"account_id": "acc_main",
|
||||
"booking_date": "2026-01-04",
|
||||
"amount": "-12.34",
|
||||
"currency": "EUR",
|
||||
"raw_description": "First line\nSecond line\nThird line",
|
||||
"fingerprint": "fp_fixture_4"
|
||||
}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {
|
||||
"kind": "expense",
|
||||
"tag_ids": [],
|
||||
"classification": {
|
||||
"source": "fallback"
|
||||
},
|
||||
"category_id": "cat_expenses_unclassified"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Fixture 05-unicode
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {"id":"tx_fixture_05","source":"csv","account_id":"acc_main","booking_date":"2026-01-05","amount":"-12.34","currency":"EUR","raw_description":"Bäckerei 東京 — café","fingerprint":"fp_fixture_5"}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {"kind":"expense","tag_ids":[],"classification":{"source":"fallback"},"category_id":"cat_expenses_unclassified"}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Fixture 06-comment-markers
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {"id":"tx_fixture_06","source":"csv","account_id":"acc_main","booking_date":"2026-01-06","amount":"-12.34","currency":"EUR","raw_description":"# not a comment // neither is this","fingerprint":"fp_fixture_6"}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {"kind":"expense","tag_ids":[],"classification":{"source":"fallback"},"category_id":"cat_expenses_unclassified"}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
# Fixture 07-braces
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {"id":"tx_fixture_07","source":"csv","account_id":"acc_main","booking_date":"2026-01-07","amount":"-12.34","currency":"EUR","raw_description":"Payment {reference}: [123]","fingerprint":"fp_fixture_7"}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {"kind":"expense","tag_ids":[],"classification":{"source":"fallback"},"category_id":"cat_expenses_unclassified"}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Fixture 08-tabs
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {
|
||||
"id": "tx_fixture_08",
|
||||
"source": "csv",
|
||||
"account_id": "acc_main",
|
||||
"booking_date": "2026-01-08",
|
||||
"amount": "-12.34",
|
||||
"currency": "EUR",
|
||||
"raw_description": "Terminal\tA\tReceipt",
|
||||
"fingerprint": "fp_fixture_8"
|
||||
}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {
|
||||
"kind": "expense",
|
||||
"tag_ids": [],
|
||||
"classification": {
|
||||
"source": "fallback"
|
||||
},
|
||||
"category_id": "cat_expenses_unclassified"
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
# Fixture 09-income
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {"id":"tx_fixture_09","source":"csv","account_id":"acc_main","booking_date":"2026-01-09","amount":"3456.78","currency":"EUR","raw_description":"Salary January","fingerprint":"fp_fixture_9"}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {"kind":"income","tag_ids":[],"classification":{"source":"fallback"},"category_id":"cat_income_unclassified"}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
# Fixture 10-refund
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {"id":"tx_fixture_10","source":"csv","account_id":"acc_main","booking_date":"2026-01-10","amount":"18.42","currency":"EUR","raw_description":"Merchant refund","fingerprint":"fp_fixture_10"}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {"kind":"income","tag_ids":[],"classification":{"source":"fallback"},"category_id":"cat_income_unclassified"}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
# Fixture 11-zero
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {"id":"tx_fixture_11","source":"csv","account_id":"acc_main","booking_date":"2026-01-11","amount":"0.00","currency":"EUR","raw_description":"Zero-value bank notification","fingerprint":"fp_fixture_11"}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {"kind":"expense","tag_ids":[],"classification":{"source":"fallback"},"category_id":"cat_expenses_unclassified"}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Fixture 12-four-decimals
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {"id":"tx_fixture_12","source":"csv","account_id":"acc_main","booking_date":"2026-01-12","amount":"-0.0001","currency":"EUR","raw_description":"Interest adjustment","fingerprint":"fp_fixture_12"}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {"kind":"expense","tag_ids":[],"classification":{"source":"fallback"},"category_id":"cat_expenses_unclassified"}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Fixture 13-large-exact
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {"id":"tx_fixture_13","source":"csv","account_id":"acc_main","booking_date":"2026-01-13","amount":"-922337203685477.5808","currency":"EUR","raw_description":"Large exact debit","fingerprint":"fp_fixture_13"}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {"kind":"expense","tag_ids":[],"classification":{"source":"fallback"},"category_id":"cat_expenses_unclassified"}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
# Fixture 14-usd
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {"id":"tx_fixture_14","source":"csv","account_id":"acc_usd","booking_date":"2026-01-14","amount":"-21.2345","currency":"USD","raw_description":"USD purchase","fingerprint":"fp_fixture_14"}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {"kind":"expense","tag_ids":[],"classification":{"source":"fallback"},"category_id":"cat_expenses_unclassified"}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
# Fixture 15-gbp
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {"id":"tx_fixture_15","source":"csv","account_id":"acc_gbp","booking_date":"2026-01-15","amount":"-9.99","currency":"GBP","raw_description":"GBP purchase","fingerprint":"fp_fixture_15"}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {"kind":"expense","tag_ids":[],"classification":{"source":"fallback"},"category_id":"cat_expenses_unclassified"}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Fixture 16-duplicate-one
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {"id":"tx_fixture_16","source":"csv","account_id":"acc_main","booking_date":"2026-01-16","amount":"-4.50","currency":"EUR","raw_description":"Identical legitimate payment","fingerprint":"duplicate-payment"}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {"kind":"expense","tag_ids":[],"classification":{"source":"fallback"},"category_id":"cat_expenses_unclassified"}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Fixture 17-duplicate-two
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {"id":"tx_fixture_17","source":"csv","account_id":"acc_main","booking_date":"2026-01-16","amount":"-4.50","currency":"EUR","raw_description":"Identical legitimate payment","fingerprint":"duplicate-payment"}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {"kind":"expense","tag_ids":[],"classification":{"source":"fallback"},"category_id":"cat_expenses_unclassified"}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Fixture 18-upstream-id
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {"id":"tx_fixture_18","source":"enable-banking","account_id":"acc_main","booking_date":"2026-01-18","amount":"-12.34","currency":"EUR","raw_description":"Provider-backed transfer reference","fingerprint":"fp_fixture_18","external_id":"provider:stable/123"}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {"kind":"expense","tag_ids":[],"classification":{"source":"fallback"},"category_id":"cat_expenses_unclassified"}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Fixture 19-counterparty
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {"id":"tx_fixture_19","source":"csv","account_id":"acc_main","booking_date":"2026-01-19","amount":"-12.34","currency":"EUR","raw_description":"SEPA direct debit","fingerprint":"fp_fixture_19","counterparty":"Example & Sons","counterparty_iban":"DE89370400440532013000"}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {"kind":"expense","tag_ids":[],"classification":{"source":"fallback"},"category_id":"cat_expenses_unclassified"}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Fixture 20-transfer-out
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {"id":"tx_fixture_20","source":"csv","account_id":"acc_main","booking_date":"2026-01-20","amount":"-250.00","currency":"EUR","raw_description":"Savings transfer","fingerprint":"fp_fixture_20"}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {"kind":"transfer","tag_ids":[],"classification":{"source":"transfer-match"},"transfer_peer_id":"tx_fixture_21"}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Fixture 21-transfer-in
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {"id":"tx_fixture_21","source":"csv","account_id":"acc_save","booking_date":"2026-01-21","amount":"250.00","currency":"EUR","raw_description":"Savings transfer","fingerprint":"fp_fixture_21"}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {"kind":"transfer","tag_ids":[],"classification":{"source":"transfer-match"},"transfer_peer_id":"tx_fixture_20"}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Fixture 22-ai-metadata
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {"id":"tx_fixture_22","source":"csv","account_id":"acc_main","booking_date":"2026-01-22","amount":"-12.34","currency":"EUR","raw_description":"Classified grocery","fingerprint":"fp_fixture_22"}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {"kind":"expense","tag_ids":["tag_food","tag_recurring"],"classification":{"source":"ai","model":"gpt-4.1-mini","timestamp":"2026-01-22T12:34:56.123Z"},"category_id":"cat_grocery","merchant_id":"mer_cafe"}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Fixture 23-manual-metadata
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {"id":"tx_fixture_23","source":"csv","account_id":"acc_main","booking_date":"2026-01-23","amount":"-12.34","currency":"EUR","raw_description":"Human reviewed purchase","fingerprint":"fp_fixture_23"}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {"kind":"expense","tag_ids":["tag_food","tag_recurring"],"classification":{"source":"manual","timestamp":"2026-01-23T16:00:00+01:00"},"category_id":"cat_grocery","merchant_id":"mer_cafe"}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Fixture 24-failed-enrichment
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {"id":"tx_fixture_24","source":"csv","account_id":"acc_main","booking_date":"2026-01-24","amount":"-12.34","currency":"EUR","raw_description":"Retained fallback after failure","fingerprint":"fp_fixture_24"}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {"kind":"expense","tag_ids":[],"classification":{"source":"fallback","error":"Provider unavailable: \"timeout\"\nRetry later"},"category_id":"cat_expenses_unclassified"}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Fixture 25-value-date-and-comments
|
||||
transaction {
|
||||
# Bank facts: preserve these bytes when enrichment changes.
|
||||
facts: {
|
||||
"id": "tx_fixture_25",
|
||||
"source": "csv",
|
||||
"account_id": "acc_main",
|
||||
"booking_date": "2026-01-25",
|
||||
"amount": "-12.34",
|
||||
"currency": "EUR",
|
||||
"raw_description": "Booked after settlement",
|
||||
"fingerprint": "fp_fixture_25",
|
||||
"value_date": "2025-12-31"
|
||||
}
|
||||
// Editable classification metadata follows.
|
||||
enrichment: {
|
||||
"kind": "expense",
|
||||
"tag_ids": [
|
||||
"tag_food",
|
||||
"tag_recurring"
|
||||
],
|
||||
"classification": {
|
||||
"source": "merchant-defaults"
|
||||
},
|
||||
"category_id": "cat_grocery",
|
||||
"merchant_id": "mer_cafe"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user