aboutsummaryrefslogtreecommitdiffstats
path: root/main.go
diff options
context:
space:
mode:
authorSimen A. W. Olsen <hello@simenandre.no>2026-10-27 17:19:57 +0100
committerSimen A. W. Olsen <hello@simenandre.no>2026-10-27 17:19:57 +0100
commit0571b30b6da27dc4c9e617403deab09b974ac641 (patch)
treef3dfab18e22a48d06c64e563ba9bcf478c01a5bc /main.go
downloadaccounted-multicase-intgr-main.tar.gz
accounted-multicase-intgr-main.zip
feat: send Multicase buntfil vouchers to AccountedHEADmain
Diffstat (limited to 'main.go')
-rw-r--r--main.go344
1 files changed, 344 insertions, 0 deletions
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..29fd139
--- /dev/null
+++ b/main.go
@@ -0,0 +1,344 @@
+// Command accounted-multicase-intgr sends vouchers from a Multicase buntfil export
+// to Accounted through its v1 REST API.
+package main
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "os"
+ "os/signal"
+ "sort"
+ "strings"
+ "text/tabwriter"
+
+ "simenandre.no/accounted-multicase-intgr/internal/accounted"
+ "simenandre.no/accounted-multicase-intgr/internal/mapping"
+ "simenandre.no/accounted-multicase-intgr/internal/multicase"
+ "simenandre.no/accounted-multicase-intgr/internal/state"
+ "simenandre.no/accounted-multicase-intgr/internal/xls"
+)
+
+const usage = `Usage:
+ accounted-multicase-intgr check [-config config.json] [-v] EXPORT.xls
+ accounted-multicase-intgr push [-config config.json] [-dry-run] [-commit] [-state FILE] EXPORT.xls
+
+check parses the export and maps every voucher, without network access.
+push creates the vouchers as drafts in Accounted; -commit also posts them
+ and assigns voucher numbers; -dry-run only lets the server validate.
+
+push reads ACCOUNTED_API_KEY (gnubok_sk_..., scope bookkeeping:write),
+ACCOUNTED_COMPANY_ID and optionally ACCOUNTED_URL (default https://app.accounted.se).
+`
+
+func main() {
+ if len(os.Args) < 2 {
+ fmt.Fprint(os.Stderr, usage)
+ os.Exit(2)
+ }
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
+ defer stop()
+
+ var err error
+ switch os.Args[1] {
+ case "check":
+ err = runCheck(os.Args[2:])
+ case "push":
+ err = runPush(ctx, os.Args[2:])
+ case "-h", "--help", "help":
+ fmt.Print(usage)
+ return
+ default:
+ fmt.Fprint(os.Stderr, usage)
+ os.Exit(2)
+ }
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "error:", err)
+ os.Exit(1)
+ }
+}
+
+type mapped struct {
+ voucher multicase.Voucher
+ entry accounted.JournalEntry
+ err error
+}
+
+func load(configPath, exportPath string) (*mapping.Config, []mapped, error) {
+ cfg, err := mapping.LoadConfig(configPath)
+ if err != nil {
+ return nil, nil, err
+ }
+ grid, err := xls.ReadFile(exportPath)
+ if err != nil {
+ return nil, nil, err
+ }
+ vouchers, err := multicase.Parse(grid)
+ if err != nil {
+ return nil, nil, err
+ }
+ out := make([]mapped, 0, len(vouchers))
+ for _, v := range vouchers {
+ e, err := cfg.Build(v)
+ out = append(out, mapped{v, e, err})
+ }
+ return cfg, out, nil
+}
+
+func parseFlags(name string, args []string, define func(*flag.FlagSet)) (*flag.FlagSet, string, error) {
+ fs := flag.NewFlagSet(name, flag.ContinueOnError)
+ fs.Usage = func() { fmt.Fprint(os.Stderr, usage) }
+ define(fs)
+ if err := fs.Parse(args); err != nil {
+ return nil, "", err
+ }
+ if fs.NArg() != 1 {
+ return nil, "", errors.New("expected exactly one export file")
+ }
+ return fs, fs.Arg(0), nil
+}
+
+func runCheck(args []string) error {
+ var configPath string
+ var verbose bool
+ _, export, err := parseFlags("check", args, func(fs *flag.FlagSet) {
+ fs.StringVar(&configPath, "config", "config.json", "mapping config")
+ fs.BoolVar(&verbose, "v", false, "print every mapped entry")
+ })
+ if err != nil {
+ return err
+ }
+ _, items, err := load(configPath, export)
+ if err != nil {
+ return err
+ }
+
+ unmappedAccounts := map[string]int{}
+ unmappedVAT := map[string]int{}
+ var ok, empty, failed int
+ var debit accounted.Money
+ for _, m := range items {
+ var u *mapping.UnmappedError
+ switch {
+ case errors.Is(m.err, mapping.ErrEmpty):
+ empty++
+ case errors.As(m.err, &u):
+ failed++
+ for _, a := range u.Accounts {
+ unmappedAccounts[a]++
+ }
+ for _, c := range u.VATCodes {
+ unmappedVAT[c]++
+ }
+ case m.err != nil:
+ failed++
+ fmt.Printf("%s: %v\n", m.voucher.Label(), m.err)
+ default:
+ ok++
+ for _, l := range m.entry.Lines {
+ debit += l.Debit
+ }
+ if verbose {
+ printEntry(m.entry)
+ }
+ }
+ }
+ printCounts("Unmapped accounts (add them to \"accounts\")", unmappedAccounts)
+ printCounts("Unknown VAT codes (add them to \"vat_codes\")", unmappedVAT)
+ fmt.Printf("\n%d vouchers ready (%s debit total), %d empty, %d with errors\n", ok, debit, empty, failed)
+ if failed > 0 {
+ return fmt.Errorf("%d vouchers cannot be sent", failed)
+ }
+ return nil
+}
+
+func printCounts(title string, counts map[string]int) {
+ if len(counts) == 0 {
+ return
+ }
+ keys := make([]string, 0, len(counts))
+ for k := range counts {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ fmt.Printf("\n%s:\n", title)
+ for _, k := range keys {
+ fmt.Printf(" %-8s used by %d vouchers\n", k, counts[k])
+ }
+}
+
+func printEntry(e accounted.JournalEntry) {
+ fmt.Printf("\n%s %s [%s]\n", e.EntryDate, e.Description, e.SourceID)
+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', tabwriter.AlignRight)
+ for _, l := range e.Lines {
+ d, c := "", ""
+ if l.Debit != 0 {
+ d = l.Debit.String()
+ }
+ if l.Credit != 0 {
+ c = l.Credit.String()
+ }
+ fmt.Fprintf(w, " %s\t%s\t%s\t %s\t\n", l.Account, d, c, l.Description)
+ }
+ w.Flush()
+}
+
+func runPush(ctx context.Context, args []string) error {
+ var configPath, statePath string
+ var dryRun, commit bool
+ _, export, err := parseFlags("push", args, func(fs *flag.FlagSet) {
+ fs.StringVar(&configPath, "config", "config.json", "mapping config")
+ fs.StringVar(&statePath, "state", "multicase-state.json", "file recording what has been sent")
+ fs.BoolVar(&dryRun, "dry-run", false, "let Accounted validate every voucher without saving anything")
+ fs.BoolVar(&commit, "commit", false, "post the drafts and assign voucher numbers")
+ })
+ if err != nil {
+ return err
+ }
+ if dryRun && commit {
+ return errors.New("-dry-run and -commit cannot be combined")
+ }
+ apiKey, companyID := os.Getenv("ACCOUNTED_API_KEY"), os.Getenv("ACCOUNTED_COMPANY_ID")
+ if apiKey == "" || companyID == "" {
+ return errors.New("ACCOUNTED_API_KEY and ACCOUNTED_COMPANY_ID must be set")
+ }
+ baseURL := os.Getenv("ACCOUNTED_URL")
+ if baseURL == "" {
+ baseURL = "https://app.accounted.se"
+ }
+
+ _, items, err := load(configPath, export)
+ if err != nil {
+ return err
+ }
+ var entries []mapped
+ var problems []string
+ for _, m := range items {
+ switch {
+ case errors.Is(m.err, mapping.ErrEmpty):
+ case m.err != nil:
+ problems = append(problems, fmt.Sprintf("%s: %v", m.voucher.Label(), m.err))
+ default:
+ entries = append(entries, m)
+ }
+ }
+ if len(problems) > 0 {
+ return fmt.Errorf("nothing sent, fix the mapping first (run check):\n %s", strings.Join(problems, "\n "))
+ }
+
+ client := accounted.NewClient(baseURL, apiKey, companyID)
+ periods, err := client.FiscalPeriods(ctx)
+ if err != nil {
+ return fmt.Errorf("list fiscal periods: %w", err)
+ }
+ for i := range entries {
+ p, err := periodFor(periods, entries[i].entry.EntryDate)
+ if err != nil {
+ problems = append(problems, fmt.Sprintf("%s: %v", entries[i].voucher.Label(), err))
+ continue
+ }
+ entries[i].entry.FiscalPeriodID = p.ID
+ }
+ if len(problems) > 0 {
+ return fmt.Errorf("nothing sent:\n %s", strings.Join(problems, "\n "))
+ }
+
+ st, err := state.Load(statePath)
+ if err != nil {
+ return fmt.Errorf("load %s: %w", statePath, err)
+ }
+
+ var created, posted, skipped, warned int
+ for _, m := range entries {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+ e := m.entry
+ label := m.voucher.Label()
+ hash := hashEntry(e)
+ rec, seen := st.Get(companyID, e.SourceID)
+
+ if seen && rec.Hash != hash {
+ fmt.Printf("WARN %s changed in Multicase since it was sent as %s; correct it in Accounted\n", label, describe(rec))
+ warned++
+ continue
+ }
+ if seen && (rec.Status == "posted" || !commit) {
+ skipped++
+ continue
+ }
+
+ if !seen {
+ id, err := client.CreateDraft(ctx, e, idempotencyKey(companyID, e.SourceID, hash, "create"), dryRun)
+ if err != nil {
+ return fmt.Errorf("%s: %w", label, err)
+ }
+ if dryRun {
+ fmt.Printf("OK %s validated\n", label)
+ created++
+ continue
+ }
+ rec = state.Record{CompanyID: companyID, EntryID: id, Status: "draft", Hash: hash}
+ if err := st.Put(e.SourceID, rec); err != nil {
+ return err
+ }
+ created++
+ if !commit {
+ fmt.Printf("DRAFT %s -> %s\n", label, id)
+ continue
+ }
+ }
+
+ c, err := client.Commit(ctx, rec.EntryID, idempotencyKey(companyID, rec.EntryID, hash, "commit"))
+ if err != nil {
+ return fmt.Errorf("%s: commit %s: %w", label, rec.EntryID, err)
+ }
+ rec.Status, rec.VoucherSeries, rec.VoucherNumber = "posted", c.VoucherSeries, c.VoucherNumber
+ if err := st.Put(e.SourceID, rec); err != nil {
+ return err
+ }
+ posted++
+ fmt.Printf("POST %s -> %s%d\n", label, c.VoucherSeries, c.VoucherNumber)
+ }
+
+ verb := "drafts created"
+ if dryRun {
+ verb = "validated"
+ }
+ fmt.Printf("\n%d %s, %d posted, %d already sent, %d changed since sent\n", created, verb, posted, skipped, warned)
+ return nil
+}
+
+func periodFor(periods []accounted.FiscalPeriod, date string) (accounted.FiscalPeriod, error) {
+ for _, p := range periods {
+ if p.PeriodStart <= date && date <= p.PeriodEnd {
+ if p.IsClosed || p.LockedAt != nil {
+ return p, fmt.Errorf("fiscal period %s is closed or locked", p.Name)
+ }
+ return p, nil
+ }
+ }
+ return accounted.FiscalPeriod{}, fmt.Errorf("no fiscal period covers %s", date)
+}
+
+func describe(r state.Record) string {
+ if r.Status == "posted" {
+ return fmt.Sprintf("voucher %s%d", r.VoucherSeries, r.VoucherNumber)
+ }
+ return "draft " + r.EntryID
+}
+
+func hashEntry(e accounted.JournalEntry) string {
+ b, _ := json.Marshal(e)
+ sum := sha256.Sum256(b)
+ return hex.EncodeToString(sum[:])
+}
+
+func idempotencyKey(parts ...string) string {
+ sum := sha256.Sum256([]byte(strings.Join(parts, "\x00")))
+ return "multicase-" + hex.EncodeToString(sum[:16])
+}