// Package accounted is a small client for the Accounted v1 REST API, // covering what is needed to book vouchers: fiscal periods, draft journal // entries and commit. package accounted import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) // Money is an amount in öre. It marshals as a JSON number with two decimals // so no float rounding happens on the way out. type Money int64 func (m Money) String() string { sign := "" if m < 0 { sign, m = "-", -m } return fmt.Sprintf("%s%d.%02d", sign, m/100, m%100) } func (m Money) MarshalJSON() ([]byte, error) { return []byte(m.String()), nil } type Line struct { Account string `json:"account_number"` Debit Money `json:"debit_amount"` Credit Money `json:"credit_amount"` Description string `json:"line_description,omitempty"` TaxCode string `json:"tax_code,omitempty"` Dimensions map[string]string `json:"dimensions,omitempty"` } type JournalEntry struct { FiscalPeriodID string `json:"fiscal_period_id"` EntryDate string `json:"entry_date"` Description string `json:"description"` SourceType string `json:"source_type"` SourceID string `json:"source_id"` VoucherSeries string `json:"voucher_series,omitempty"` Notes string `json:"notes,omitempty"` Lines []Line `json:"lines"` } type FiscalPeriod struct { ID string `json:"id"` Name string `json:"name"` PeriodStart string `json:"period_start"` PeriodEnd string `json:"period_end"` IsClosed bool `json:"is_closed"` LockedAt *string `json:"locked_at"` } type Committed struct { ID string `json:"id"` VoucherSeries string `json:"voucher_series"` VoucherNumber int `json:"voucher_number"` Status string `json:"status"` } // APIError is the v1 error envelope. type APIError struct { Status int `json:"-"` Code string `json:"code"` Message string `json:"message_en"` MessageSV string `json:"message"` Hint string `json:"recovery_hint"` Details json.RawMessage `json:"details"` RequestID string `json:"request_id"` } func (e *APIError) Error() string { msg := e.Message if msg == "" { msg = e.MessageSV } s := fmt.Sprintf("%s (HTTP %d): %s", e.Code, e.Status, msg) if len(e.Details) > 0 && string(e.Details) != "null" { s += " " + string(e.Details) } if e.RequestID != "" { s += " [" + e.RequestID + "]" } return s } type Client struct { BaseURL string APIKey string CompanyID string HTTP *http.Client // Interval is the minimum time between requests. The default API key // limit is 100 requests per minute. Interval time.Duration MaxRetries int last time.Time } func NewClient(baseURL, apiKey, companyID string) *Client { return &Client{ BaseURL: strings.TrimRight(baseURL, "/"), APIKey: apiKey, CompanyID: companyID, HTTP: &http.Client{Timeout: 60 * time.Second}, Interval: 700 * time.Millisecond, MaxRetries: 5, } } func (c *Client) FiscalPeriods(ctx context.Context) ([]FiscalPeriod, error) { var out struct { FiscalPeriods []FiscalPeriod `json:"fiscal_periods"` } err := c.do(ctx, http.MethodGet, c.companyPath("fiscal-periods"), nil, "", false, &out) return out.FiscalPeriods, err } // CreateDraft creates a draft journal entry and returns its id. In dry-run // mode the server validates the entry and nothing is stored; the id is empty. func (c *Client) CreateDraft(ctx context.Context, e JournalEntry, idempotencyKey string, dryRun bool) (string, error) { var out struct { ID string `json:"id"` } err := c.do(ctx, http.MethodPost, c.companyPath("journal-entries"), e, idempotencyKey, dryRun, &out) return out.ID, err } // Commit posts a draft and assigns its voucher number. func (c *Client) Commit(ctx context.Context, id, idempotencyKey string) (Committed, error) { var out Committed err := c.do(ctx, http.MethodPost, c.companyPath("journal-entries/"+url.PathEscape(id)+"/commit"), struct{}{}, idempotencyKey, false, &out) return out, err } func (c *Client) companyPath(p string) string { return "/api/v1/companies/" + url.PathEscape(c.CompanyID) + "/" + p } func (c *Client) do(ctx context.Context, method, path string, body any, idempotencyKey string, dryRun bool, out any) error { var payload []byte if body != nil { var err error if payload, err = json.Marshal(body); err != nil { return err } } u := c.BaseURL + path if dryRun { u += "?dry_run=true" } for attempt := 0; ; attempt++ { if err := c.wait(ctx); err != nil { return err } req, err := http.NewRequestWithContext(ctx, method, u, bytes.NewReader(payload)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+c.APIKey) req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") } // The same key is reused on every retry, so a request that reached // the server before the connection failed is replayed, not repeated. if idempotencyKey != "" { req.Header.Set("Idempotency-Key", idempotencyKey) } resp, err := c.HTTP.Do(req) var delay time.Duration if err == nil { retry, d, rerr := c.handle(resp, dryRun, out) if !retry { return rerr } err, delay = rerr, d } if attempt >= c.MaxRetries || ctx.Err() != nil { return err } if delay == 0 { delay = time.Second << attempt } select { case <-ctx.Done(): return ctx.Err() case <-time.After(delay): } } } func (c *Client) handle(resp *http.Response, dryRun bool, out any) (retry bool, delay time.Duration, err error) { defer resp.Body.Close() raw, err := io.ReadAll(resp.Body) if err != nil { return true, 0, err } if resp.StatusCode >= 300 { var env struct { Error *APIError `json:"error"` } apiErr := &APIError{Code: "HTTP_ERROR", Message: strings.TrimSpace(string(raw))} if json.Unmarshal(raw, &env) == nil && env.Error != nil { apiErr = env.Error } apiErr.Status = resp.StatusCode if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { if s, perr := strconv.Atoi(resp.Header.Get("Retry-After")); perr == nil { delay = time.Duration(s) * time.Second } return true, delay, apiErr } return false, 0, apiErr } if dryRun || out == nil { return false, 0, nil } var env struct { Data json.RawMessage `json:"data"` } if err := json.Unmarshal(raw, &env); err != nil { return false, 0, fmt.Errorf("decode response: %w", err) } if err := json.Unmarshal(env.Data, out); err != nil { return false, 0, fmt.Errorf("decode response data: %w", err) } return false, 0, nil } func (c *Client) wait(ctx context.Context) error { if d := time.Until(c.last.Add(c.Interval)); d > 0 { select { case <-ctx.Done(): return ctx.Err() case <-time.After(d): } } c.last = time.Now() return nil } // IsCode reports whether err is an APIError with the given code. func IsCode(err error, code string) bool { var e *APIError return errors.As(err, &e) && e.Code == code }