fortnox-api 1.0.0.rc1 → 1.0.0.rc3

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: d4ab3f55b3783c477974abba27bae6b9104baf418e131532a746f822177605bd
4
- data.tar.gz: 882d8f34d8246f74052823894fd7286a146bdaa745155928059cad7abfe0d6ef
3
+ metadata.gz: 2297de9b35ce0c8d7f3f0e3b84f0a44a443062d257f843876657a6d26a4da160
4
+ data.tar.gz: b328a297acaa35278c2b943b10bc2dc04b7e8dfbf2dd65642346f35c572bb950
5
5
  SHA512:
6
- metadata.gz: 2e4a87ea6c15df6acce08ef2e3006f6cf4acd936a933e8449af585f5426f0a5f951e06f84a96ec34a044248519b013d2085b3c7ff8889e5016a4cc79af0074f1
7
- data.tar.gz: '09c2e017f7dc98c61565c0dc00b9d8ab35a8ea8ad90928e523f822c5f6bb84a2ebc74c75cbd1d02a4cb2ba8d58147f39286b0de05c656170a7f55ae6f4792971'
6
+ metadata.gz: a73b124f68428feaaccbfea406a025bb24995b7dce7e88959dee7aca21ab449e4c92cbae11808b33c50cf8f8e498d6039399157cdcc5e68c3bcb6cfce5a35b30
7
+ data.tar.gz: aa5b472af7edc87d4f808581ae99545e1b99f33d7319890843d442d9f2c7d1afe7db6fb04d509a5524d5b7f405337e0b6043d8155be1e5b3ebf2b8e83d3e75e9
data/CHANGELOG.md CHANGED
@@ -6,6 +6,61 @@ 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
+ ## [1.0.0.rc3] - 2026-05-08
10
+
11
+ ### Changed
12
+
13
+ - `Fortnox::RequestError#message` now includes the API's
14
+ `ErrorInformation.Message` and code from the response body when present,
15
+ normalising Fortnox's inconsistent key casing (PascalCase vs lowercase)
16
+ across endpoints. Previously the message was only `"Request failed: <status>"`,
17
+ hiding the cause from logs and uncaught backtraces.
18
+ - `Article#commodity_code`, `Customer#phone`, `Invoice#accounting_method`,
19
+ `Invoice#invoice_period_reference`, `Invoice#invoice_reference`,
20
+ `Document#time_basis_reference`, `Document#total_to_pay`, and
21
+ `Document#warehouse_ready` are now flagged read-only to match the Fortnox
22
+ API. Values set on these attributes are silently excluded from save
23
+ requests; previously they were sent and rejected by the API.
24
+
25
+ ### Fixed
26
+
27
+ - `Order` and `Invoice` rows now serialise the VAT field as `VAT` instead of
28
+ `Vat`. Saving rows with a `vat` value previously failed with Fortnox
29
+ rejecting the request as `"Felaktigt fältnamn"`. This bug was introduced in the 1.0.0.rc1,
30
+ it did not exist in 0.x.
31
+
32
+ ## [1.0.0.rc2] - 2026-05-05
33
+
34
+ ### Added
35
+
36
+ - Per-resource OAuth scope declarations via the `scope` setting in
37
+ `Fortnox::Resource`, plus `Fortnox.scopes` returning a
38
+ `{ scope_string => [resource_classes] }` mapping derived from the
39
+ registered resources.
40
+
41
+ ### Changed
42
+
43
+ - `fortnox-setup` lists the OAuth scopes covered by the gem's resources
44
+ and accepts a space-separated selection or `all`, replacing the
45
+ prescriptive default that didn't reflect the actual resource set. Other
46
+ Fortnox scopes (`salary`, `bookkeeping`, etc.) can still be entered
47
+ manually.
48
+ - `TermsOfPayment.code` now has a 25-character limit, matching Fortnox
49
+ API documentation.
50
+ - `Unit.code` now has a 20-character limit, matching Fortnox API
51
+ documentation.
52
+ - `Unit.description` is now `Sized::String[100]` and required, matching
53
+ Fortnox API documentation. In 0.x and rc1 this was nullable client-side,
54
+ but the Fortnox API rejected unset descriptions anyway.
55
+
56
+ ### Fixed
57
+
58
+ - `TermsOfPayment.code` is required again, matching Fortnox API
59
+ documentation. The rest-easy rewrite for rc1 briefly lost the required
60
+ flag.
61
+ - `Unit.code` is required again, matching Fortnox API documentation. The
62
+ rest-easy rewrite for rc1 briefly lost the required flag.
63
+
9
64
  ## [1.0.0.rc1] - 2026-05-04
10
65
 
11
66
  Version 1.0 is a complete rewrite of the gem and is **not** a drop-in
@@ -24,6 +79,14 @@ for the full list of breaking changes.
24
79
  - **Breaking** Authorization now uses the new Fortnox client credentials flow.
25
80
  Refresh tokens are no longer needed, nor supported. A tenant ID is now required;
26
81
  obtain one with the new `fortnox-setup` executable.
82
+ - **Breaking** Environment variables lose the `_API_` infix:
83
+ `FORTNOX_API_CLIENT_ID` → `FORTNOX_CLIENT_ID`, `FORTNOX_API_CLIENT_SECRET`
84
+ → `FORTNOX_CLIENT_SECRET`, `FORTNOX_API_ACCESS_TOKEN` →
85
+ `FORTNOX_ACCESS_TOKEN`. `FORTNOX_API_REFRESH_TOKEN`,
86
+ `FORTNOX_API_REDIRECT_URI`, and `FORTNOX_API_SCOPES` are removed —
87
+ refresh tokens are no longer supported, and the redirect URI and scopes
88
+ are now selected interactively in `fortnox-setup`. The new
89
+ `FORTNOX_TENANT_ID` is required for the client credentials flow.
27
90
  - **Breaking** `Fortnox.request_access_token` replaces
28
91
  `Fortnox::API::Repository::Authentication` for token management.
29
92
  - **Breaking** Configuration moves from `Fortnox::API.configuration` to
@@ -60,6 +123,16 @@ for the full list of breaking changes.
60
123
  `size`, `length`, `empty?`, `[]`, and `to_a`, so most existing Array
61
124
  usage works unchanged. Code that explicitly checks `is_a?(Array)` or
62
125
  compares with `==` against an Array literal needs updating.
126
+ - **Breaking** `Invoice.accounting_method` is now an enum accepting only
127
+ `''`, `'ACCRUAL'`, or `'CASH'`. In 0.x this was a free-form
128
+ `Nullable::String` so any value passed client-side.
129
+ - **Breaking** `Invoice.invoice_type` is now an enum accepting only `''`,
130
+ `'INVOICE'`, `'AGREEMENTINVOICE'`, `'INTRESTINVOICE'`, `'SUMMARYINVOICE'`,
131
+ or `'CASHINVOICE'`. In 0.x this was a free-form `Nullable::String`.
132
+ - `your_order_number` on Invoice and Order (inherited from Document) max
133
+ length raised from 30 to 75 characters to match the current Fortnox API.
134
+ - Article dimension fields (`depth`, `height`, `weight`, `width`) max raised
135
+ from 99,999,999 to 999,999,999 to match the documented Fortnox range.
63
136
 
64
137
  ### Added
65
138
 
@@ -94,3 +167,7 @@ for the full list of breaking changes.
94
167
 
95
168
  For changes prior to the 1.0 rewrite, see the
96
169
  [0.x changelog](https://github.com/accodeing/fortnox-api/blob/v0.9.2/CHANGELOG.md).
170
+
171
+ [1.0.0.rc3]: https://github.com/accodeing/fortnox-api/compare/v1.0.0.rc2...v1.0.0.rc3
172
+ [1.0.0.rc2]: https://github.com/accodeing/fortnox-api/compare/v1.0.0.rc1...v1.0.0.rc2
173
+ [1.0.0.rc1]: https://github.com/accodeing/fortnox-api/releases/tag/v1.0.0.rc1
data/README.md CHANGED
@@ -16,7 +16,7 @@ Adding more resources is quick and easy, see the
16
16
  ## Status
17
17
 
18
18
  Version 1.0 is a complete rewrite, currently in release candidate
19
- (`1.0.0.rc1`). It is built on
19
+ (`1.0.0.rc3`). It is built on
20
20
  [rest-easy](https://github.com/accodeing/rest-easy), replacing the old
21
21
  HTTParty + Data Mapper architecture with a single resource class per entity.
22
22
  Authorization uses the Fortnox client credentials flow.
@@ -35,21 +35,21 @@ snake_case in Ruby).
35
35
 
36
36
  ### Immutability
37
37
 
38
- The model instances are immutable. That means:
38
+ Resource instances are immutable. That means:
39
39
 
40
40
  ```ruby
41
- customer.model.name # => "Old Name"
42
- customer.model.name = 'New Name' # => NoMethodError
41
+ customer.name # => "Old Name"
42
+ customer.name = 'New Name' # => NoMethodError
43
43
  ```
44
44
 
45
45
  Any operation that updates state returns a new instance with the updated
46
46
  attributes while leaving the old instance alone:
47
47
 
48
48
  ```ruby
49
- customer.model.name # => "Old Name"
49
+ customer.name # => "Old Name"
50
50
  updated_customer = customer.update(name: 'New Name')
51
- updated_customer.model.name # => "New Name"
52
- customer.model.name # => "Old Name"
51
+ updated_customer.name # => "New Name"
52
+ customer.name # => "Old Name"
53
53
  ```
54
54
 
55
55
  This is how all resources work, they are all immutable.
@@ -68,8 +68,10 @@ The gem raises the following exceptions:
68
68
 
69
69
  - `Fortnox::Error` — base class for everything below. Rescue this to catch
70
70
  any error raised by the gem.
71
- - `Fortnox::RequestError` — 4xx/5xx responses from the Fortnox API. Carries
72
- the response object as `.response`.
71
+ - `Fortnox::RequestError` — 4xx/5xx responses from the Fortnox API. The
72
+ exception message includes the API's `ErrorInformation.Message` and code
73
+ when present (Fortnox is inconsistent about the key casing — the gem
74
+ normalises both). The full response is on `.response`.
73
75
  - `Fortnox::AttributeError` — base for attribute validation failures.
74
76
  - `Fortnox::ConstraintError` — an attribute value violates a type
75
77
  constraint (max size, format, etc.). Carries `.attribute_name` and
@@ -141,20 +143,26 @@ fortnox-setup
141
143
 
142
144
  The script will:
143
145
 
144
- 1. Ask for your client ID, client secret, and scopes
145
- 2. Offer to use a local server on `http://localhost:4242` to catch the
146
- authorization response automatically. If you choose this, set your Fortnox
147
- app's redirect URL to `http://localhost:4242`. Otherwise, enter your existing
148
- redirect URL and paste the authorization code manually.
149
- 3. Open your browser to the Fortnox authorization page
150
- 4. You log in to Fortnox and grant your app access
151
- 5. The script exchanges the authorization code for an access token and extracts
152
- the tenant ID from the JWT
153
- 6. The tenant ID is printed for you to store in your application's configuration
146
+ 1. Ask for your client ID and client secret.
147
+ 2. List the OAuth scopes covered by the gem's resources and ask which ones
148
+ you need. Enter a space-separated list, or `all` for everything the gem
149
+ supports. You can also enter scopes the gem doesn't expose directly
150
+ if you plan to call those endpoints manually.
151
+ 3. Offer to use a local server to catch the authorization response automatically.
152
+ If you choose this, set your Fortnox app's redirect URL to `http://localhost:4242`.
153
+ Otherwise, enter your existing redirect URL and paste the authorization code manually.
154
+ 4. Open your browser to the Fortnox authorization page.
155
+ 5. You log in to Fortnox and grant your app access.
156
+ 6. The script exchanges the authorization code for an access token and extracts
157
+ the tenant ID from the JWT.
158
+ 7. The tenant ID is printed for you to store in your application's configuration.
154
159
 
155
160
  After this you have a tenant ID and never need to run this script again (unless
156
161
  you need to authorize against a different Fortnox account).
157
162
 
163
+ Note: If you change the integration configuration in Fortnox it takes some time for
164
+ Fortnox to propagate the changes (for instance changing the Redirect URI or the scope).
165
+
158
166
  ### Requesting access tokens
159
167
 
160
168
  Once you have a tenant ID, you can request access tokens programmatically.
@@ -176,6 +184,11 @@ It is up to you to manage the token lifecycle in your application. A common
176
184
  approach is to request a new token before each batch of API calls, or to cache
177
185
  the token and refresh it when it expires.
178
186
 
187
+ Note: Fortnox only allows one active access token per integration. Requesting a
188
+ new token invalidates the previous one. If you need multiple active access
189
+ tokens in parallel, you need a separate integration (client ID and secret) for
190
+ each token.
191
+
179
192
  ### Updating access tokens in env files
180
193
 
181
194
  For development and testing, the gem includes an executable that reads your
@@ -228,11 +241,11 @@ returns alongside collection responses:
228
241
 
229
242
  ```ruby
230
243
  customers = Fortnox::Customer.all
231
- customers.first.model.name # => "Acme Corp"
232
- customers.size # => 50
233
- customers.total # => 327
234
- customers.pages # => 7
235
- customers.current_page # => 1
244
+ customers.first.name # => "Acme Corp"
245
+ customers.size # => 50
246
+ customers.total # => 327
247
+ customers.pages # => 7
248
+ customers.current_page # => 1
236
249
  ```
237
250
 
238
251
  `Collection` is `Enumerable`, so `.each`, `.map`, `.select`, `.first`, etc.
@@ -265,13 +278,13 @@ See the
265
278
  [Fortnox documentation](https://developer.fortnox.se/general/parameters/)
266
279
  for available parameters.
267
280
 
268
- The returned object wraps the model. Access attributes through `.model`:
281
+ Attributes are exposed directly on the returned instance:
269
282
 
270
283
  ```ruby
271
284
  customer = Fortnox::Customer.find(1)
272
- customer.model.name # => "Acme Corp"
273
- customer.model.city # => "Stockholm"
274
- customer.unique_id # => "1"
285
+ customer.name # => "Acme Corp"
286
+ customer.city # => "Stockholm"
287
+ customer.unique_id # => "1"
275
288
  ```
276
289
 
277
290
  ### Creating a record
@@ -281,7 +294,7 @@ Use `.stub` to build a new instance and `.save` to persist it:
281
294
  ```ruby
282
295
  customer = Fortnox::Customer.stub(name: 'Acme Corp', city: 'Stockholm')
283
296
  result = Fortnox::Customer.save(customer)
284
- result.model.customer_number # => "1"
297
+ result.customer_number # => "1"
285
298
  ```
286
299
 
287
300
  ### Updating a record
data/bin/fortnox-setup CHANGED
@@ -11,6 +11,7 @@ require 'securerandom'
11
11
  require 'socket'
12
12
  require 'uri'
13
13
  require 'faraday'
14
+ require 'fortnox'
14
15
 
15
16
  OAUTH_ENDPOINT = 'https://apps.fortnox.se/oauth-v1'
16
17
  LOCAL_PORT = 4242
@@ -108,10 +109,25 @@ puts
108
109
 
109
110
  client_id = prompt('Client ID')
110
111
  client_secret = prompt('Client secret')
112
+
113
+ puts
114
+ puts 'Available scopes:'
115
+ Fortnox.scopes.each do |scope, resources|
116
+ resource_names = resources.map { |r| r.name.split('::').last }.join(', ')
117
+ puts " #{scope.ljust(10)} → #{resource_names}"
118
+ end
119
+ puts
120
+ puts 'These must match the scopes configured on your Fortnox app in the developer'
121
+ puts 'portal. Other Fortnox scopes (salary, bookkeeping, etc.) can be added manually.'
111
122
  puts
112
- puts 'Enter the scopes you need. These must match the scopes configured on your'
113
- puts 'Fortnox app in the developer portal.'
114
- scopes = prompt('Scopes', default: 'article customer invoice order project settings')
123
+
124
+ scopes = loop do
125
+ input = prompt("Enter scopes (space-separated, or 'all')").strip
126
+ break Fortnox.scopes.keys.join(' ') if input == 'all'
127
+ break input unless input.empty?
128
+
129
+ puts 'Please enter at least one scope.'
130
+ end
115
131
 
116
132
  puts
117
133
  puts 'The script can catch the authorization response automatically using a'
@@ -6,7 +6,8 @@ module Fortnox
6
6
  struct Structs::DocumentRow
7
7
  overrides housework: 'HouseWork',
8
8
  housework_hours_to_report: 'HouseWorkHoursToReport',
9
- housework_type: 'HouseWorkType'
9
+ housework_type: 'HouseWorkType',
10
+ vat: 'VAT'
10
11
  end
11
12
  end
12
13
  end
@@ -7,8 +7,11 @@ module Fortnox
7
7
  settings do
8
8
  setting :instance_wrapper, reader: true
9
9
  setting :collection_wrapper, reader: true
10
+ setting :scope, reader: true
10
11
  end
11
12
 
13
+ @registered_resources = []
14
+
12
15
  before_parse do |data, meta|
13
16
  if data.key?(config.instance_wrapper)
14
17
  meta.partial = false
@@ -38,6 +41,13 @@ module Fortnox
38
41
  end
39
42
 
40
43
  class << self
44
+ attr_reader :registered_resources
45
+
46
+ def inherited(subclass)
47
+ super
48
+ Fortnox::Resource.registered_resources << subclass
49
+ end
50
+
41
51
  def parse(response)
42
52
  with_translated_errors do
43
53
  pagination = extract_pagination(response)
@@ -8,6 +8,7 @@ module Fortnox
8
8
  path 'articles'
9
9
  instance_wrapper 'Article'
10
10
  collection_wrapper 'Articles'
11
+ scope 'article'
11
12
  end
12
13
 
13
14
  # @url Direct URL to the record.
@@ -154,6 +155,6 @@ module Fortnox
154
155
  attr :default_stock_location, Coercible::String.optional
155
156
 
156
157
  # CommodityCode Commodity code of the article.
157
- attr :commodity_code, Coercible::String.optional
158
+ attr :commodity_code, Coercible::String.optional, :read_only
158
159
  end
159
160
  end
@@ -8,6 +8,7 @@ module Fortnox
8
8
  path 'customers'
9
9
  instance_wrapper 'Customer'
10
10
  collection_wrapper 'Customers'
11
+ scope 'customer'
11
12
  end
12
13
 
13
14
  # Direct URL to the record.
@@ -130,6 +131,9 @@ module Fortnox
130
131
  # Our reference
131
132
  attr :our_reference, Sized::String[50]
132
133
 
134
+ # Phone number of the customer. Only present in collection responses.
135
+ attr :phone, Coercible::String.optional, :read_only
136
+
133
137
  # First phone number of the customer
134
138
  attr :phone1, Sized::String[1024]
135
139
 
@@ -190,9 +194,6 @@ module Fortnox
190
194
  # Active If the customer is active.
191
195
  attr :active, Bool.optional, Boolean
192
196
 
193
- # Phone number of the customer. Only present in collection responses.
194
- attr :phone, Coercible::String.optional
195
-
196
197
  # External reference
197
198
  attr :external_reference, Sized::String[1024]
198
199
 
@@ -191,12 +191,12 @@ module Fortnox
191
191
  attr :outbound_date, Date.optional, Mappers::Date
192
192
 
193
193
  # TimeBasisReference Reference to time basis.
194
- attr :time_basis_reference, Coercible::Integer.optional
194
+ attr :time_basis_reference, Coercible::Integer.optional, :read_only
195
195
 
196
196
  # TotalToPay Total amount to pay.
197
- attr :total_to_pay, Coercible::Float.optional
197
+ attr :total_to_pay, Coercible::Float.optional, :read_only
198
198
 
199
199
  # WarehouseReady If the document is warehouse ready.
200
- attr :warehouse_ready, Bool.optional, Boolean
200
+ attr :warehouse_ready, Bool.optional, :read_only, Boolean
201
201
  end
202
202
  end
@@ -8,10 +8,11 @@ module Fortnox
8
8
  path 'invoices'
9
9
  instance_wrapper 'Invoice'
10
10
  collection_wrapper 'Invoices'
11
+ scope 'invoice'
11
12
  end
12
13
 
13
14
  # AccountingMethod Accounting Method.
14
- attr :accounting_method, AccountingMethods
15
+ attr :accounting_method, AccountingMethods, :read_only
15
16
 
16
17
  # Balance Balance of the invoice.
17
18
  attr :balance, Coercible::Float.optional, :read_only
@@ -50,10 +51,10 @@ module Fortnox
50
51
  attr :invoice_period_end, Date.optional, :read_only, Mappers::Date
51
52
 
52
53
  # InvoicePeriodReference Reference to the invoice period.
53
- attr :invoice_period_reference, Coercible::String.optional
54
+ attr :invoice_period_reference, Coercible::String.optional, :read_only
54
55
 
55
56
  # InvoiceReference Reference to another invoice.
56
- attr :invoice_reference, Coercible::String.optional
57
+ attr :invoice_reference, Coercible::String.optional, :read_only
57
58
 
58
59
  # InvoiceRows Separate object
59
60
  attr :invoice_rows, Strict::Array.of(Structs::InvoiceRow), Mappers::StructArray.for(Mappers::InvoiceRow)
@@ -6,6 +6,7 @@ module Fortnox
6
6
  path 'labels'
7
7
  instance_wrapper 'Label'
8
8
  collection_wrapper 'Labels'
9
+ scope 'settings'
9
10
  end
10
11
 
11
12
  attr :id, Coercible::Integer.optional, :read_only, :key
@@ -8,6 +8,7 @@ module Fortnox
8
8
  path 'orders'
9
9
  instance_wrapper 'Order'
10
10
  collection_wrapper 'Orders'
11
+ scope 'order'
11
12
  end
12
13
 
13
14
  # CopyRemarks If remarks shall be copied from order to invoice
@@ -8,6 +8,7 @@ module Fortnox
8
8
  path 'projects'
9
9
  instance_wrapper 'Project'
10
10
  collection_wrapper 'Projects'
11
+ scope 'project'
11
12
  end
12
13
 
13
14
  # @url Direct URL to the record.
@@ -8,13 +8,14 @@ module Fortnox
8
8
  path 'termsofpayments'
9
9
  instance_wrapper 'TermsOfPayment'
10
10
  collection_wrapper 'TermsOfPayments'
11
+ scope 'settings'
11
12
  end
12
13
 
13
14
  # @url Direct URL to the record.
14
15
  attr :url <=> '@url', Coercible::String.optional, :read_only
15
16
 
16
17
  # Code The code of the term of payment.
17
- key :code, Strict::String
18
+ key :code, Sized::String[25], :required
18
19
 
19
20
  # Description The description of the term of payment.
20
21
  attr :description, Strict::String, :required
@@ -8,16 +8,17 @@ module Fortnox
8
8
  path 'units'
9
9
  instance_wrapper 'Unit'
10
10
  collection_wrapper 'Units'
11
+ scope 'settings'
11
12
  end
12
13
 
13
14
  # @url Direct URL to the record.
14
15
  attr :url <=> '@url', Coercible::String.optional, :read_only
15
16
 
16
17
  # Code The code of the unit.
17
- key :code, Strict::String
18
+ key :code, Sized::String[20], :required
18
19
 
19
20
  # Description The description of the unit.
20
- attr :description, Coercible::String.optional
21
+ attr :description, Sized::String[100], :required
21
22
 
22
23
  # CodeEnglish English code of the unit
23
24
  attr :code_english, Sized::String[100]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fortnox
4
- VERSION = '1.0.0.rc1'
4
+ VERSION = '1.0.0.rc3'
5
5
  end
data/lib/fortnox.rb CHANGED
@@ -5,14 +5,14 @@ require 'json'
5
5
  require 'rest_easy'
6
6
  require 'zeitwerk'
7
7
 
8
- loader = Zeitwerk::Loader.for_gem
9
- loader.collapse("#{__dir__}/fortnox/resources")
10
- loader.inflector.inflect(
11
- 'edi_information' => 'EDIInformation'
12
- )
13
- loader.setup
14
-
15
8
  module Fortnox
9
+ @loader = Zeitwerk::Loader.for_gem
10
+ @loader.collapse("#{__dir__}/fortnox/resources")
11
+ @loader.inflector.inflect(
12
+ 'edi_information' => 'EDIInformation'
13
+ )
14
+ @loader.setup
15
+
16
16
  extend RestEasy
17
17
 
18
18
  class Error < StandardError; end
@@ -23,11 +23,49 @@ module Fortnox
23
23
  def initialize(arg = nil)
24
24
  if arg.respond_to?(:status)
25
25
  @response = arg
26
- super("Request failed: #{arg.status}")
26
+ super("Request failed: #{arg.status}#{format_body(arg.body)}")
27
27
  else
28
28
  super
29
29
  end
30
30
  end
31
+
32
+ BODY_FALLBACK_LIMIT = 500
33
+ private_constant :BODY_FALLBACK_LIMIT
34
+
35
+ private
36
+
37
+ def format_body(body)
38
+ return '' if body.nil? || body.empty?
39
+
40
+ " - #{error_details(body) || truncate(body.to_s)}"
41
+ end
42
+
43
+ # Fortnox have at least in the past sometimes returned HTML responses on error,
44
+ # for instance 503 Service Temporarily Unavailable.
45
+ # In that case, the body might be long and useful for debugging,
46
+ # so let's truncate it to a reasonable length if we end up here.
47
+ def truncate(string)
48
+ string.length > BODY_FALLBACK_LIMIT ? "#{string[0, BODY_FALLBACK_LIMIT]}…" : string
49
+ end
50
+
51
+ def error_details(body)
52
+ info = error_information(body)
53
+ message = info && info['message']
54
+ return nil unless message
55
+
56
+ code = info['code']
57
+ code ? "#{message} (#{code})" : message
58
+ end
59
+
60
+ def error_information(body)
61
+ parsed = body.is_a?(String) ? JSON.parse(body) : body
62
+ info = parsed['ErrorInformation'] if parsed.is_a?(Hash)
63
+ # Fortnox responds with inconsistently-cased error keys (see tests),
64
+ # so let's normalise to lowercase before reading.
65
+ info.is_a?(Hash) ? info.transform_keys(&:downcase) : nil
66
+ rescue JSON::ParserError
67
+ nil
68
+ end
31
69
  end
32
70
 
33
71
  class AttributeError < Error; end
@@ -76,6 +114,15 @@ module Fortnox
76
114
  parsed['access_token']
77
115
  end
78
116
 
117
+ def scopes
118
+ @loader.eager_load
119
+ Resource.registered_resources
120
+ .group_by(&:scope)
121
+ .reject { |scope, _| scope.nil? }
122
+ .sort
123
+ .to_h
124
+ end
125
+
79
126
  private
80
127
 
81
128
  def token_request(client_id, client_secret, tenant_id, scopes)
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: 1.0.0.rc1
4
+ version: 1.0.0.rc3
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: 2026-05-04 00:00:00.000000000 Z
14
+ date: 2026-05-08 00:00:00.000000000 Z
15
15
  dependencies:
16
16
  - !ruby/object:Gem::Dependency
17
17
  name: countries