aboutsummaryrefslogtreecommitdiffstats

fiken

A Go client for Fiken's API v2, and the OAuth2 flow that gets a token for it.

go get simenandre.no/fiken

Three packages, and the split is the point:

  • fiken knows how to put a bearer token in a header and how to read Fiken's answers. Where the token came from is not its business.
  • fikenauth gets and keeps a token: the authorization-code flow over a loopback redirect, refreshing an expired grant, and a 0600 file to store it in. Refreshing is a side effect of asking for a token rather than a step the caller has to remember, which is the only way it gets done everywhere.
  • fikenconfig is the login every program on the machine shares, so logging in once, from any of them, logs in all of them.
store := fikenauth.NewStore(filepath.Join(dir, "fiken-token.json"))
source := fikenauth.NewSource(cfg, store)
source.LoginCommand = "myapp fiken login" // named in the error if the grant dies

client, err := fiken.NewWithTokens(source)

One login for every program

fikenconfig keeps one OAuth app and a grant per Fiken account in ~/.config/fiken ($XDG_CONFIG_HOME/fiken, or $FIKEN_CONFIG_DIR):

config.json                  the app, and each account with the companies it reaches
accounts/<email>.json        each account's grant
{
  "app": {
    "client_id": "...",
    "client_secret": "...",
    "redirect_url": "http://localhost:8137/callback"
  },
  "accounts": {
    "simen@sokkel.io": {
      "name": "Simen",
      "companies": [{ "name": "Sokkel AS", "slug": "sokkel-as" }]
    },
    "post@bjerk.io": { "companies": [...], "token": "a personal API token" }
  }
}

A program names the company it works for and gets the account that reaches it. Which company that is stays the program's business:

cfg, err := fikenconfig.Open("")
cfg.LoginCommand = "myapp login"
client, err := cfg.Client(ctx, "sokkel-as")

Logging in records the grant under the email of the Fiken user who gave it:

grant, err := cfg.Auth().Login(ctx, announce)
email, err := cfg.AddGrant(ctx, grant)

A company added in Fiken after the login is found without logging in again: when no account has it, Client asks each account once more.

Sharing the grant file is the only safe way to share a grant, because Fiken rotates the refresh token on every renewal and two copies means the first to renew silently logs out the other. Renewing takes a lock beside the file and reads it again under the lock, so two programs renewing at once take turns. $FIKEN_CLIENT_ID and $FIKEN_CLIENT_SECRET override the app in the file.

Without the shared login

A personal API token works too, with no OAuth at all:

client, err := fiken.New(os.Getenv("FIKEN_TOKEN"))

What it is careful about

Amounts are integers. DraftLine.UnitPrice is the invoice currency's minor unit and Quantity is normally 1, so the amount is carried entirely by the price and no float ever enters the payload. Fiken's minor unit has two decimals whatever the currency, so a price carried at more precision than that has to be rounded by the caller — where the rounding rule can be stated and tested, rather than here where it would be invisible.

A token never reaches a log line. Token.String hides its own value, so %v on one prints nothing useful.

404 is its own error. ErrNoSuchResource wraps it, because deleting a draft that is already gone is usually the state the caller wanted, not a failure. ErrUnauthorized is likewise separate from a transport error: one means log in again, the other means try again.

Retries are bounded and only for what is retryable. A 5xx or 429 is retried; a 4xx is not, because sending the same bad request again cannot help. Nothing that creates something is retried at all: a 5xx does not say whether Fiken acted before failing, and a draft or contact made twice is worse than an error.

Status

Extracted from a working invoicing tool and used by three of them, so the surface is what those needed rather than all of Fiken: companies, contacts, invoices, drafts, credit notes. Pull requests that widen it are welcome; the constraints above are not up for negotiation.

Tests run against httptest servers — including retries, 4xx, and a declined consent — and reach nothing real.