1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
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
}
|