From c0a01ac65bb492640b169274614e0d8022f5076d Mon Sep 17 00:00:00 2001 From: "Simen A. W. Olsen" Date: Sat, 17 Oct 2026 14:19:09 +0200 Subject: Fiken API client and OAuth2 for Go Extracted from gcbill, where it was internal/fiken and internal/fikenauth, so adaa and sokkel-billing can use the same client instead of each carrying its own. The login command named when a grant cannot be renewed is now injected (Source.LoginCommand): it was hardcoded to gcbill's, which would have sent every other caller's operator to a command that does not exist. --- README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 README.md (limited to 'README.md') diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e59e41 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# fiken + +A Go client for [Fiken](https://fiken.no)'s API v2, and the OAuth2 flow that +gets a token for it. + +```sh +go get simenandre.no/fiken +``` + +Two 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. + +```go +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) +``` + +A personal API token works too, with no OAuth at all: + +```go +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. + +## 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. -- cgit v1.3.1