// Package state records which Multicase vouchers have been sent, so a // re-run of the same or an overlapping export does not book them twice. // Accounted's idempotency keys expire after 24 hours; this file does not. package state import ( "encoding/json" "errors" "io/fs" "os" "path/filepath" "time" ) type Record struct { CompanyID string `json:"company_id"` EntryID string `json:"entry_id"` Status string `json:"status"` // "draft" or "posted" VoucherSeries string `json:"voucher_series,omitempty"` VoucherNumber int `json:"voucher_number,omitempty"` Hash string `json:"hash"` UpdatedAt time.Time `json:"updated_at"` } type File struct { path string Entries map[string]Record `json:"entries"` } func Load(path string) (*File, error) { f := &File{path: path, Entries: map[string]Record{}} b, err := os.ReadFile(path) if errors.Is(err, fs.ErrNotExist) { return f, nil } if err != nil { return nil, err } if err := json.Unmarshal(b, f); err != nil { return nil, err } if f.Entries == nil { f.Entries = map[string]Record{} } return f, nil } func key(companyID, sourceID string) string { return companyID + "/" + sourceID } func (f *File) Get(companyID, sourceID string) (Record, bool) { r, ok := f.Entries[key(companyID, sourceID)] return r, ok } // Put stores the record and writes the file, so progress survives a crash // halfway through a run. func (f *File) Put(sourceID string, r Record) error { r.UpdatedAt = time.Now().UTC() f.Entries[key(r.CompanyID, sourceID)] = r b, err := json.MarshalIndent(f, "", " ") if err != nil { return err } tmp, err := os.CreateTemp(filepath.Dir(f.path), ".state-*") if err != nil { return err } defer os.Remove(tmp.Name()) if _, err := tmp.Write(b); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } return os.Rename(tmp.Name(), f.path) }