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/accounted | |
| download | accounted-multicase-intgr-0571b30b6da27dc4c9e617403deab09b974ac641.tar.gz accounted-multicase-intgr-0571b30b6da27dc4c9e617403deab09b974ac641.zip | |
Diffstat (limited to 'internal/accounted')
| -rw-r--r-- | internal/accounted/accounted.go | 262 | ||||
| -rw-r--r-- | internal/accounted/accounted_test.go | 65 |
2 files changed, 327 insertions, 0 deletions
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 +} diff --git a/internal/accounted/accounted_test.go b/internal/accounted/accounted_test.go new file mode 100644 index 0000000..01ae42f --- /dev/null +++ b/internal/accounted/accounted_test.go @@ -0,0 +1,65 @@ +package accounted + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestMoneyJSON(t *testing.T) { + b, _ := json.Marshal(Line{Account: "1930", Debit: 123405, Credit: 7}) + want := `{"account_number":"1930","debit_amount":1234.05,"credit_amount":0.07}` + if string(b) != want { + t.Errorf("got %s, want %s", b, want) + } +} + +func TestCreateDraftRetriesWithSameKey(t *testing.T) { + var keys []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + keys = append(keys, r.Header.Get("Idempotency-Key")) + if r.URL.Path != "/api/v1/companies/c1/journal-entries" || r.Header.Get("Authorization") != "Bearer k" { + t.Errorf("unexpected request %s %s", r.URL.Path, r.Header.Get("Authorization")) + } + if len(keys) == 1 { + w.Header().Set("Retry-After", "0") + w.WriteHeader(http.StatusTooManyRequests) + io.WriteString(w, `{"error":{"code":"RATE_LIMITED","message_en":"slow down"}}`) + return + } + w.WriteHeader(http.StatusCreated) + io.WriteString(w, `{"data":{"id":"je1","status":"draft"},"meta":{"request_id":"req_1"}}`) + })) + defer srv.Close() + + c := NewClient(srv.URL, "k", "c1") + c.Interval = 0 + id, err := c.CreateDraft(context.Background(), JournalEntry{}, "key-1", false) + if err != nil || id != "je1" { + t.Fatalf("id = %q, err = %v", id, err) + } + if len(keys) != 2 || keys[0] != "key-1" || keys[1] != "key-1" { + t.Errorf("idempotency keys = %v", keys) + } +} + +func TestErrorEnvelope(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("dry_run") != "true" { + t.Error("dry_run not set") + } + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, `{"error":{"code":"JOURNAL_ENTRY_NOT_BALANCED","message":"Obalanserad","message_en":"Not balanced","request_id":"req_2"}}`) + })) + defer srv.Close() + + c := NewClient(srv.URL, "k", "c1") + c.Interval = 0 + _, err := c.CreateDraft(context.Background(), JournalEntry{}, "key-2", true) + if !IsCode(err, "JOURNAL_ENTRY_NOT_BALANCED") { + t.Fatalf("err = %v", err) + } +} |
