diff options
Diffstat (limited to 'internal/mapping')
| -rw-r--r-- | internal/mapping/mapping.go | 248 | ||||
| -rw-r--r-- | internal/mapping/mapping_test.go | 118 |
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) + } +} |
