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