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 | |
| download | accounted-multicase-intgr-main.tar.gz accounted-multicase-intgr-main.zip | |
| -rw-r--r-- | .gitignore | 4 | ||||
| -rw-r--r-- | README.md | 82 | ||||
| -rw-r--r-- | config.example.json | 32 | ||||
| -rw-r--r-- | go.mod | 10 | ||||
| -rw-r--r-- | go.sum | 6 | ||||
| -rw-r--r-- | internal/accounted/accounted.go | 262 | ||||
| -rw-r--r-- | internal/accounted/accounted_test.go | 65 | ||||
| -rw-r--r-- | internal/mapping/mapping.go | 248 | ||||
| -rw-r--r-- | internal/mapping/mapping_test.go | 118 | ||||
| -rw-r--r-- | internal/multicase/multicase.go | 227 | ||||
| -rw-r--r-- | internal/multicase/multicase_test.go | 68 | ||||
| -rw-r--r-- | internal/state/state.go | 79 | ||||
| -rw-r--r-- | internal/xls/xls.go | 364 | ||||
| -rw-r--r-- | internal/xls/xls_test.go | 99 | ||||
| -rw-r--r-- | main.go | 344 |
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" } +} @@ -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 @@ -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) + } +} @@ -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]) +} |
