aboutsummaryrefslogtreecommitdiffstats
path: root/internal
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 /internal
downloadaccounted-multicase-intgr-0571b30b6da27dc4c9e617403deab09b974ac641.tar.gz
accounted-multicase-intgr-0571b30b6da27dc4c9e617403deab09b974ac641.zip
feat: send Multicase buntfil vouchers to AccountedHEADmain
Diffstat (limited to 'internal')
-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
9 files changed, 1530 insertions, 0 deletions
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)
+ }
+}