// 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]) }