diff options
| author | Simen A. W. Olsen <hello@simenandre.no> | 2026-10-27 17:19:57 +0100 |
|---|---|---|
| committer | Simen A. W. Olsen <hello@simenandre.no> | 2026-10-27 17:19:57 +0100 |
| commit | 0571b30b6da27dc4c9e617403deab09b974ac641 (patch) | |
| tree | f3dfab18e22a48d06c64e563ba9bcf478c01a5bc /main.go | |
| download | accounted-multicase-intgr-0571b30b6da27dc4c9e617403deab09b974ac641.tar.gz accounted-multicase-intgr-0571b30b6da27dc4c9e617403deab09b974ac641.zip | |
Diffstat (limited to 'main.go')
| -rw-r--r-- | main.go | 344 |
1 files changed, 344 insertions, 0 deletions
@@ -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]) +} |
