// 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 }