aboutsummaryrefslogtreecommitdiffstats
path: root/internal/accounted
diff options
context:
space:
mode:
authorSimen A. W. Olsen <hello@simenandre.no>2026-10-27 17:19:57 +0100
committerSimen A. W. Olsen <hello@simenandre.no>2026-10-27 17:19:57 +0100
commit0571b30b6da27dc4c9e617403deab09b974ac641 (patch)
treef3dfab18e22a48d06c64e563ba9bcf478c01a5bc /internal/accounted
downloadaccounted-multicase-intgr-0571b30b6da27dc4c9e617403deab09b974ac641.tar.gz
accounted-multicase-intgr-0571b30b6da27dc4c9e617403deab09b974ac641.zip
feat: send Multicase buntfil vouchers to AccountedHEADmain
Diffstat (limited to 'internal/accounted')
-rw-r--r--internal/accounted/accounted.go262
-rw-r--r--internal/accounted/accounted_test.go65
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)
+ }
+}