aboutsummaryrefslogtreecommitdiffstats
path: root/internal/mapping/mapping.go
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/mapping.go
downloadaccounted-multicase-intgr-main.tar.gz
accounted-multicase-intgr-main.zip
feat: send Multicase buntfil vouchers to AccountedHEADmain
Diffstat (limited to 'internal/mapping/mapping.go')
-rw-r--r--internal/mapping/mapping.go248
1 files changed, 248 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
+}