diff options
| author | Simen A. W. Olsen <hello@simenandre.no> | 2026-10-27 17:19:57 +0100 |
|---|---|---|
| committer | Simen A. W. Olsen <hello@simenandre.no> | 2026-10-27 17:19:57 +0100 |
| commit | 0571b30b6da27dc4c9e617403deab09b974ac641 (patch) | |
| tree | f3dfab18e22a48d06c64e563ba9bcf478c01a5bc /internal/mapping/mapping.go | |
| download | accounted-multicase-intgr-0571b30b6da27dc4c9e617403deab09b974ac641.tar.gz accounted-multicase-intgr-0571b30b6da27dc4c9e617403deab09b974ac641.zip | |
Diffstat (limited to 'internal/mapping/mapping.go')
| -rw-r--r-- | internal/mapping/mapping.go | 248 |
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 +} |
