From 0571b30b6da27dc4c9e617403deab09b974ac641 Mon Sep 17 00:00:00 2001 From: "Simen A. W. Olsen" Date: Tue, 27 Oct 2026 17:19:57 +0100 Subject: feat: send Multicase buntfil vouchers to Accounted --- internal/accounted/accounted.go | 262 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 internal/accounted/accounted.go (limited to 'internal/accounted/accounted.go') diff --git a/internal/accounted/accounted.go b/internal/accounted/accounted.go new file mode 100644 index 0000000..45508c9 --- /dev/null +++ b/internal/accounted/accounted.go @@ -0,0 +1,262 @@ +// 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 +} -- cgit v1.3.1