fortnox-api 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ea75f17f262d1586930c15e3114c1d65590a7f542ea7d75069c6d864c0db52ba
4
- data.tar.gz: bcdc7c31ac33686637badff069fc6eedf9668e915a974420bccfef183c3e335b
3
+ metadata.gz: a0605a1ea41ed7a7322a560e9e27255b60df149c486f875bd968354adbf1b826
4
+ data.tar.gz: b66ce8b6a31fa9ef28b5ae0a99e8531df1027253bde63338291c875bfaa15367
5
5
  SHA512:
6
- metadata.gz: 56e587b9a912a34d08ceaed8207d9097638726423b62bc8cacd0b440d57203f079f8c527aa30bb78bd3f82cd359a11f6c153d344026109e0c9de92ff1f054e44
7
- data.tar.gz: e02fe96b0107463dbeab810717dc08549a8d2292216455c59089f7477725df1e1ecf883af02d521c3ec69bee52fe22f4afaeddf2b473408e7f8e8f40fe039b7f
6
+ metadata.gz: 347bab9a453b33bd19d396d2b142fb8e133ee9615529f307fbe2ecc47f79e37d907731940267da551481c62ae8a17bd0df8b83de8c955c5cdbed8ba2e0517130
7
+ data.tar.gz: d5ea5952ecc427a24ba0264197ed5cfa364119f37921a742304a9aec41d55b13d24ff379001af4087ce5d3235456413ade7b2a5d51ad733040de13710a3b5d6e
data/CHANGELOG.md CHANGED
@@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
6
6
  and this project adheres to
7
7
  [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
8
8
 
9
+ ## [0.9.2]
10
+
11
+ ### Fixed
12
+
13
+ - Email validation now accepts internationalized domain names (IDN),
14
+ e.g. `user@teståäö.se` (RFC 6530)
15
+
9
16
  ## [0.9.1]
10
17
 
11
18
  ### Fixed
@@ -101,6 +108,7 @@ and this project adheres to
101
108
 
102
109
  - Model attribute `url` is no longer null
103
110
 
111
+ [0.9.2]: https://github.com/accodeing/fortnox-api/compare/v0.9.1...v0.9.2
104
112
  [0.9.1]: https://github.com/accodeing/fortnox-api/compare/v0.9.0...v0.9.1
105
113
  [0.9.0]: https://github.com/accodeing/fortnox-api/compare/v0.8.0...v0.9.0
106
114
  [0.8.0]: https://github.com/accodeing/fortnox-api/compare/v0.7.2...v0.8.0
data/CLAUDE.md ADDED
@@ -0,0 +1,79 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ Ruby gem wrapping Fortnox AB's version 3 REST API. Uses the data mapper pattern (not ActiveRecord) with separate concerns for models, types, mappers, and repositories.
8
+
9
+ ## Common Commands
10
+
11
+ ```bash
12
+ # Run all tests
13
+ bundle exec rspec
14
+
15
+ # Run a single test file
16
+ bundle exec rspec spec/fortnox/api/repositories/customer_spec.rb
17
+
18
+ # Run tests matching a pattern
19
+ bundle exec rspec --example "Customer"
20
+
21
+ # Run linter
22
+ bundle exec rubocop
23
+
24
+ # Run linter with auto-fix
25
+ bundle exec rubocop -a
26
+
27
+ # List rake tasks
28
+ rake -T
29
+
30
+ # Remove all VCR cassettes (for re-recording)
31
+ rake throw_vcr_cassettes
32
+
33
+ # Seed test Fortnox instance with required data
34
+ rake seed_fortnox_test_instance
35
+
36
+ # Get new OAuth tokens (requires credentials in .env)
37
+ bin/get_tokens
38
+ ```
39
+
40
+ ## Architecture
41
+
42
+ ### Data Mapper Pattern
43
+
44
+ Unlike Rails' ActiveRecord, this gem separates concerns:
45
+
46
+ - **Models** (`lib/fortnox/api/models/`): Immutable data objects. Use `model.update(attr: value)` to get a new instance with changed attributes.
47
+ - **Types** (`lib/fortnox/api/types/`): Enforce constraints on attribute values, lengths, and content.
48
+ - **Mappers** (`lib/fortnox/api/mappers/`): Convert between Ruby objects and Fortnox JSON. Used internally by repositories.
49
+ - **Repositories** (`lib/fortnox/api/repositories/`): Handle HTTP requests. Methods: `all`, `find(id)`, `find_by(attr: value)`, `save`.
50
+
51
+ ### Key Classes
52
+
53
+ - `Fortnox::API` - Main module with configuration and thread-local access token
54
+ - `Fortnox::API::Repository::Authentication` - Token renewal via `renew_tokens`
55
+ - Available models: Article, Customer, Invoice, Label, Order, Project, TermsOfPayment, Unit
56
+
57
+ ### Exception Hierarchy
58
+
59
+ - `Fortnox::API::AttributeError` - Invalid attribute value
60
+ - `Fortnox::API::MissingAttributeError` - Required attribute missing
61
+ - `Fortnox::API::RemoteServerError` - Server-side error from Fortnox
62
+
63
+ ## Testing
64
+
65
+ - Uses VCR to record API responses as cassettes in `spec/vcr_cassettes/`
66
+ - When re-recording cassettes, do one repository at a time to avoid 429 rate limits
67
+ - Environment variables for testing go in `.env.test` (see `.env.template`)
68
+ - Set `DEBUG=true` for debug output during tests
69
+ - Set `REFRESH_TOKENS=true` to enable token refresh during testing
70
+
71
+ ## Fortnox API Gotchas
72
+
73
+ See `docs/gotchas.md` for detailed API quirks including:
74
+ - `SalesAccount` default values may reference non-existent accounts
75
+ - Legacy `HouseWorkType` values that can't be used for new orders/invoices
76
+ - `VATIncluded` affects all price fields (no VAT-inclusive fields when false)
77
+ - `TermsOfPayments.code` is case-sensitive (`30DAYS` not `30days`)
78
+ - Row descriptions limited to 255 characters (undocumented)
79
+ - Only one active refresh token per Fortnox account per integration
data/README.md CHANGED
@@ -16,12 +16,9 @@ PRs of your own 😃
16
16
  [![Maintainability](https://api.codeclimate.com/v1/badges/89d30a43fedf210d470b/maintainability)](https://codeclimate.com/github/accodeing/fortnox-api/maintainability)
17
17
  [![Test Coverage](https://api.codeclimate.com/v1/badges/89d30a43fedf210d470b/test_coverage)](https://codeclimate.com/github/accodeing/fortnox-api/test_coverage)
18
18
 
19
- The rough status of this project is as follows (as of spring 2023):
19
+ The rough status of this project is as follows (as of spring 2025):
20
20
 
21
- - `master` branch and the released versions should be production ready.
22
- - We are actively working on our generalization of this gem:
23
- [rest_easy gem](https://github.com/accodeing/rest_easy). It will be a base for
24
- REST API's in general.
21
+ - `master` branch and the released versions is production ready.
25
22
  - Basic structure complete. Things like getting customers and invoices, updating
26
23
  and saving etc.
27
24
  - Some advanced features implemented, for instance support for multiple Fortnox
@@ -175,11 +172,9 @@ $ gem install fortnox-api
175
172
  ## Authorization
176
173
 
177
174
  > :warning: Before 2022, Fortnox used a client ID and a fixed access token for
178
- > authorization. This way of is now deprecated. The old access tokens have a
179
- > life span of 10 years according to Fortnox. They can still be used, but you
180
- > can't issue any new long lived tokens and they recommend to migrate to the new
181
- > authorization process. This gem will no longer support the old way of
182
- > authorization since v0.9.0.
175
+ > authorization. This way of is now deprecated. The old access tokens will be
176
+ > deprecated April 30, 2025 according to Fortnox. This gem will no longer support
177
+ > the old way of authorization from v0.9.0.
183
178
 
184
179
  You need to have a Fortnox app and to create such an app, you need to register
185
180
  as a Fortnox developer. It might feel as if "I just want to create an
@@ -207,7 +202,7 @@ Things you need:
207
202
 
208
203
  When you have authorized your integration you get an access token from Fortnox.
209
204
  It's a JWT with a expiration time (currently **1 hour**). You also get a long
210
- lived refresh token (currently lasts for **31 days** ). When you need a new
205
+ lived refresh token (currently lasts for **45 days** ). When you need a new
211
206
  access token you send a renewal request to Fortnox. That request contains the
212
207
  new access token as well as a new refresh token and some other data. Note that
213
208
  **the old refresh token is invalidated when new tokens are requested**. As long
data/bin/fortnox ADDED
@@ -0,0 +1,285 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ lib = File.expand_path('../lib', __dir__)
5
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
6
+
7
+ require 'bundler/setup'
8
+ require 'dry/cli'
9
+ require 'securerandom'
10
+ require 'base64'
11
+ require 'socket'
12
+ require 'cgi'
13
+ require 'uri'
14
+ require 'faraday'
15
+
16
+ # TODO: Not implemented yet
17
+ # require "fortnox/version"
18
+
19
+ module Fortnox
20
+ module CLI
21
+ module Server
22
+ def self.start(port)
23
+ socket = TCPServer.new(port)
24
+ client = socket.accept
25
+ request = client.gets
26
+ _, path, = request.split
27
+ client.puts("HTTP/1.1 200\r\n\r\n#{response_html}")
28
+ client.close
29
+ socket.close
30
+
31
+ URI.decode_www_form(path[2..]).to_h.transform_keys(&:to_sym)
32
+ end
33
+
34
+ def self.response_html
35
+ assets_root = 'https://accodeing.com/assets/images'
36
+
37
+ favicon = "#{assets_root}/favicon-32x32.png"
38
+ bkg = "#{assets_root}/background.svg"
39
+ logo = "#{assets_root}/only_logo.svg"
40
+
41
+ <<~HTML
42
+ <html>
43
+ <head>
44
+ <title>Fortnox gem local server</title>
45
+ <link rel="icon" type="image/png" sizes="32x32" href="#{favicon}" />
46
+ <style>
47
+ main{display:block;width:800px;margin:2rem auto 0}html{color:#222;font-family:sans-serif}body{margin:0;background-color:#31926f;color:#fff;font-family:'Open Sans',sans-serif;background-image:url(#{bkg});background-repeat:no-repeat;background-size:cover;font-size:1em;line-height:1.4}h1{font-family:Quicksand,sans-serif;font-size:2em;margin:.67em 0;color:#f2dfc3}img{width:100%}
48
+ </style>
49
+ </head>
50
+ <body>
51
+ <main>
52
+ <img src="#{logo}" alt="According to you's logo, a happy otter.">
53
+ <h1>The response from Fortnox has been caught.</h1>
54
+ <p>You can safely close this tab now and continue in the terminal.</p>
55
+ </main>
56
+ </body>
57
+ </html>
58
+ HTML
59
+ end
60
+ end
61
+
62
+ module Commands
63
+ extend Dry::CLI::Registry
64
+
65
+ class Version < Dry::CLI::Command
66
+ desc 'Print version'
67
+
68
+ def call(*)
69
+ puts Fortnox::VERSION
70
+ end
71
+ end
72
+
73
+ class Init < Dry::CLI::Command # rubocop:disable Metrics/ClassLength
74
+ desc "Create initial authentication and refresh tokens using Fortnox's OAuth screen. " \
75
+ 'If started without arguments it will run through a wizzard to get you set up. ' \
76
+ 'If all the arguments are given it will run the process automatically, without prompting.'
77
+ option :port, default: '4242', type: :integer,
78
+ desc: "Port used by a local server to catch Fortnox's auth response"
79
+ argument :client_id, type: :string, desc: 'Client ID'
80
+ argument :client_secret, type: :string, desc: 'Client secret'
81
+ argument :scopes, type: :array, desc: 'Array of scopes'
82
+
83
+ def call(port: 4242, client_id: nil, client_secret: nil, scopes: [], **) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
84
+ fast_track = !(client_id.nil? || client_secret.nil? || scopes.empty?)
85
+
86
+ confirm_ready unless fast_track
87
+
88
+ if fast_track
89
+ scopes = scopes.join(' ')
90
+ else
91
+ client_id = get_client_id
92
+ client_secret = get_client_secret
93
+ scopes = get_scopes
94
+ end
95
+
96
+ credentials = Base64.encode64("#{client_id}:#{client_secret}")
97
+ redirect_uri = "http://localhost:#{port}"
98
+ nonce = SecureRandom.base64
99
+ params = {
100
+ client_id:,
101
+ redirect_uri:,
102
+ scope: scopes,
103
+ state: nonce,
104
+ access_type: 'offline',
105
+ response_type: 'code',
106
+ account_type: 'service'
107
+ }
108
+ url = "https://apps.fortnox.se/oauth-v1/auth?#{URI.encode_www_form(params)}"
109
+
110
+ confirm_redirect_uri(redirect_uri) unless fast_track
111
+ launch_fortnox_authorisation(url)
112
+ auth_code = get_auth_code(port, nonce)
113
+ tokens = exchange_auth_code_for_tokens(auth_code, credentials, redirect_uri)
114
+ print_tokens(tokens)
115
+ end
116
+
117
+ private
118
+
119
+ def confirm_ready
120
+ puts "Before you can complete this setup you need to complete all the steps on Fortnox's side " \
121
+ "as documented in #{gem_homepage}/docs/getting_set_up.md"
122
+ print 'Do you have client ID, client secret and a list of scopes handy? [Y/n] '
123
+
124
+ confirmation = $stdin.gets.chomp.downcase
125
+
126
+ if confirmation == 'n'
127
+ puts 'Ok. Go read the guide, get setup as a developer in Fortnox and come back here when you are ready.'
128
+ exit 0
129
+ end
130
+
131
+ puts "Excellent, let's go! Input the information from Fortnox in the following prompts."
132
+ end
133
+
134
+ def get_client_id # rubocop:disable Naming/AccessorMethodName
135
+ print 'Client ID: '
136
+ $stdin.gets.chomp
137
+ end
138
+
139
+ def get_client_secret # rubocop:disable Naming/AccessorMethodName
140
+ print 'Client secret: '
141
+ $stdin.gets.chomp
142
+ end
143
+
144
+ def get_scopes # rubocop:disable Naming/AccessorMethodName
145
+ print 'Give a space separated list of all the scopes you will need. ' \
146
+ "See #{gem_homepage}/docs/scopes.md for reference.\n" \
147
+ 'Scopes: '
148
+ $stdin.gets.chomp
149
+ end
150
+
151
+ def confirm_redirect_uri(url)
152
+ print "Set the redirect URL in your Fortnox application to #{url}, then press enter to continue."
153
+ $stdin.gets
154
+ end
155
+
156
+ def get_auth_code(port, nonce) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
157
+ response = Server.start(port)
158
+
159
+ if response[:error]
160
+ puts "An error occured. Fortnox returned \"#{response[:error]}\":\n #{response[:description]}"
161
+ exit(-1)
162
+ end
163
+
164
+ normalized_state_response = CGI.unescape(response[:state]).gsub(' ', '+')
165
+
166
+ if normalized_state_response != nonce
167
+ puts 'The nonce returned from Fortnox did not match the one we sent, possible replay attack!'
168
+ puts "Raw sent: #{nonce.inspect}"
169
+ puts "Escaped sent: #{URI.encode_www_form({ nonce: }).inspect}"
170
+ puts "Raw returned: #{response[:state].inspect}"
171
+ puts "CGI escaped returned: #{CGI.unescape(response[:state]).inspect}"
172
+ puts "URI escaped returned: #{URI.decode_www_form(response[:state]).first.first.inspect}"
173
+ exit(-1)
174
+ end
175
+
176
+ response[:code]
177
+ rescue SocketError
178
+ puts "The local server failed to start so we can't catch the auth code automatically. " \
179
+ 'If you look in the addressbar of the missing page Fortnox redirected you to after you completed ' \
180
+ 'the authorisation you will see a request parameter called "code", paste the value below.'
181
+ print 'Auth code:'
182
+ $stdin.gets.chomp
183
+ end
184
+
185
+ def launch_fortnox_authorisation(url)
186
+ if (cmd = system_open)
187
+ system "#{cmd} \"#{url}\""
188
+ else
189
+ puts 'Could not identify a way to open default browser. ' \
190
+ 'Please open the following url in your prefered browser:'
191
+ puts url
192
+ end
193
+ end
194
+
195
+ def system_open
196
+ case RbConfig::CONFIG['host_os']
197
+ when /mswin|mingw|cygwin/
198
+ 'start'
199
+ when /darwin/
200
+ 'open'
201
+ when /linux|bsd/
202
+ 'xdg-open'
203
+ end
204
+ end
205
+
206
+ def exchange_auth_code_for_tokens(auth_code, credentials, redirect_uri)
207
+ headers = {
208
+ 'Content-type' => 'application/x-www-form-urlencoded',
209
+ 'Authorization' => "Basic #{credentials}"
210
+ }
211
+ body = "grant_type=authorization_code&code=#{auth_code}&redirect_uri=#{redirect_uri}"
212
+
213
+ response = Faraday.post('https://apps.fortnox.se/oauth-v1/token', body, headers)
214
+
215
+ JSON.parse(response.body).transform_keys(&:to_sym)
216
+ end
217
+
218
+ def print_tokens(tokens) # rubocop:disable Metrics/MethodLength
219
+ puts 'Save these tokens in an appropriate place:'
220
+ puts ''
221
+ puts "refresh token: #{tokens[:refresh_token]}"
222
+ puts ''
223
+ puts "access token: #{tokens[:access_token]}"
224
+ puts ''
225
+ puts 'You can run this command with all the inputs as parameters if you want to reauthorize ' \
226
+ 'without the guide in the future.'
227
+ puts ''
228
+ puts 'There is also a `fortnox refresh` command to run the refresh cycle manually ' \
229
+ 'for use in cron jobs or other automated token refresh scenarios. ' \
230
+ 'See `fortnox help` for more information.'
231
+ puts ''
232
+ end
233
+
234
+ def gem_homepage
235
+ @gem_homepage ||= gem_homepage_from_gemspec
236
+ end
237
+
238
+ def gem_homepage_from_gemspec
239
+ gem_root = File.expand_path('..', __dir__)
240
+ file = Dir.entries(gem_root).find do |f|
241
+ puts f
242
+ f.end_with? '.gemspec'
243
+ end
244
+ our_gemspec = Gem::Specification.load("#{gem_root}/#{file}")
245
+ our_gemspec.homepage
246
+ end
247
+ end
248
+
249
+ class Refresh < Dry::CLI::Command
250
+ desc 'Get a new set of tokens from Fortnox given a valid refresh token. ' \
251
+ 'If you do not already have a set of tokens you want `fortnox init` instead.'
252
+ argument :client_id, required: true, type: :string, desc: 'Client ID'
253
+ argument :client_secret, required: true, type: :string, desc: 'Client secret'
254
+ argument :refresh_token, required: true, type: :string, desc: 'Valid refresh token'
255
+
256
+ def call(client_id:, client_secret:, refresh_token:, **)
257
+ credentials = Base64.encode64("#{client_id}:#{client_secret}")
258
+ headers = {
259
+ 'Content-type' => 'application/x-www-form-urlencoded',
260
+ 'Authorization' => "Basic #{credentials}"
261
+ }
262
+ body = "grant_type=refresh_token&refresh_token=#{refresh_token}"
263
+
264
+ response = Faraday.post('https://apps.fortnox.se/oauth-v1/token', body, headers)
265
+
266
+ print_tokens(JSON.parse(response.body).transform_keys(&:to_sym))
267
+ end
268
+
269
+ def print_tokens(tokens)
270
+ puts ''
271
+ puts "refresh token: #{tokens[:refresh_token]}"
272
+ puts ''
273
+ puts "access token: #{tokens[:access_token]}"
274
+ puts ''
275
+ end
276
+ end
277
+
278
+ register 'version', Version, aliases: ['v', '-v', '--version']
279
+ register 'init', Init, aliases: ['i', '-i', '--init']
280
+ register 'refresh', Refresh, aliases: ['r', '-r', '--refresh']
281
+ end
282
+ end
283
+ end
284
+
285
+ Dry::CLI.new(Fortnox::CLI::Commands).call
data/docs/gotchas.md ADDED
@@ -0,0 +1,146 @@
1
+ # Gotchas
2
+ Fortnox API is not perfect. There are a couple of things to take into consideration when using it.
3
+
4
+ ## Article
5
+ If you create a new Article, the `SalesAccount` will default to the default sales account set in the Fortnox settings. An interesting thing though is that this account might not exist in the chart of accounts. Therefore, this strange thing can happen (requests and responses below are the raw HTTP requests/responses):
6
+
7
+ 1. You create an new Article via the API: `'{"Article":{"Description":"A value","SalesAccount":1250}}'` and you get an Article back that has `SalesAccount` set: `'{"Article":{"Description":"A Value", "SalesAccount":1250, "PurchaseAccount":4000, ...}}'`.
8
+ 2. You try to update the newly created Article with `'{"Article":{"Description":"Updated description"}}'` and you get this back:
9
+ `'{"ErrorInformation":{"error":1,"message":"Inköpskonto inte uppdaterat. Kontot \"4000\" existerar inte. (PurchaseAccount)", ...}}'`
10
+
11
+ We haven't even touched the `SalesAccount` but you still get an error. So the account that the API says that the Article has does not necessarily need to exist in "reality"...
12
+
13
+ ## HouseWorkType
14
+ ### Legacy types
15
+ Not all HouseWorkTypes available are possible to use when creating a new `Order` or `Invoice`. For instance, creating an `OrderRow` with `HouseWorkType` set to `COOKING` returns:
16
+ > Skattereduktion för en av de valda husarbetestyperna har upphört.
17
+
18
+ Unfortunately, their documentation does not tell you which types are deprecated... In fact, the deprecated types are simply removed from their documentation, so if you fetch an old `Order`/`Invoice`, you have no clue what HouseWorkType can be set to! There are two constants available in the gem: `CURRENT_HOUSEWORK_TYPES` and `LEGACY_HOUSEWORK_TYPES`, but we cannot guarantee that they are up to date and that they include all possible values.
19
+
20
+ ### OTHERCOSTS
21
+ Another weird thing is that the option `OTHERCOSTS` is not allowed to be combined with `HouseWork` attribute. Yes, that is true...
22
+
23
+ ## VAT on Invoices and Orders rows
24
+
25
+ If you create an Invoice with the following data:
26
+
27
+ ```
28
+ $ curl -X "POST" "https://api.fortnox.se/3/invoices" \
29
+ > (credentials...)
30
+ > -d $'{
31
+ > "Invoice": {
32
+ > "CustomerNumber": "188",
33
+ > "InvoiceRows": [
34
+ > {
35
+ > "ArticleNumber": "1",
36
+ > "DeliveredQuantity": "10.00",
37
+ > "VAT": "25"
38
+ > }
39
+ > ]
40
+ > }
41
+ > }'
42
+ ```
43
+
44
+ You get the following when you query the created Invoice:
45
+
46
+ ```
47
+ {"Invoice":
48
+ {...,
49
+ "InvoiceRows":[
50
+ {
51
+ ...
52
+ DeliveredQuantity":"10.00",
53
+ ...,
54
+ "Price":100,
55
+ "PriceExcludingVAT":100,
56
+ ...,
57
+ "Total":1000,
58
+ "TotalExcludingVAT":1000,
59
+ "Unit":"",
60
+ "VAT":25}
61
+ ]
62
+ ```
63
+
64
+ Notice that the `Price` and `PriceExcludingVAT` have the exact same amount. The same goes for `Total` and `TotalExcludingVAT`. Strange, right? The `VAT` is set to `25`%.
65
+
66
+ It works like this: `Invoice` (and `Order`) has a `VATIncluded` attribute. If it is set to `true`, `Price` and `Total` will be VAT included and `PriceExcludingVAT` and `TotalExcludingVAT` will obviously be VAT excluded. BUT if you set `VATIncluded` to false, all those attributes will be VAT exclusive and none inclusive. If you want to know the VAT for each row, then you have to calculate it yourself! I have suggested to Fortnox to fix this weird logic by adding a `TotalVAT` and `PriceWithVAT` that always holds the `VAT` amount. Then `Price` and `Total` can be VAT included/excluded depending on the value of `VATIncluded`. Fortnox said they will add this to their to do list...
67
+
68
+ ## Dependent attributes
69
+ Some attributes depends on other attributes.
70
+
71
+ ### `Invoice.InvoiceRows.Discount`
72
+ `Discount` have two different limits depending on `DiscountType`. Calling Fortnox with
73
+ ```
74
+ {
75
+ "Invoice":{
76
+ "CustomerNumber":"1",
77
+ "InvoiceRows":[
78
+ {"ArticleNumber":"0000","Discount":12.0001,"DiscountType":"PERCENT","Price":20.0}
79
+ ]
80
+ }
81
+ }
82
+ ```
83
+ gives an `InvoiceRow` with `"Discount":12,"DiscountType":"PERCENT"`
84
+
85
+ Calling Fortnox with
86
+ ```
87
+ {
88
+ "Invoice":{
89
+ "CustomerNumber":"1",
90
+ "InvoiceRows":[
91
+ {"ArticleNumber":"0000","Discount":123456.0,"DiscountType":"PERCENT","Price":20.0}
92
+ ]
93
+ }
94
+ }
95
+ ```
96
+ gives
97
+ ```
98
+ Fortnox::API::RemoteServerError:
99
+ Ogiltig rabatt. Får inte överstiga 100 %
100
+ ```
101
+
102
+ ## Types
103
+ ### Type of attribute DocumentNumber
104
+ `Customer` attribute `DocumentNumber` can be both an `Integer` and a `String` according to Fortnox... See [#78](https://github.com/my-codeworks/fortnox-api/issues/78).
105
+
106
+ ### Default values for Date attributes
107
+ The default values for date attributes from Fortnox API can be either an empty string or null. Fortnox is working on the issue. See [#79](https://github.com/my-codeworks/fortnox-api/issues/79).
108
+
109
+ ## Strange error messages
110
+ ### InternalError when creating Order without OrderRows
111
+ Creating an `Order` without `OrderRows` returns `InternalError`. Fortnox is working on this issue. See [#84](https://github.com/my-codeworks/fortnox-api/issues/84).
112
+
113
+ ### Kunde inte hitta konto.
114
+ If you create an `Order` or `Invoice` with an `OrderRow`/`InvoiceRow` without both `ArticleNumber` and `AccountNumber` Fortnox gives you this splendid message:
115
+ > Kunde inte hitta konto.
116
+
117
+ I think you can set a default account number for rows.
118
+
119
+ ## Fortnox rewrites attributes
120
+ ### TermsOfPayments
121
+ This model has a `code` attribute. `30days` is a valid attribute value, but the API rewrites this as `30DAYS` and a `GET` request with `30days` will return `Not Found`. This is not documented anywhere...
122
+
123
+ ## Not documented limitations
124
+ ### TermsOfPayments
125
+ The `code` attribute has no limits according to the documentations, but when sending `30 days` to the API, you get an error saying that the value must be alphanumeric.
126
+
127
+ ### Row description
128
+ The row description for `Invoice` (and I guess for `Offer` and `Order` as well) has a limit of 255 characters.
129
+
130
+ ## Consistency
131
+ ### Customer's SalesAccount attribute
132
+ If you create a `Customer` with the following JSON payload, with `SalesAccount` as a **string** just like the documentation says it should be
133
+ ```
134
+ {"Customer":{"Name":"Customer with Sales Account","SalesAccount":"3001"}}
135
+ ```
136
+ Fortnox returns `SalesAccount` as an **integer**...
137
+ ```
138
+ {"Customer":{"@url":"https:\/\/api.fortnox.se\/3\/customers\/242",..., "SalesAccount":3001}}
139
+ ```
140
+ When you then fetch **the same Customer you just created**, you get the `SalesAccount` as a **string**
141
+ ```
142
+ {"Customer": "@url":"https:\/\/api.fortnox.se\/3\/customers\/242", ..., "SalesAccount":"3001"}}
143
+ ```
144
+
145
+ ## Authentication
146
+ You can only have one active refresh token per Fortnox account and Fortnox integration. If you want multiple refresh tokens per Fortnox account you can create multiple integrations.
@@ -91,7 +91,7 @@ module Fortnox
91
91
  .constructor(EnumConstructors.default)
92
92
 
93
93
  Email = Strict::String
94
- .constrained(max_size: 1024, format: /^$|\A[[[:alnum:]]+-_.]+@[\w+-_.]+\.[a-z]+\z/i)
94
+ .constrained(max_size: 1024, format: /^$|\A[[[:alnum:]]._+-]+@[[[:alnum:]]._-]+\.[a-z]+\z/i)
95
95
  .optional
96
96
  .constructor { |v| v&.to_s&.downcase }
97
97
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Fortnox
4
4
  module API
5
- VERSION = '0.9.1'
5
+ VERSION = '0.9.2'
6
6
  end
7
7
  end
@@ -97,4 +97,23 @@ describe Fortnox::API::Repository::Customer, integration: true, order: :defined
97
97
  end
98
98
  end
99
99
  end
100
+
101
+ describe 'internationalized domain name email' do
102
+ context 'when saving a Customer with an IDN email address' do
103
+ subject(:customer) do
104
+ VCR.use_cassette("#{vcr_dir}/save_new_with_idn_email") do
105
+ repository.save(
106
+ described_class::MODEL.new(
107
+ name: 'Customer with IDN email',
108
+ email: 'user@teståäö.se'
109
+ )
110
+ )
111
+ end
112
+ end
113
+
114
+ it 'saves the email' do
115
+ expect(customer.email).to eq('user@teståäö.se')
116
+ end
117
+ end
118
+ end
100
119
  end
@@ -23,7 +23,8 @@ describe Fortnox::API::Types::Email do
23
23
  valid_emails = [
24
24
  'valid@example.com',
25
25
  'kanal_75_ab-faktura@mail.unit4agresso.readsoftonline.com',
26
- 'sköldpadda@example.com'
26
+ 'sköldpadda@example.com',
27
+ 'user@teståäö.se'
27
28
  ]
28
29
 
29
30
  valid_emails.each do |email|
@@ -32,7 +33,19 @@ describe Fortnox::API::Types::Email do
32
33
  end
33
34
 
34
35
  context 'when created with invalid email' do
35
- include_examples 'raises ConstraintError', 'te$£@st@example.com'
36
+ invalid_emails = [
37
+ 'te$£@st@example.com',
38
+ 'user@exam!ple.com',
39
+ 'user@exam<ple.com',
40
+ 'user@exam>ple.com',
41
+ 'user@exam=ple.com',
42
+ 'user@exam[ple.com',
43
+ 'user@exam]ple.com'
44
+ ]
45
+
46
+ invalid_emails.each do |email|
47
+ include_examples 'raises ConstraintError', email
48
+ end
36
49
  end
37
50
 
38
51
  context 'when created with more than 1024 characters' do
@@ -9,7 +9,8 @@ VCR.configure do |c|
9
9
  interaction.request.headers['Authorization']&.first
10
10
  end
11
11
  c.filter_sensitive_data('<REFRESH_TOKEN>') do |interaction|
12
- interaction.request.body.split('&refresh_token=').last
12
+ body = interaction.request.body
13
+ body&.split('&refresh_token=')&.last if body&.include?('&refresh_token=')
13
14
  end
14
15
  c.filter_sensitive_data('<ACCESS_TOKEN>') do |interaction|
15
16
  body = interaction.response.body
@@ -0,0 +1,67 @@
1
+ ---
2
+ http_interactions:
3
+ - request:
4
+ method: post
5
+ uri: https://api.fortnox.se/3/customers/
6
+ body:
7
+ encoding: UTF-8
8
+ string: '{"Customer":{"Email":"user@teståäö.se","Name":"Customer with IDN email"}}'
9
+ headers:
10
+ Content-Type:
11
+ - application/json
12
+ Accept:
13
+ - application/json
14
+ Authorization:
15
+ - "<AUTHORIZATION>"
16
+ Accept-Encoding:
17
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
18
+ User-Agent:
19
+ - Ruby
20
+ response:
21
+ status:
22
+ code: 201
23
+ message: Created
24
+ headers:
25
+ Content-Length:
26
+ - '1479'
27
+ Content-Type:
28
+ - application/json
29
+ Date:
30
+ - Thu, 22 Jan 2026 21:10:13 GMT
31
+ Location:
32
+ - customers
33
+ X-Build:
34
+ - 0a146b2789
35
+ X-Frame-Options:
36
+ - sameorigin
37
+ X-Krakend:
38
+ - Version 2.9.4
39
+ X-Krakend-Completed:
40
+ - 'false'
41
+ X-Rack-Responsetime:
42
+ - '227'
43
+ X-Uid:
44
+ - 5e6d835b
45
+ Server:
46
+ - Fortnox
47
+ X-Content-Type-Options:
48
+ - nosniff
49
+ X-Xss-Protection:
50
+ - '0'
51
+ Referrer-Policy:
52
+ - strict-origin-when-cross-origin
53
+ Content-Security-Policy:
54
+ - 'upgrade-insecure-requests;frame-ancestors https://*.fortnox.se;report-uri
55
+ /api/cspreport;connect-src ''self'' https://a.storyblok.com wss://*.fortnox.se
56
+ *.fortnox.se *.findity.com *.ingest.de.sentry.io mybusiness.pwc.se themes.googleusercontent.com
57
+ s3.amazonaws.com/helpjuice-static/ *.helpjuice.com *.vimeo.com fonts.googleapis.com
58
+ fonts.gstatic.com fortnox.piwik.pro api.cling.se wss://api.cling.se app.boardeaser.com
59
+ ''unsafe-inline'' ''unsafe-eval'' blob: data:'
60
+ Strict-Transport-Security:
61
+ - max-age=31536000; includeSubdomains
62
+ body:
63
+ encoding: UTF-8
64
+ string: '{"Customer":{"@url":"https:\/\/api.fortnox.se\/3\/customers\/2","Address1":null,"Address2":null,"City":null,"Country":null,"Comments":null,"Currency":"SEK","CostCenter":null,"CountryCode":null,"Active":true,"CustomerNumber":"2","DefaultDeliveryTypes":{"Invoice":"PRINT","Order":"PRINT","Offer":"PRINT"},"DefaultTemplates":{"Order":"DEFAULTTEMPLATE","Offer":"DEFAULTTEMPLATE","Invoice":"DEFAULTTEMPLATE","CashInvoice":"DEFAULTTEMPLATE"},"DeliveryAddress1":null,"DeliveryAddress2":null,"DeliveryCity":null,"DeliveryCountry":null,"DeliveryCountryCode":null,"DeliveryFax":null,"DeliveryName":null,"DeliveryPhone1":null,"DeliveryPhone2":null,"DeliveryZipCode":null,"Email":"user@test\u00e5\u00e4\u00f6.se","EmailInvoice":"","EmailInvoiceBCC":"","EmailInvoiceCC":"","EmailOffer":"","EmailOfferBCC":"","EmailOfferCC":"","EmailOrder":"","EmailOrderBCC":"","EmailOrderCC":"","ExternalReference":null,"Fax":null,"GLN":null,"GLNDelivery":null,"InvoiceAdministrationFee":null,"InvoiceDiscount":null,"InvoiceFreight":null,"InvoiceRemark":"","Name":"Customer
65
+ with IDN email","OrganisationNumber":"","OurReference":"","Phone1":null,"Phone2":null,"PriceList":"A","Project":"","SalesAccount":null,"ShowPriceVATIncluded":false,"TermsOfDelivery":"","TermsOfPayment":"","Type":"COMPANY","VATNumber":"","VATType":"SEVAT","VisitingAddress":null,"VisitingCity":null,"VisitingCountry":null,"VisitingCountryCode":null,"VisitingZipCode":null,"WayOfDelivery":"","WWW":"","YourReference":"","ZipCode":null}}'
66
+ recorded_at: Thu, 22 Jan 2026 21:10:13 GMT
67
+ recorded_with: VCR 6.2.0
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fortnox-api
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.1
4
+ version: 0.9.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonas Schubert Erlandsson
@@ -11,7 +11,7 @@ authors:
11
11
  autorequire:
12
12
  bindir: bin
13
13
  cert_chain: []
14
- date: 2025-01-08 00:00:00.000000000 Z
14
+ date: 2026-01-23 00:00:00.000000000 Z
15
15
  dependencies:
16
16
  - !ruby/object:Gem::Dependency
17
17
  name: countries
@@ -315,6 +315,7 @@ email:
315
315
  - info@accodeing.com
316
316
  executables:
317
317
  - console
318
+ - fortnox
318
319
  - get_tokens
319
320
  - renew_tokens
320
321
  extensions: []
@@ -329,6 +330,7 @@ files:
329
330
  - ".tool-versions"
330
331
  - ".travis.yml"
331
332
  - CHANGELOG.md
333
+ - CLAUDE.md
332
334
  - CONTRIBUTE.md
333
335
  - DEVELOPER_README.md
334
336
  - Gemfile
@@ -337,8 +339,10 @@ files:
337
339
  - README.md
338
340
  - Rakefile
339
341
  - bin/console
342
+ - bin/fortnox
340
343
  - bin/get_tokens
341
344
  - bin/renew_tokens
345
+ - docs/gotchas.md
342
346
  - fortnox-api.gemspec
343
347
  - lib/fortnox/api.rb
344
348
  - lib/fortnox/api/mappers.rb
@@ -522,6 +526,7 @@ files:
522
526
  - spec/vcr_cassettes/customers/multi_param_find_by_hash.yml
523
527
  - spec/vcr_cassettes/customers/save_new.yml
524
528
  - spec/vcr_cassettes/customers/save_new_with_country_code_SE.yml
529
+ - spec/vcr_cassettes/customers/save_new_with_idn_email.yml
525
530
  - spec/vcr_cassettes/customers/save_new_with_sales_account.yml
526
531
  - spec/vcr_cassettes/customers/save_old.yml
527
532
  - spec/vcr_cassettes/customers/save_with_specially_named_attribute.yml