aboutsummaryrefslogtreecommitdiffstats
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
downloadaccounted-multicase-intgr-0571b30b6da27dc4c9e617403deab09b974ac641.tar.gz
accounted-multicase-intgr-0571b30b6da27dc4c9e617403deab09b974ac641.zip
feat: send Multicase buntfil vouchers to AccountedHEADmain
-rw-r--r--.gitignore4
-rw-r--r--README.md82
-rw-r--r--config.example.json32
-rw-r--r--go.mod10
-rw-r--r--go.sum6
-rw-r--r--internal/accounted/accounted.go262
-rw-r--r--internal/accounted/accounted_test.go65
-rw-r--r--internal/mapping/mapping.go248
-rw-r--r--internal/mapping/mapping_test.go118
-rw-r--r--internal/multicase/multicase.go227
-rw-r--r--internal/multicase/multicase_test.go68
-rw-r--r--internal/state/state.go79
-rw-r--r--internal/xls/xls.go364
-rw-r--r--internal/xls/xls_test.go99
-rw-r--r--main.go344
15 files changed, 2008 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ae7f506
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+/accounted-multicase-intgr
+config.json
+multicase-state.json
+*.xls
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1311a9d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,82 @@
+# accounted-multicase-intgr
+
+Example integration that sends vouchers (bilag) from a [Multicase](https://www.multicase.no)
+"buntfil" export to [Accounted](https://accounted.se) through its v1 REST API.
+
+It reads the `.xls` export, groups the posting lines into vouchers, maps them to
+Accounted journal entries and creates them as drafts, optionally posting them.
+
+## Install
+
+```sh
+go install simenandre.no/accounted-multicase-intgr@latest
+```
+
+## Usage
+
+```sh
+cp config.example.json config.json # then adjust the mapping
+
+# Parse and map every voucher, no network access
+accounted-multicase-intgr check "MC Buntfil 01102026-12102026.xls"
+accounted-multicase-intgr check -v "MC Buntfil 01102026-12102026.xls" # print each entry
+
+export ACCOUNTED_API_KEY=gnubok_sk_... # scope bookkeeping:write
+export ACCOUNTED_COMPANY_ID=<company uuid>
+# export ACCOUNTED_URL=https://app.accounted.se
+
+accounted-multicase-intgr push -dry-run export.xls # server validates, nothing is saved
+accounted-multicase-intgr push export.xls # create drafts
+accounted-multicase-intgr push -commit export.xls # create and post, assigning voucher numbers
+```
+
+`push` validates everything before it writes anything: if any voucher has an
+unmapped account or VAT code, or a date outside an open fiscal period, nothing
+is sent.
+
+## How vouchers are mapped
+
+- **Grouping.** Lines are grouped by `BilagsArt` and `Bilagsnummer`. Inventory
+ corrections (Lagerkorrigering) carry Bilagsnummer 0 and are grouped per
+ batch (`BuntHodeID`) instead.
+- **Amounts.** `Belop` is used, in the Multicase company's base currency.
+ Positive is debit, negative is credit. Amounts are rounded to two decimals;
+ Multicase writes currency conversions with three, so a voucher can end up a
+ few öre off. That difference, up to `max_rounding_ore`, is posted to
+ `rounding_account`. Zero-amount lines are dropped.
+- **Accounts.** A line whose `Konto` equals the customer number (`Faktura
+ kundenr`) or supplier number (`LevNr.`) is a subledger line and is posted to
+ `customer_ledger_account` / `supplier_ledger_account`. Every other account
+ must be listed in `accounts`.
+- **VAT.** Multicase posts revenue and cost lines gross with an MVA code and
+ leaves the split to the receiving ledger. For codes with a `rate`, the net
+ amount stays on the mapped account and the VAT goes to the code's `account`.
+ Codes with rate 0 pass through untouched.
+- **Dimensions.** `AvdNr.` and `Prosjekt` are sent as the SIE dimensions named
+ in `dimensions` (by default 1, kostnadsställe, and 6, projekt).
+
+`config.example.json` maps the accounts found in one Print Supplies AS export
+to BAS 2026 accounts. It is a starting point only: have the mapping, and in
+particular the VAT codes, checked by whoever keeps the books.
+
+## Not booking twice
+
+Each voucher is sent with `source_id` `multicase:<BilagsArt>:<Bilagsnummer>`
+and a deterministic `Idempotency-Key`, so retries after a timeout are replayed
+by the server rather than repeated. Accounted keeps idempotency keys for 24
+hours, so `push` also records every draft and posted voucher in
+`multicase-state.json`. Re-running an overlapping export skips what was sent.
+
+A voucher that changed in Multicase after it was sent is reported and skipped.
+Posted vouchers cannot be edited; correct them in Accounted (storno or rättelse).
+
+## Limitations
+
+- Accounted keeps its ledger in SEK. Amounts are sent as they appear in
+ Multicase, so the export's base currency must match the Accounted company's.
+- Only the first worksheet of the export is read, and only Excel 97-2003
+ (`.xls`) files are supported. The bundled BIFF8 reader exists because the
+ available Go libraries decode negative whole-number cells as large positive
+ numbers.
+- Requests are throttled to stay under the default limit of 100 requests per
+ minute per API key; 429 and 5xx responses are retried.
diff --git a/config.example.json b/config.example.json
new file mode 100644
index 0000000..9eee1f4
--- /dev/null
+++ b/config.example.json
@@ -0,0 +1,32 @@
+{
+ "accounts": {
+ "1460": "1460",
+ "1501": "1580",
+ "1502": "1580",
+ "3000": "3001",
+ "3010": "3001",
+ "3025": "3041",
+ "3040": "3001",
+ "3220": "3004",
+ "3610": "3911",
+ "4300": "4000",
+ "4301": "4000",
+ "4302": "4000",
+ "4310": "4000",
+ "4361": "4000",
+ "4390": "4730",
+ "7799": "3740"
+ },
+ "customer_ledger_account": "1510",
+ "supplier_ledger_account": "2440",
+ "vat_codes": {
+ "1": { "rate": 25, "account": "2641" },
+ "3": { "rate": 25, "account": "2611" },
+ "6": { "rate": 0 }
+ },
+ "rounding_account": "3740",
+ "max_rounding_ore": 100,
+ "source_type": "manual",
+ "voucher_series": {},
+ "dimensions": { "department": "1", "project": "6" }
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..5053634
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,10 @@
+module simenandre.no/accounted-multicase-intgr
+
+go 1.26.9
+
+require (
+ github.com/richardlehane/mscfb v1.0.9
+ golang.org/x/text v0.43.0
+)
+
+require github.com/richardlehane/msoleps v1.0.3 // indirect
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..f02a999
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,6 @@
+github.com/richardlehane/mscfb v1.0.9 h1:8xdd9auUvXbFoCw3L9h1spnQHZgjNsSX+ek46J6A9tE=
+github.com/richardlehane/mscfb v1.0.9/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
+github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM=
+github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
+golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
+golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
diff --git a/internal/accounted/accounted.go b/internal/accounted/accounted.go
new file mode 100644
index 0000000..45508c9
--- /dev/null
+++ b/internal/accounted/accounted.go
@@ -0,0 +1,262 @@
+// Package accounted is a small client for the Accounted v1 REST API,
+// covering what is needed to book vouchers: fiscal periods, draft journal
+// entries and commit.
+package accounted
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Money is an amount in öre. It marshals as a JSON number with two decimals
+// so no float rounding happens on the way out.
+type Money int64
+
+func (m Money) String() string {
+ sign := ""
+ if m < 0 {
+ sign, m = "-", -m
+ }
+ return fmt.Sprintf("%s%d.%02d", sign, m/100, m%100)
+}
+
+func (m Money) MarshalJSON() ([]byte, error) {
+ return []byte(m.String()), nil
+}
+
+type Line struct {
+ Account string `json:"account_number"`
+ Debit Money `json:"debit_amount"`
+ Credit Money `json:"credit_amount"`
+ Description string `json:"line_description,omitempty"`
+ TaxCode string `json:"tax_code,omitempty"`
+ Dimensions map[string]string `json:"dimensions,omitempty"`
+}
+
+type JournalEntry struct {
+ FiscalPeriodID string `json:"fiscal_period_id"`
+ EntryDate string `json:"entry_date"`
+ Description string `json:"description"`
+ SourceType string `json:"source_type"`
+ SourceID string `json:"source_id"`
+ VoucherSeries string `json:"voucher_series,omitempty"`
+ Notes string `json:"notes,omitempty"`
+ Lines []Line `json:"lines"`
+}
+
+type FiscalPeriod struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ PeriodStart string `json:"period_start"`
+ PeriodEnd string `json:"period_end"`
+ IsClosed bool `json:"is_closed"`
+ LockedAt *string `json:"locked_at"`
+}
+
+type Committed struct {
+ ID string `json:"id"`
+ VoucherSeries string `json:"voucher_series"`
+ VoucherNumber int `json:"voucher_number"`
+ Status string `json:"status"`
+}
+
+// APIError is the v1 error envelope.
+type APIError struct {
+ Status int `json:"-"`
+ Code string `json:"code"`
+ Message string `json:"message_en"`
+ MessageSV string `json:"message"`
+ Hint string `json:"recovery_hint"`
+ Details json.RawMessage `json:"details"`
+ RequestID string `json:"request_id"`
+}
+
+func (e *APIError) Error() string {
+ msg := e.Message
+ if msg == "" {
+ msg = e.MessageSV
+ }
+ s := fmt.Sprintf("%s (HTTP %d): %s", e.Code, e.Status, msg)
+ if len(e.Details) > 0 && string(e.Details) != "null" {
+ s += " " + string(e.Details)
+ }
+ if e.RequestID != "" {
+ s += " [" + e.RequestID + "]"
+ }
+ return s
+}
+
+type Client struct {
+ BaseURL string
+ APIKey string
+ CompanyID string
+ HTTP *http.Client
+ // Interval is the minimum time between requests. The default API key
+ // limit is 100 requests per minute.
+ Interval time.Duration
+ MaxRetries int
+
+ last time.Time
+}
+
+func NewClient(baseURL, apiKey, companyID string) *Client {
+ return &Client{
+ BaseURL: strings.TrimRight(baseURL, "/"),
+ APIKey: apiKey,
+ CompanyID: companyID,
+ HTTP: &http.Client{Timeout: 60 * time.Second},
+ Interval: 700 * time.Millisecond,
+ MaxRetries: 5,
+ }
+}
+
+func (c *Client) FiscalPeriods(ctx context.Context) ([]FiscalPeriod, error) {
+ var out struct {
+ FiscalPeriods []FiscalPeriod `json:"fiscal_periods"`
+ }
+ err := c.do(ctx, http.MethodGet, c.companyPath("fiscal-periods"), nil, "", false, &out)
+ return out.FiscalPeriods, err
+}
+
+// CreateDraft creates a draft journal entry and returns its id. In dry-run
+// mode the server validates the entry and nothing is stored; the id is empty.
+func (c *Client) CreateDraft(ctx context.Context, e JournalEntry, idempotencyKey string, dryRun bool) (string, error) {
+ var out struct {
+ ID string `json:"id"`
+ }
+ err := c.do(ctx, http.MethodPost, c.companyPath("journal-entries"), e, idempotencyKey, dryRun, &out)
+ return out.ID, err
+}
+
+// Commit posts a draft and assigns its voucher number.
+func (c *Client) Commit(ctx context.Context, id, idempotencyKey string) (Committed, error) {
+ var out Committed
+ err := c.do(ctx, http.MethodPost, c.companyPath("journal-entries/"+url.PathEscape(id)+"/commit"), struct{}{}, idempotencyKey, false, &out)
+ return out, err
+}
+
+func (c *Client) companyPath(p string) string {
+ return "/api/v1/companies/" + url.PathEscape(c.CompanyID) + "/" + p
+}
+
+func (c *Client) do(ctx context.Context, method, path string, body any, idempotencyKey string, dryRun bool, out any) error {
+ var payload []byte
+ if body != nil {
+ var err error
+ if payload, err = json.Marshal(body); err != nil {
+ return err
+ }
+ }
+ u := c.BaseURL + path
+ if dryRun {
+ u += "?dry_run=true"
+ }
+
+ for attempt := 0; ; attempt++ {
+ if err := c.wait(ctx); err != nil {
+ return err
+ }
+ req, err := http.NewRequestWithContext(ctx, method, u, bytes.NewReader(payload))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+c.APIKey)
+ req.Header.Set("Accept", "application/json")
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ // The same key is reused on every retry, so a request that reached
+ // the server before the connection failed is replayed, not repeated.
+ if idempotencyKey != "" {
+ req.Header.Set("Idempotency-Key", idempotencyKey)
+ }
+
+ resp, err := c.HTTP.Do(req)
+ var delay time.Duration
+ if err == nil {
+ retry, d, rerr := c.handle(resp, dryRun, out)
+ if !retry {
+ return rerr
+ }
+ err, delay = rerr, d
+ }
+ if attempt >= c.MaxRetries || ctx.Err() != nil {
+ return err
+ }
+ if delay == 0 {
+ delay = time.Second << attempt
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(delay):
+ }
+ }
+}
+
+func (c *Client) handle(resp *http.Response, dryRun bool, out any) (retry bool, delay time.Duration, err error) {
+ defer resp.Body.Close()
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return true, 0, err
+ }
+
+ if resp.StatusCode >= 300 {
+ var env struct {
+ Error *APIError `json:"error"`
+ }
+ apiErr := &APIError{Code: "HTTP_ERROR", Message: strings.TrimSpace(string(raw))}
+ if json.Unmarshal(raw, &env) == nil && env.Error != nil {
+ apiErr = env.Error
+ }
+ apiErr.Status = resp.StatusCode
+ if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
+ if s, perr := strconv.Atoi(resp.Header.Get("Retry-After")); perr == nil {
+ delay = time.Duration(s) * time.Second
+ }
+ return true, delay, apiErr
+ }
+ return false, 0, apiErr
+ }
+
+ if dryRun || out == nil {
+ return false, 0, nil
+ }
+ var env struct {
+ Data json.RawMessage `json:"data"`
+ }
+ if err := json.Unmarshal(raw, &env); err != nil {
+ return false, 0, fmt.Errorf("decode response: %w", err)
+ }
+ if err := json.Unmarshal(env.Data, out); err != nil {
+ return false, 0, fmt.Errorf("decode response data: %w", err)
+ }
+ return false, 0, nil
+}
+
+func (c *Client) wait(ctx context.Context) error {
+ if d := time.Until(c.last.Add(c.Interval)); d > 0 {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(d):
+ }
+ }
+ c.last = time.Now()
+ return nil
+}
+
+// IsCode reports whether err is an APIError with the given code.
+func IsCode(err error, code string) bool {
+ var e *APIError
+ return errors.As(err, &e) && e.Code == code
+}
diff --git a/internal/accounted/accounted_test.go b/internal/accounted/accounted_test.go
new file mode 100644
index 0000000..01ae42f
--- /dev/null
+++ b/internal/accounted/accounted_test.go
@@ -0,0 +1,65 @@
+package accounted
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestMoneyJSON(t *testing.T) {
+ b, _ := json.Marshal(Line{Account: "1930", Debit: 123405, Credit: 7})
+ want := `{"account_number":"1930","debit_amount":1234.05,"credit_amount":0.07}`
+ if string(b) != want {
+ t.Errorf("got %s, want %s", b, want)
+ }
+}
+
+func TestCreateDraftRetriesWithSameKey(t *testing.T) {
+ var keys []string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ keys = append(keys, r.Header.Get("Idempotency-Key"))
+ if r.URL.Path != "/api/v1/companies/c1/journal-entries" || r.Header.Get("Authorization") != "Bearer k" {
+ t.Errorf("unexpected request %s %s", r.URL.Path, r.Header.Get("Authorization"))
+ }
+ if len(keys) == 1 {
+ w.Header().Set("Retry-After", "0")
+ w.WriteHeader(http.StatusTooManyRequests)
+ io.WriteString(w, `{"error":{"code":"RATE_LIMITED","message_en":"slow down"}}`)
+ return
+ }
+ w.WriteHeader(http.StatusCreated)
+ io.WriteString(w, `{"data":{"id":"je1","status":"draft"},"meta":{"request_id":"req_1"}}`)
+ }))
+ defer srv.Close()
+
+ c := NewClient(srv.URL, "k", "c1")
+ c.Interval = 0
+ id, err := c.CreateDraft(context.Background(), JournalEntry{}, "key-1", false)
+ if err != nil || id != "je1" {
+ t.Fatalf("id = %q, err = %v", id, err)
+ }
+ if len(keys) != 2 || keys[0] != "key-1" || keys[1] != "key-1" {
+ t.Errorf("idempotency keys = %v", keys)
+ }
+}
+
+func TestErrorEnvelope(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Query().Get("dry_run") != "true" {
+ t.Error("dry_run not set")
+ }
+ w.WriteHeader(http.StatusBadRequest)
+ io.WriteString(w, `{"error":{"code":"JOURNAL_ENTRY_NOT_BALANCED","message":"Obalanserad","message_en":"Not balanced","request_id":"req_2"}}`)
+ }))
+ defer srv.Close()
+
+ c := NewClient(srv.URL, "k", "c1")
+ c.Interval = 0
+ _, err := c.CreateDraft(context.Background(), JournalEntry{}, "key-2", true)
+ if !IsCode(err, "JOURNAL_ENTRY_NOT_BALANCED") {
+ t.Fatalf("err = %v", err)
+ }
+}
diff --git a/internal/mapping/mapping.go b/internal/mapping/mapping.go
new file mode 100644
index 0000000..77f9cab
--- /dev/null
+++ b/internal/mapping/mapping.go
@@ -0,0 +1,248 @@
+// Package mapping turns Multicase vouchers into Accounted journal entries:
+// it translates account numbers, posts customer and supplier subledger lines
+// to their control accounts and splits VAT out of gross amounts.
+package mapping
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math"
+ "os"
+ "sort"
+ "strings"
+
+ "simenandre.no/accounted-multicase-intgr/internal/accounted"
+ "simenandre.no/accounted-multicase-intgr/internal/multicase"
+)
+
+// VATCode describes one Multicase MVA code. Multicase posts revenue and cost
+// lines gross with the code attached and leaves the VAT split to the
+// receiving ledger, so a code with a rate needs an account to post VAT to.
+type VATCode struct {
+ Rate float64 `json:"rate"`
+ Account string `json:"account"`
+ TaxCode string `json:"tax_code"`
+}
+
+type Config struct {
+ Accounts map[string]string `json:"accounts"`
+ CustomerLedgerAccount string `json:"customer_ledger_account"`
+ SupplierLedgerAccount string `json:"supplier_ledger_account"`
+ VATCodes map[string]VATCode `json:"vat_codes"`
+ // RoundingAccount takes the öre left over when converted amounts no
+ // longer balance, up to MaxRoundingOre.
+ RoundingAccount string `json:"rounding_account"`
+ MaxRoundingOre int64 `json:"max_rounding_ore"`
+ SourceType string `json:"source_type"`
+ // VoucherSeries maps a Multicase BilagsArt to an Accounted series letter;
+ // "*" is the fallback. Empty means the company's default series.
+ VoucherSeries map[string]string `json:"voucher_series"`
+ // Dimensions maps "department" (AvdNr.) and "project" (Prosjekt) to SIE
+ // dimension numbers. Unset means the columns are not sent.
+ Dimensions map[string]string `json:"dimensions"`
+}
+
+func LoadConfig(path string) (*Config, error) {
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ var c Config
+ dec := json.NewDecoder(bytes.NewReader(b))
+ dec.DisallowUnknownFields()
+ if err := dec.Decode(&c); err != nil {
+ return nil, fmt.Errorf("%s: %w", path, err)
+ }
+ if c.SourceType == "" {
+ c.SourceType = "manual"
+ }
+ if c.MaxRoundingOre == 0 {
+ c.MaxRoundingOre = 100
+ }
+ for code, v := range c.VATCodes {
+ if v.Rate < 0 || v.Rate >= 100 {
+ return nil, fmt.Errorf("vat_codes.%s: rate must be a percentage between 0 and 100", code)
+ }
+ if v.Rate > 0 && v.Account == "" {
+ return nil, fmt.Errorf("vat_codes.%s: a code with a rate needs an account", code)
+ }
+ }
+ return &c, nil
+}
+
+// UnmappedError lists the Multicase accounts and VAT codes a voucher uses
+// that the config does not cover.
+type UnmappedError struct {
+ Accounts []string
+ VATCodes []string
+}
+
+func (e *UnmappedError) Error() string {
+ var parts []string
+ if len(e.Accounts) > 0 {
+ parts = append(parts, "unmapped accounts "+strings.Join(e.Accounts, ", "))
+ }
+ if len(e.VATCodes) > 0 {
+ parts = append(parts, "unknown VAT codes "+strings.Join(e.VATCodes, ", "))
+ }
+ return strings.Join(parts, "; ")
+}
+
+// ErrEmpty means every line of the voucher has a zero amount, as Multicase
+// writes for the customer line of a nullfaktura.
+var ErrEmpty = errors.New("voucher has no non-zero lines")
+
+// Build maps one voucher. The fiscal period is left for the caller to set.
+func (c *Config) Build(v multicase.Voucher) (accounted.JournalEntry, error) {
+ unmapped := &UnmappedError{}
+ var lines []accounted.Line
+ add := func(account string, amount int64, desc, taxCode string, dims map[string]string) {
+ l := accounted.Line{Account: account, Description: desc, TaxCode: taxCode, Dimensions: dims}
+ if amount > 0 {
+ l.Debit = accounted.Money(amount)
+ } else {
+ l.Credit = accounted.Money(-amount)
+ }
+ lines = append(lines, l)
+ }
+
+ for _, l := range v.Lines {
+ if l.Amount == 0 {
+ continue
+ }
+ account, err := c.account(l)
+ if err != nil {
+ unmapped.Accounts = appendUnique(unmapped.Accounts, l.Account)
+ continue
+ }
+ desc := lineDescription(l)
+ dims := c.dimensions(l)
+ if l.VATCode == "" {
+ add(account, l.Amount, desc, "", dims)
+ continue
+ }
+ vat, ok := c.VATCodes[l.VATCode]
+ if !ok {
+ unmapped.VATCodes = appendUnique(unmapped.VATCodes, l.VATCode)
+ continue
+ }
+ if vat.Rate == 0 {
+ add(account, l.Amount, desc, vat.TaxCode, dims)
+ continue
+ }
+ net := SplitNet(l.Amount, vat.Rate)
+ add(account, net, desc, vat.TaxCode, dims)
+ if l.Amount-net != 0 {
+ add(vat.Account, l.Amount-net, desc, vat.TaxCode, nil)
+ }
+ }
+ if len(unmapped.Accounts) > 0 || len(unmapped.VATCodes) > 0 {
+ return accounted.JournalEntry{}, unmapped
+ }
+ if len(lines) == 0 {
+ return accounted.JournalEntry{}, ErrEmpty
+ }
+
+ var sum int64
+ for _, l := range lines {
+ sum += int64(l.Debit) - int64(l.Credit)
+ }
+ if sum != 0 {
+ if c.RoundingAccount == "" || abs(sum) > c.MaxRoundingOre {
+ return accounted.JournalEntry{}, fmt.Errorf("does not balance: off by %s", accounted.Money(sum))
+ }
+ add(c.RoundingAccount, -sum, "Öresavrundning", "", nil)
+ }
+
+ return accounted.JournalEntry{
+ EntryDate: v.Date.Format("2006-01-02"),
+ Description: truncate(strings.TrimSuffix(v.Label()+": "+v.Text, ": "), 200),
+ SourceType: c.SourceType,
+ SourceID: v.Key(),
+ VoucherSeries: c.series(v.Type),
+ Notes: fmt.Sprintf("Multicase %s, BilagsArt %s, Bilagsnummer %s, BuntHodeID %s", v.Company, v.Type, v.Number, v.Batch),
+ Lines: lines,
+ }, nil
+}
+
+func (c *Config) account(l multicase.Line) (string, error) {
+ switch {
+ case l.CustomerNo != "" && l.Account == l.CustomerNo && c.CustomerLedgerAccount != "":
+ return c.CustomerLedgerAccount, nil
+ case l.SupplierNo != "" && l.Account == l.SupplierNo && c.SupplierLedgerAccount != "":
+ return c.SupplierLedgerAccount, nil
+ }
+ if a, ok := c.Accounts[l.Account]; ok && a != "" {
+ return a, nil
+ }
+ return "", fmt.Errorf("unmapped account %s", l.Account)
+}
+
+func (c *Config) dimensions(l multicase.Line) map[string]string {
+ dims := map[string]string{}
+ if n := c.Dimensions["department"]; n != "" && l.Department != "" {
+ dims[n] = l.Department
+ }
+ if n := c.Dimensions["project"]; n != "" && l.Project != "" {
+ dims[n] = l.Project
+ }
+ if len(dims) == 0 {
+ return nil
+ }
+ return dims
+}
+
+func (c *Config) series(voucherType string) string {
+ if s, ok := c.VoucherSeries[voucherType]; ok {
+ return s
+ }
+ return c.VoucherSeries["*"]
+}
+
+// SplitNet returns the net part of a gross amount in öre at the given VAT
+// rate, rounded half away from zero. VAT is gross minus net, so the two
+// always add back up to the gross amount.
+func SplitNet(gross int64, rate float64) int64 {
+ bp := int64(math.Round(rate * 100))
+ num, den := gross*10000, 10000+bp
+ if num >= 0 {
+ return (2*num + den) / (2 * den)
+ }
+ return (2*num - den) / (2 * den)
+}
+
+func lineDescription(l multicase.Line) string {
+ desc := l.Reference
+ if l.Currency != "" && l.CurrencyAmt != "" && l.CurrencyAmt != "0" {
+ desc = strings.TrimSpace(fmt.Sprintf("%s (%s %s)", desc, l.Currency, l.CurrencyAmt))
+ }
+ return truncate(desc, 200)
+}
+
+func appendUnique(list []string, s string) []string {
+ for _, x := range list {
+ if x == s {
+ return list
+ }
+ }
+ list = append(list, s)
+ sort.Strings(list)
+ return list
+}
+
+func truncate(s string, n int) string {
+ r := []rune(s)
+ if len(r) <= n {
+ return s
+ }
+ return string(r[:n])
+}
+
+func abs(n int64) int64 {
+ if n < 0 {
+ return -n
+ }
+ return n
+}
diff --git a/internal/mapping/mapping_test.go b/internal/mapping/mapping_test.go
new file mode 100644
index 0000000..d9190cd
--- /dev/null
+++ b/internal/mapping/mapping_test.go
@@ -0,0 +1,118 @@
+package mapping
+
+import (
+ "errors"
+ "testing"
+ "time"
+
+ "simenandre.no/accounted-multicase-intgr/internal/accounted"
+ "simenandre.no/accounted-multicase-intgr/internal/multicase"
+)
+
+func config() *Config {
+ return &Config{
+ Accounts: map[string]string{"3000": "3001", "1460": "1460", "4301": "4000"},
+ CustomerLedgerAccount: "1510",
+ VATCodes: map[string]VATCode{
+ "3": {Rate: 25, Account: "2611"},
+ "6": {Rate: 0},
+ },
+ RoundingAccount: "3740",
+ MaxRoundingOre: 100,
+ SourceType: "manual",
+ }
+}
+
+func voucher(lines ...multicase.Line) multicase.Voucher {
+ return multicase.Voucher{
+ Type: "11", TypeText: "Faktura", Number: "100", Text: "Demo kunde",
+ Date: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), Lines: lines,
+ }
+}
+
+func TestSplitNet(t *testing.T) {
+ tests := []struct {
+ gross, want int64
+ rate float64
+ }{
+ {270019, 216015, 25},
+ {-270019, -216015, 25},
+ {12500, 10000, 25},
+ {1, 1, 25},
+ {11200, 10000, 12},
+ {5000, 5000, 0},
+ }
+ for _, tt := range tests {
+ if got := SplitNet(tt.gross, tt.rate); got != tt.want {
+ t.Errorf("SplitNet(%d, %v) = %d, want %d", tt.gross, tt.rate, got, tt.want)
+ }
+ }
+}
+
+func TestBuildSplitsVATAndMapsCustomerLedger(t *testing.T) {
+ e, err := config().Build(voucher(
+ multicase.Line{Account: "3000", Amount: -270019, VATCode: "3", CustomerNo: "20001"},
+ multicase.Line{Account: "20001", Amount: 270019, CustomerNo: "20001"},
+ ))
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []accounted.Line{
+ {Account: "3001", Credit: 216015},
+ {Account: "2611", Credit: 54004},
+ {Account: "1510", Debit: 270019},
+ }
+ if len(e.Lines) != len(want) {
+ t.Fatalf("lines = %+v", e.Lines)
+ }
+ for i, w := range want {
+ g := e.Lines[i]
+ if g.Account != w.Account || g.Debit != w.Debit || g.Credit != w.Credit {
+ t.Errorf("line %d = %+v, want %+v", i, g, w)
+ }
+ }
+ if e.SourceID != "multicase:11:100" || e.EntryDate != "2026-10-01" || e.Description != "Faktura 100: Demo kunde" {
+ t.Errorf("header = %+v", e)
+ }
+}
+
+func TestBuildReportsUnmapped(t *testing.T) {
+ _, err := config().Build(voucher(
+ multicase.Line{Account: "9999", Amount: -100},
+ multicase.Line{Account: "1460", Amount: 100, VATCode: "42"},
+ ))
+ var u *UnmappedError
+ if !errors.As(err, &u) || len(u.Accounts) != 1 || u.Accounts[0] != "9999" || len(u.VATCodes) != 1 || u.VATCodes[0] != "42" {
+ t.Fatalf("err = %v", err)
+ }
+}
+
+func TestBuildRounding(t *testing.T) {
+ e, err := config().Build(voucher(
+ multicase.Line{Account: "1460", Amount: 1978734},
+ multicase.Line{Account: "4301", Amount: -1832162},
+ multicase.Line{Account: "4301", Amount: -146573},
+ ))
+ if err != nil {
+ t.Fatal(err)
+ }
+ last := e.Lines[len(e.Lines)-1]
+ if last.Account != "3740" || last.Debit != 1 {
+ t.Errorf("rounding line = %+v", last)
+ }
+
+ _, err = config().Build(voucher(
+ multicase.Line{Account: "1460", Amount: 1000},
+ multicase.Line{Account: "4301", Amount: -500},
+ ))
+ if err == nil {
+ t.Error("expected an error for a voucher off by more than max_rounding_ore")
+ }
+}
+
+func TestBuildSkipsZeroLines(t *testing.T) {
+ _, err := config().Build(voucher(multicase.Line{Account: "20001", Amount: 0, CustomerNo: "20001"}))
+ if !errors.Is(err, ErrEmpty) {
+ t.Fatalf("err = %v, want ErrEmpty", err)
+ }
+}
diff --git a/internal/multicase/multicase.go b/internal/multicase/multicase.go
new file mode 100644
index 0000000..68efc7a
--- /dev/null
+++ b/internal/multicase/multicase.go
@@ -0,0 +1,227 @@
+// Package multicase parses a Multicase "buntfil" export and groups its
+// posting lines into vouchers (bilag).
+package multicase
+
+import (
+ "fmt"
+ "math"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Line is one posting line. Amount is in øre of the Multicase company's base
+// currency (the Belop column); positive is debit, negative is credit.
+type Line struct {
+ Row int
+ Account string
+ Amount int64
+ VATCode string
+ CustomerNo string
+ SupplierNo string
+ Department string
+ Project string
+ Reference string
+ Currency string
+ CurrencyAmt string
+ VoucherType string
+ VoucherText string
+ VoucherNo string
+ Company string
+ Date time.Time
+}
+
+// Voucher is every line sharing a voucher type and voucher number.
+type Voucher struct {
+ Company string
+ Type string // BilagsArt, e.g. "11"
+ TypeText string // BilagsArtTekst, e.g. "Faktura"
+ Number string // Bilagsnummer; "0" for lagerkorrigering
+ Batch string // BuntHodeID
+ Date time.Time
+ Text string
+ Lines []Line
+}
+
+// Key identifies the voucher across runs.
+func (v Voucher) Key() string {
+ if unnumbered(v.Number) {
+ return "multicase:" + v.Type + ":bunt-" + v.Batch
+ }
+ return "multicase:" + v.Type + ":" + v.Number
+}
+
+// Label is a human-readable name for the voucher.
+func (v Voucher) Label() string {
+ if unnumbered(v.Number) {
+ return v.TypeText + " (bunt " + v.Batch + ")"
+ }
+ return v.TypeText + " " + v.Number
+}
+
+// Inventory corrections carry no voucher number; each batch holds one.
+func unnumbered(n string) bool {
+ return n == "" || n == "0"
+}
+
+// Balance returns the sum of the lines in øre; zero for a balanced voucher.
+func (v Voucher) Balance() int64 {
+ var sum int64
+ for _, l := range v.Lines {
+ sum += l.Amount
+ }
+ return sum
+}
+
+var required = []string{"Firma", "Year", "Month", "Day", "Konto", "Bilagsnummer", "Belop", "BilagsArt"}
+
+// Parse turns the worksheet grid (header row first) into vouchers ordered
+// by date, then voucher type and number.
+func Parse(grid [][]string) ([]Voucher, error) {
+ if len(grid) == 0 {
+ return nil, fmt.Errorf("empty worksheet")
+ }
+ col := map[string]int{}
+ for i, h := range grid[0] {
+ if _, dup := col[strings.TrimSpace(h)]; !dup {
+ col[strings.TrimSpace(h)] = i
+ }
+ }
+ for _, h := range required {
+ if _, ok := col[h]; !ok {
+ return nil, fmt.Errorf("column %q missing: is this a Multicase buntfil export?", h)
+ }
+ }
+ get := func(row []string, name string) string {
+ i, ok := col[name]
+ if !ok || i >= len(row) {
+ return ""
+ }
+ return strings.TrimSpace(row[i])
+ }
+
+ byKey := map[string]*Voucher{}
+ var order []string
+ for i, row := range grid[1:] {
+ rowNo := i + 2
+ if get(row, "Konto") == "" && get(row, "Belop") == "" {
+ continue
+ }
+ l, err := parseLine(row, rowNo, get)
+ if err != nil {
+ return nil, err
+ }
+ candidate := Voucher{
+ Company: l.Company,
+ Type: l.VoucherType,
+ TypeText: get(row, "BilagsArtTekst"),
+ Number: l.VoucherNo,
+ Batch: get(row, "BuntHodeID"),
+ Date: l.Date,
+ Text: l.VoucherText,
+ }
+ key := candidate.Key()
+ v, ok := byKey[key]
+ if !ok {
+ v = &candidate
+ byKey[key] = v
+ order = append(order, key)
+ }
+ if !l.Date.Equal(v.Date) {
+ return nil, fmt.Errorf("row %d: %s has lines on both %s and %s",
+ rowNo, v.Label(), v.Date.Format(time.DateOnly), l.Date.Format(time.DateOnly))
+ }
+ v.Lines = append(v.Lines, l)
+ }
+
+ out := make([]Voucher, 0, len(order))
+ for _, k := range order {
+ out = append(out, *byKey[k])
+ }
+ sort.SliceStable(out, func(a, b int) bool {
+ if !out[a].Date.Equal(out[b].Date) {
+ return out[a].Date.Before(out[b].Date)
+ }
+ if out[a].Type != out[b].Type {
+ return out[a].Type < out[b].Type
+ }
+ return numericLess(out[a].Number, out[b].Number)
+ })
+ return out, nil
+}
+
+func parseLine(row []string, rowNo int, get func([]string, string) string) (Line, error) {
+ amount, err := ParseAmount(get(row, "Belop"))
+ if err != nil {
+ return Line{}, fmt.Errorf("row %d: Belop: %w", rowNo, err)
+ }
+ date, err := parseDate(get(row, "Year"), get(row, "Month"), get(row, "Day"))
+ if err != nil {
+ return Line{}, fmt.Errorf("row %d: %w", rowNo, err)
+ }
+ l := Line{
+ Row: rowNo,
+ Account: get(row, "Konto"),
+ Amount: amount,
+ VATCode: get(row, "MvaKode"),
+ CustomerNo: get(row, "Faktura kundenr"),
+ SupplierNo: get(row, "LevNr."),
+ Department: get(row, "AvdNr."),
+ Project: get(row, "Prosjekt"),
+ Reference: get(row, "BestRef1"),
+ Currency: get(row, "Valuta"),
+ CurrencyAmt: get(row, "BelopValuta"),
+ VoucherType: get(row, "BilagsArt"),
+ VoucherText: get(row, "Tekst"),
+ VoucherNo: get(row, "Bilagsnummer"),
+ Company: get(row, "Firma"),
+ Date: date,
+ }
+ if l.Account == "" || l.VoucherType == "" {
+ return Line{}, fmt.Errorf("row %d: Konto and BilagsArt are required", rowNo)
+ }
+ if unnumbered(l.VoucherNo) && get(row, "BuntHodeID") == "" {
+ return Line{}, fmt.Errorf("row %d: line has neither Bilagsnummer nor BuntHodeID", rowNo)
+ }
+ return l, nil
+}
+
+// ParseAmount converts a decimal amount string to øre, rounding half away
+// from zero. Multicase writes currency conversions with three decimals.
+func ParseAmount(s string) (int64, error) {
+ if s == "" {
+ return 0, nil
+ }
+ f, err := strconv.ParseFloat(strings.ReplaceAll(s, ",", "."), 64)
+ if err != nil {
+ return 0, err
+ }
+ // Round the scaled value to 6 places first so binary noise like
+ // 44587.125 -> 4458712.499999 still rounds up.
+ scaled := math.Round(f*100*1e6) / 1e6
+ return int64(math.Round(scaled)), nil
+}
+
+func parseDate(y, m, d string) (time.Time, error) {
+ yi, err1 := strconv.Atoi(y)
+ mi, err2 := strconv.Atoi(m)
+ di, err3 := strconv.Atoi(d)
+ if err1 != nil || err2 != nil || err3 != nil {
+ return time.Time{}, fmt.Errorf("invalid date %s-%s-%s", y, m, d)
+ }
+ t := time.Date(yi, time.Month(mi), di, 0, 0, 0, 0, time.UTC)
+ if t.Year() != yi || int(t.Month()) != mi || t.Day() != di {
+ return time.Time{}, fmt.Errorf("invalid date %s-%s-%s", y, m, d)
+ }
+ return t, nil
+}
+
+func numericLess(a, b string) bool {
+ ai, errA := strconv.Atoi(a)
+ bi, errB := strconv.Atoi(b)
+ if errA == nil && errB == nil {
+ return ai < bi
+ }
+ return a < b
+}
diff --git a/internal/multicase/multicase_test.go b/internal/multicase/multicase_test.go
new file mode 100644
index 0000000..6c737eb
--- /dev/null
+++ b/internal/multicase/multicase_test.go
@@ -0,0 +1,68 @@
+package multicase
+
+import "testing"
+
+var header = []string{"Firma", "Year", "Month", "Day", "Konto", "Bilagsnummer", "Belop", "MvaKode",
+ "BilagsArt", "BilagsArtTekst", "Tekst", "Faktura kundenr", "LevNr.", "BuntHodeID"}
+
+func row(account, number, amount, vat, typ, typeText, customer, batch string) []string {
+ return []string{"Demo AS", "2026", "10", "1", account, number, amount, vat, typ, typeText, "Demo", customer, "", batch}
+}
+
+func TestParse(t *testing.T) {
+ grid := [][]string{
+ header,
+ row("3000", "100", "-125", "3", "11", "Faktura", "20001", "1"),
+ row("20001", "100", "125", "", "11", "Faktura", "20001", "1"),
+ row("1460", "0", "-10", "", "31", "Lagerkorrigering", "", "1"),
+ row("4301", "0", "10", "", "31", "Lagerkorrigering", "", "1"),
+ row("1460", "0", "5", "", "31", "Lagerkorrigering", "", "2"),
+ row("4301", "0", "-5", "", "31", "Lagerkorrigering", "", "2"),
+ }
+ vs, err := Parse(grid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(vs) != 3 {
+ t.Fatalf("got %d vouchers, want 3", len(vs))
+ }
+ keys := map[string]bool{}
+ for _, v := range vs {
+ keys[v.Key()] = true
+ if v.Balance() != 0 {
+ t.Errorf("%s does not balance", v.Label())
+ }
+ }
+ for _, k := range []string{"multicase:11:100", "multicase:31:bunt-1", "multicase:31:bunt-2"} {
+ if !keys[k] {
+ t.Errorf("missing voucher %s in %v", k, keys)
+ }
+ }
+ if got := vs[0].Lines[0].Amount; got != -12500 {
+ t.Errorf("amount = %d øre, want -12500", got)
+ }
+}
+
+func TestParseRejectsMissingColumns(t *testing.T) {
+ if _, err := Parse([][]string{{"Konto", "Belop"}}); err == nil {
+ t.Fatal("expected an error for a file that is not a buntfil")
+ }
+}
+
+func TestParseAmount(t *testing.T) {
+ tests := map[string]int64{
+ "": 0,
+ "46.7": 4670,
+ "-46.7": -4670,
+ "44587.125": 4458713,
+ "-4053.375": -405338,
+ "0.1": 10,
+ "1,5": 150,
+ }
+ for in, want := range tests {
+ got, err := ParseAmount(in)
+ if err != nil || got != want {
+ t.Errorf("ParseAmount(%q) = %d, %v; want %d", in, got, err, want)
+ }
+ }
+}
diff --git a/internal/state/state.go b/internal/state/state.go
new file mode 100644
index 0000000..f530d96
--- /dev/null
+++ b/internal/state/state.go
@@ -0,0 +1,79 @@
+// 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)
+}
diff --git a/internal/xls/xls.go b/internal/xls/xls.go
new file mode 100644
index 0000000..402f9f4
--- /dev/null
+++ b/internal/xls/xls.go
@@ -0,0 +1,364 @@
+// Package xls reads the first worksheet of a legacy Excel 97-2003 (BIFF8)
+// workbook into a grid of strings. It covers the cell records Multicase
+// exports use; formatting, formulas beyond their cached value and dates are
+// out of scope.
+package xls
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+ "os"
+ "strconv"
+ "unicode/utf16"
+
+ "github.com/richardlehane/mscfb"
+ "golang.org/x/text/encoding/charmap"
+)
+
+const (
+ recBOF = 0x0809
+ recEOF = 0x000A
+ recBoundSheet = 0x0085
+ recSST = 0x00FC
+ recContinue = 0x003C
+ recLabelSST = 0x00FD
+ recLabel = 0x0204
+ recNumber = 0x0203
+ recRK = 0x027E
+ recMulRK = 0x00BD
+ recFormula = 0x0006
+ recString = 0x0207
+ recBoolErr = 0x0205
+)
+
+// ReadFile returns the first worksheet of the workbook at path as rows of
+// cell strings. Numbers are rendered without exponent or trailing zeros.
+func ReadFile(path string) ([][]string, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ doc, err := mscfb.New(f)
+ if err != nil {
+ return nil, fmt.Errorf("not an Excel 97-2003 file: %w", err)
+ }
+ for entry, err := doc.Next(); err == nil; entry, err = doc.Next() {
+ if entry.Name != "Workbook" && entry.Name != "Book" {
+ continue
+ }
+ stream, err := io.ReadAll(entry)
+ if err != nil {
+ return nil, err
+ }
+ return ParseWorkbook(stream)
+ }
+ return nil, errors.New("workbook stream not found")
+}
+
+type record struct {
+ typ uint16
+ data []byte
+}
+
+func splitRecords(stream []byte) ([]record, []int, error) {
+ var recs []record
+ var offsets []int
+ for pos := 0; pos+4 <= len(stream); {
+ typ := binary.LittleEndian.Uint16(stream[pos:])
+ n := int(binary.LittleEndian.Uint16(stream[pos+2:]))
+ if pos+4+n > len(stream) {
+ return nil, nil, fmt.Errorf("truncated record 0x%04x at offset %d", typ, pos)
+ }
+ recs = append(recs, record{typ, stream[pos+4 : pos+4+n]})
+ offsets = append(offsets, pos)
+ pos += 4 + n
+ }
+ return recs, offsets, nil
+}
+
+// ParseWorkbook parses a raw BIFF8 workbook stream.
+func ParseWorkbook(stream []byte) ([][]string, error) {
+ recs, offsets, err := splitRecords(stream)
+ if err != nil {
+ return nil, err
+ }
+
+ var sst []string
+ sheetOffset := -1
+ for i := 0; i < len(recs); i++ {
+ r := recs[i]
+ switch r.typ {
+ case recBoundSheet:
+ if sheetOffset < 0 && len(r.data) >= 4 {
+ sheetOffset = int(binary.LittleEndian.Uint32(r.data))
+ }
+ case recSST:
+ segments := [][]byte{r.data}
+ for i+1 < len(recs) && recs[i+1].typ == recContinue {
+ i++
+ segments = append(segments, recs[i].data)
+ }
+ if sst, err = parseSST(segments); err != nil {
+ return nil, err
+ }
+ }
+ if r.typ == recEOF {
+ break
+ }
+ }
+ if sheetOffset < 0 {
+ return nil, errors.New("workbook has no worksheets")
+ }
+
+ start := -1
+ for i, off := range offsets {
+ if off == sheetOffset && recs[i].typ == recBOF {
+ start = i
+ break
+ }
+ }
+ if start < 0 {
+ return nil, errors.New("worksheet BOF not found")
+ }
+
+ var grid [][]string
+ set := func(p pos, v string) {
+ for len(grid) <= p.row {
+ grid = append(grid, nil)
+ }
+ for len(grid[p.row]) <= p.col {
+ grid[p.row] = append(grid[p.row], "")
+ }
+ grid[p.row][p.col] = v
+ }
+
+ for i := start + 1; i < len(recs) && recs[i].typ != recEOF; i++ {
+ d := recs[i].data
+ if len(d) < 6 && recs[i].typ != recString {
+ continue
+ }
+ switch recs[i].typ {
+ case recLabelSST:
+ idx := int(binary.LittleEndian.Uint32(d[6:]))
+ if idx >= len(sst) {
+ return nil, fmt.Errorf("shared string index %d out of range", idx)
+ }
+ set(cell(d), sst[idx])
+ case recLabel:
+ s, err := readString(d[6:])
+ if err != nil {
+ return nil, err
+ }
+ set(cell(d), s)
+ case recNumber:
+ set(cell(d), formatNumber(math.Float64frombits(binary.LittleEndian.Uint64(d[6:]))))
+ case recRK:
+ set(cell(d), formatNumber(decodeRK(binary.LittleEndian.Uint32(d[6:]))))
+ case recMulRK:
+ p := cell(d)
+ for off := 4; off+6 <= len(d)-2; off += 6 {
+ set(p, formatNumber(decodeRK(binary.LittleEndian.Uint32(d[off+2:]))))
+ p.col++
+ }
+ case recFormula:
+ p := cell(d)
+ res := d[6:14]
+ if binary.LittleEndian.Uint16(res[6:]) != 0xFFFF {
+ set(p, formatNumber(math.Float64frombits(binary.LittleEndian.Uint64(res))))
+ } else if res[0] == 0 && i+1 < len(recs) && recs[i+1].typ == recString {
+ s, err := readString(recs[i+1].data)
+ if err != nil {
+ return nil, err
+ }
+ set(p, s)
+ }
+ case recBoolErr:
+ if d[7] == 0 {
+ set(cell(d), strconv.Itoa(int(d[6])))
+ }
+ }
+ }
+ return grid, nil
+}
+
+type pos struct{ row, col int }
+
+func cell(d []byte) pos {
+ return pos{int(binary.LittleEndian.Uint16(d)), int(binary.LittleEndian.Uint16(d[2:]))}
+}
+
+// decodeRK decodes a BIFF RK number. The integer form is a signed 30-bit
+// value: it must be shifted as int32, or negative amounts come out as
+// 2^30 minus their magnitude.
+func decodeRK(rk uint32) float64 {
+ var v float64
+ if rk&0x02 != 0 {
+ v = float64(int32(rk) >> 2)
+ } else {
+ v = math.Float64frombits(uint64(rk&0xFFFFFFFC) << 32)
+ }
+ if rk&0x01 != 0 {
+ v /= 100
+ }
+ return v
+}
+
+func formatNumber(f float64) string {
+ return strconv.FormatFloat(f, 'f', -1, 64)
+}
+
+// readString reads an XLUnicodeString (16-bit length) from b.
+func readString(b []byte) (string, error) {
+ if len(b) < 3 {
+ return "", errors.New("short string")
+ }
+ n := int(binary.LittleEndian.Uint16(b))
+ wide := b[2]&0x01 != 0
+ size := n
+ if wide {
+ size *= 2
+ }
+ if len(b) < 3+size {
+ return "", errors.New("short string")
+ }
+ return decodeChars(b[3:3+size], wide), nil
+}
+
+func decodeChars(b []byte, wide bool) string {
+ if !wide {
+ // "Compressed" BIFF8 strings are the low bytes of UTF-16, i.e. Latin-1.
+ s, _ := charmap.ISO8859_1.NewDecoder().Bytes(b)
+ return string(s)
+ }
+ u := make([]uint16, len(b)/2)
+ for i := range u {
+ u[i] = binary.LittleEndian.Uint16(b[2*i:])
+ }
+ return string(utf16.Decode(u))
+}
+
+// sstReader walks an SST record and its CONTINUE records. Character data
+// that crosses a record boundary restarts with a fresh option byte.
+type sstReader struct {
+ segs [][]byte
+ seg int
+ pos int
+}
+
+func (r *sstReader) next() error {
+ for r.pos >= len(r.segs[r.seg]) {
+ if r.seg+1 >= len(r.segs) {
+ return io.ErrUnexpectedEOF
+ }
+ r.seg++
+ r.pos = 0
+ }
+ return nil
+}
+
+func (r *sstReader) bytes(n int) ([]byte, error) {
+ var out []byte
+ for n > 0 {
+ if err := r.next(); err != nil {
+ return nil, err
+ }
+ cur := r.segs[r.seg]
+ take := min(n, len(cur)-r.pos)
+ out = append(out, cur[r.pos:r.pos+take]...)
+ r.pos += take
+ n -= take
+ }
+ return out, nil
+}
+
+func (r *sstReader) chars(n int, wide bool) (string, error) {
+ var buf bytes.Buffer
+ for n > 0 {
+ if r.pos >= len(r.segs[r.seg]) {
+ if r.seg+1 >= len(r.segs) {
+ return "", io.ErrUnexpectedEOF
+ }
+ r.seg++
+ r.pos = 1
+ wide = r.segs[r.seg][0]&0x01 != 0
+ continue
+ }
+ cur := r.segs[r.seg]
+ width := 1
+ if wide {
+ width = 2
+ }
+ take := min(n, (len(cur)-r.pos)/width)
+ if take == 0 {
+ return "", errors.New("malformed shared string")
+ }
+ buf.WriteString(decodeChars(cur[r.pos:r.pos+take*width], wide))
+ r.pos += take * width
+ n -= take
+ }
+ return buf.String(), nil
+}
+
+func (r *sstReader) u16() (int, error) {
+ b, err := r.bytes(2)
+ if err != nil {
+ return 0, err
+ }
+ return int(binary.LittleEndian.Uint16(b)), nil
+}
+
+func (r *sstReader) u32() (int, error) {
+ b, err := r.bytes(4)
+ if err != nil {
+ return 0, err
+ }
+ return int(binary.LittleEndian.Uint32(b)), nil
+}
+
+func parseSST(segs [][]byte) ([]string, error) {
+ r := &sstReader{segs: segs}
+ if _, err := r.u32(); err != nil {
+ return nil, err
+ }
+ unique, err := r.u32()
+ if err != nil {
+ return nil, err
+ }
+ out := make([]string, 0, unique)
+ for range unique {
+ n, err := r.u16()
+ if err != nil {
+ return nil, err
+ }
+ flags, err := r.bytes(1)
+ if err != nil {
+ return nil, err
+ }
+ runs, ext := 0, 0
+ if flags[0]&0x08 != 0 {
+ if runs, err = r.u16(); err != nil {
+ return nil, err
+ }
+ }
+ if flags[0]&0x04 != 0 {
+ if ext, err = r.u32(); err != nil {
+ return nil, err
+ }
+ }
+ s, err := r.chars(n, flags[0]&0x01 != 0)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := r.bytes(4*runs + ext); err != nil {
+ return nil, err
+ }
+ out = append(out, s)
+ }
+ return out, nil
+}
diff --git a/internal/xls/xls_test.go b/internal/xls/xls_test.go
new file mode 100644
index 0000000..71ed21a
--- /dev/null
+++ b/internal/xls/xls_test.go
@@ -0,0 +1,99 @@
+package xls
+
+import (
+ "encoding/binary"
+ "math"
+ "reflect"
+ "testing"
+)
+
+func rkInt(n int32, div100 bool) uint32 {
+ rk := uint32(n)<<2 | 0x02
+ if div100 {
+ rk |= 0x01
+ }
+ return rk
+}
+
+func TestDecodeRK(t *testing.T) {
+ tests := []struct {
+ rk uint32
+ want float64
+ }{
+ {rkInt(1460, false), 1460},
+ {rkInt(-677083, true), -6770.83},
+ {rkInt(-1, false), -1},
+ {rkInt(4670, true), 46.7},
+ // Float form: the top 30 bits of an IEEE double.
+ {uint32(math.Float64bits(-11800) >> 32), -11800},
+ }
+ for _, tt := range tests {
+ if got := decodeRK(tt.rk); math.Abs(got-tt.want) > 1e-9 {
+ t.Errorf("decodeRK(%#x) = %v, want %v", tt.rk, got, tt.want)
+ }
+ }
+}
+
+type builder struct{ b []byte }
+
+func (w *builder) rec(typ uint16, data []byte) {
+ w.b = binary.LittleEndian.AppendUint16(w.b, typ)
+ w.b = binary.LittleEndian.AppendUint16(w.b, uint16(len(data)))
+ w.b = append(w.b, data...)
+}
+
+func cellHead(row, col uint16) []byte {
+ b := binary.LittleEndian.AppendUint16(nil, row)
+ b = binary.LittleEndian.AppendUint16(b, col)
+ return binary.LittleEndian.AppendUint16(b, 0) // xf
+}
+
+func TestParseWorkbook(t *testing.T) {
+ // SST with two strings; the second ("Bærum", stored as UTF-16) is split
+ // across a CONTINUE record, which restarts with its own option byte.
+ sst := binary.LittleEndian.AppendUint32(nil, 2)
+ sst = binary.LittleEndian.AppendUint32(sst, 2)
+ sst = append(sst, 5, 0, 0)
+ sst = append(sst, "Konto"...)
+ sst = append(sst, 5, 0, 1, 'B', 0, 0xE6, 0)
+ cont := []byte{0, 'r', 'u', 'm'}
+
+ var sheet builder
+ sheet.rec(recBOF, make([]byte, 16))
+ sheet.rec(recLabelSST, binary.LittleEndian.AppendUint32(cellHead(0, 0), 0))
+ sheet.rec(recLabelSST, binary.LittleEndian.AppendUint32(cellHead(0, 1), 1))
+ sheet.rec(recRK, binary.LittleEndian.AppendUint32(cellHead(1, 0), rkInt(1460, false)))
+ mulrk := binary.LittleEndian.AppendUint16(nil, 1)
+ mulrk = binary.LittleEndian.AppendUint16(mulrk, 1)
+ for _, v := range []uint32{rkInt(-677083, true), rkInt(5, false)} {
+ mulrk = binary.LittleEndian.AppendUint16(mulrk, 0)
+ mulrk = binary.LittleEndian.AppendUint32(mulrk, v)
+ }
+ mulrk = binary.LittleEndian.AppendUint16(mulrk, 2)
+ sheet.rec(recMulRK, mulrk)
+ sheet.rec(recNumber, binary.LittleEndian.AppendUint64(cellHead(2, 0), math.Float64bits(44587.125)))
+ sheet.rec(recEOF, nil)
+
+ // The globals substream, with the BOUNDSHEET offset patched once its
+ // own length is known.
+ var globals builder
+ globals.rec(recBOF, make([]byte, 16))
+ globals.rec(recBoundSheet, append(make([]byte, 6), 1, 0, 'S'))
+ globals.rec(recSST, sst)
+ globals.rec(recContinue, cont)
+ globals.rec(recEOF, nil)
+ binary.LittleEndian.PutUint32(globals.b[20+4:], uint32(len(globals.b)))
+
+ grid, err := ParseWorkbook(append(globals.b, sheet.b...))
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := [][]string{
+ {"Konto", "Bærum"},
+ {"1460", "-6770.83", "5"},
+ {"44587.125"},
+ }
+ if !reflect.DeepEqual(grid, want) {
+ t.Fatalf("grid = %q, want %q", grid, want)
+ }
+}
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])
+}