fortnox-api 1.0.0.rc13 → 1.0.0.rc15

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: 695db94ae3937bdcc80a1737e65b730cc58dd6784b79eedf6296490b716ab31a
4
- data.tar.gz: 3a25be836c8d7251578d71b40364c122673176c89b878a7a8ed21d68c0eb2377
3
+ metadata.gz: ab7ad12aa41343980f10d5669c15e92c00ffe1b144f3e2d7eb250205c8f1ddaf
4
+ data.tar.gz: b88c8debe218e7e0bfcfe8f89ae3c14ac5eaf7f0c9466563c23dd7d13a153b22
5
5
  SHA512:
6
- metadata.gz: c8fb345adff538fbb9e0d1db6c0d653bebbfa35014ecb0b78e7ee5940c9f8f6f77eab563a2378f2d0827822a44a56fe0bf0fffc06e4c961e84850ae07b83adf9
7
- data.tar.gz: '05914af9563412c342711d69418462bbd47335ea09aa46d13bf581906fd80a81fea2d1b997dc5a27f61c98b54f705637f502c4b44c3d66fcc78aea8c0368dd5c'
6
+ metadata.gz: 3e3580f904dc0ab0b6ade2d540e858bd6e4b282c7589508c32431da156f12634365283ed28bd1e117d81f84f8081596612b9f6ae8736e8c8063b3d11b6f2b653
7
+ data.tar.gz: b9150f40236d1f2c22d3410f51e774b424af4612f905d3e63174c1f333b4b5b85b474648e0ac3e6999e2c243cc1994b71bd5b89c02146769644a0099b30f887f
data/CHANGELOG.md CHANGED
@@ -8,11 +8,98 @@ and this project adheres to
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.0.0.rc15] - 2026-08-18
12
+
13
+ ### Fixed
14
+
15
+ - **Cached resources no longer raise on the first cache hit.** The
16
+ `rest-easy` dependency is now `~> 1.4.1`, which stops `RestEasy::Meta`
17
+ claiming to implement methods it has no value for. Because it claimed
18
+ `marshal_dump`, marshalling a parsed resource silently wrote `nil` where
19
+ the meta state should be, and reading it back raised `NoMethodError:
20
+ undefined method '[]' for nil:NilClass`. Every parsed Fortnox resource
21
+ carries a `meta` — it is where the `partial` flag lives — so any consumer
22
+ caching resources in a store that marshals its entries (Rails'
23
+ `:memory_store` and `:file_store` among them) wrote an unusable entry on
24
+ the first request and raised on every cache hit after that.
25
+
26
+ **Upgrading with a warm cache:** entries written before this release are
27
+ not recoverable and now fail with `TypeError: instance of RestEasy::Meta
28
+ needs to have method 'marshal_load'`. Flush the cache or bump its key
29
+ namespace when upgrading. A process-local `:memory_store` clears itself on
30
+ restart and needs nothing.
31
+
32
+ ## [1.0.0.rc14] - 2026-08-11
33
+
34
+ ### Changed
35
+
36
+ - The `base64` runtime dependency is now bounded to `~> 0.2`, matching what
37
+ `rest-easy` already requires. It was declared open-ended when it was added
38
+ in 1.0.0.rc7, so a future `base64` 1.0 with a breaking change would have
39
+ been resolved into consumers' bundles unannounced.
40
+
41
+ ### Added
42
+
43
+ - **Breaking** `Fortnox::UnknownAttributeError`, raised when `new`, `stub` or
44
+ `update` is passed an attribute the resource or struct doesn't declare.
45
+ Previously the attribute was silently discarded: the request went out
46
+ without it and Fortnox accepted the result, so a typo cost a field with no
47
+ indication anything was wrong. Worst on nested rows, where
48
+ `stub(order_rows: [{ artcile_number: '1' }])` sent `"OrderRows":[{}]` and
49
+ created an order with empty rows. Subclasses `Fortnox::AttributeError` and
50
+ carries `.attribute_names` (all of them) and `.attribute_name` (the first).
51
+ Consumers passing a wider hash than the resource declares must slice it
52
+ first. Parsing an API response is unaffected and stays tolerant of fields
53
+ the gem doesn't declare — Fortnox adds them over time, and a response must
54
+ not fail to parse because of one.
55
+ - Attribute hashes now accept string keys as well as symbols, on `new`,
56
+ `stub`, `update` and struct constructors. Previously a string key matched
57
+ nothing and was dropped, which is how a Rails controller passing `params`
58
+ through produced records missing every field.
59
+ - Optional Rails integration, `require 'fortnox/rails'`. Defines `as_json` on
60
+ resources, collections and nested structs so `render json:` works when they
61
+ are nested inside another structure — ActiveSupport walks nested objects
62
+ with `as_json`, and without a definition falls through to `Object#as_json`,
63
+ which serialises instance variables and leaks `api_data`,
64
+ `model_attributes`, `changes` and `meta` into response bodies. The file is
65
+ not loaded with the rest of the gem and ActiveSupport is not a runtime
66
+ dependency; requiring it is opt-in for apps that already have Rails.
67
+
68
+ ### Fixed
69
+
70
+ - `to_json` on a resource holding nested structs no longer renders them
71
+ through `to_s`. An invoice or order with rows serialised them as
72
+ `"#<Fortnox::Structs::InvoiceRow:0x…>"`. `Fortnox::Struct#to_json` and
73
+ `Fortnox::Collection#to_json` are defined for the same reason — both
74
+ previously fell back to the default `Object#to_json`, producing an
75
+ inspect string rather than JSON. All three render the model representation
76
+ (snake_case attribute names); `to_api` still produces the Fortnox wire
77
+ format. This affects all consumers, not only Rails apps.
78
+ - Nested structs (`Fortnox::Structs::*`) now coerce boolean attributes from
79
+ the string spellings params arrive as (`'true'`, `'false'`, `'yes'`,
80
+ `'no'`, `'1'`, `'0'`, `'on'`, `'off'`), matching what resource attributes
81
+ have always accepted. Previously the struct-level type was strict and
82
+ rejected every string, so `Order.stub(order_rows: [{ housework: 'true' }])`
83
+ failed while the equivalent value on a resource attribute coerced cleanly
84
+ — callers passing Rails controller params had to cast booleans by hand
85
+ before building rows. Affects `DocumentRow#housework` and the `InvoiceRow`
86
+ and `OrderRow` subclasses. This asymmetry was introduced in 1.0.0.rc1 and
87
+ did not exist in 0.x.
88
+ - Struct construction now raises Fortnox-namespaced errors. A bad value in a
89
+ nested struct — reached directly via `Fortnox::Structs::OrderRow.new` or
90
+ indirectly via `stub`/`update` with a nested hash — previously raised
91
+ `Dry::Struct::Error`, which is a `TypeError` outside the `Fortnox::Error`
92
+ hierarchy, so `rescue Fortnox::AttributeError` blocks didn't catch it. It
93
+ now raises `Fortnox::ConstraintError` carrying `.attribute_name` and
94
+ `.value`, with the same message format as the resource-level error. This
95
+ extends the error translation added in 1.0.0.rc13, which covered resources
96
+ but not structs.
97
+
11
98
  ## [1.0.0.rc13] - 2026-08-04
12
99
 
13
100
  ### Changed
14
101
 
15
- - String attributes now normalise `''` to `nil` at the type level. This
102
+ - **Breaking** String attributes now normalise `''` to `nil` at the type level. This
16
103
  is the mechanism behind the reset fix below: Fortnox silently ignores
17
104
  empty strings in update payloads — updating an attribute to `''` kept
18
105
  the original value — and a field can only be cleared with an explicit
@@ -332,7 +419,9 @@ for the full list of breaking changes.
332
419
  For changes prior to the 1.0 rewrite, see the
333
420
  [0.x changelog](https://github.com/accodeing/fortnox-api/blob/v0.9.2/CHANGELOG.md).
334
421
 
335
- [Unreleased]: https://github.com/accodeing/fortnox-api/compare/v1.0.0.rc13...HEAD
422
+ [Unreleased]: https://github.com/accodeing/fortnox-api/compare/v1.0.0.rc15...HEAD
423
+ [1.0.0.rc15]: https://github.com/accodeing/fortnox-api/compare/v1.0.0.rc14...v1.0.0.rc15
424
+ [1.0.0.rc14]: https://github.com/accodeing/fortnox-api/compare/v1.0.0.rc13...v1.0.0.rc14
336
425
  [1.0.0.rc13]: https://github.com/accodeing/fortnox-api/compare/v1.0.0.rc12...v1.0.0.rc13
337
426
  [1.0.0.rc12]: https://github.com/accodeing/fortnox-api/compare/v1.0.0.rc11...v1.0.0.rc12
338
427
  [1.0.0.rc11]: https://github.com/accodeing/fortnox-api/compare/v1.0.0.rc10...v1.0.0.rc11
data/README.md CHANGED
@@ -397,6 +397,58 @@ hitting some other schema-drift issue — please open an issue. There's a
397
397
  maintainer-only knob to surface those warnings; see the
398
398
  [Developer readme](DEVELOPER_README.md) for details.
399
399
 
400
+ ### Rails
401
+
402
+ Two things are worth setting up before you render or receive Fortnox data in
403
+ a Rails app.
404
+
405
+ **Rendering.** Require the Rails integration once, in an initializer:
406
+
407
+ ```ruby
408
+ # config/initializers/fortnox.rb
409
+ require 'fortnox/rails'
410
+ ```
411
+
412
+ Without it, `render json:` only works for a resource passed on its own.
413
+ Rendering one *inside* another structure walks it with `as_json`, which the
414
+ gem's types don't define outside this file, so ActiveSupport falls back to
415
+ serialising instance variables and your response body gets the gem's
416
+ internals:
417
+
418
+ ```ruby
419
+ # Without fortnox/rails
420
+ render json: { invoices: [Fortnox::Invoice.find(1)] }
421
+ # => {"invoices":[{"api_data":{…},"model_attributes":{…},"changes":[…],"meta":{…}}]}
422
+
423
+ # With it
424
+ # => {"invoices":[{"document_number":1,"invoice_rows":[{"article_number":"101"}],…}]}
425
+ ```
426
+
427
+ It covers resources, collections, and nested structs, and produces the same
428
+ representation as `to_json` — model attribute names, not the Fortnox wire
429
+ names. Rendering a collection yields its records, as an array would;
430
+ pagination stays on the object (`.total`, `.pages`, `.current_page`) for you
431
+ to render alongside if you want it.
432
+
433
+ The file is not loaded with the rest of the gem and ActiveSupport is not a
434
+ dependency of this gem — requiring it is opt-in, for apps that already have
435
+ Rails.
436
+
437
+ **Receiving params.** Controller params can be passed through as they arrive.
438
+ String keys are accepted, and string values coerce — booleans from
439
+ `'true'`/`'false'`/`'1'`/`'0'`/`'yes'`/`'no'`/`'on'`/`'off'`, numbers from
440
+ their string forms:
441
+
442
+ ```ruby
443
+ Fortnox::Order.stub('customer_number' => '1', 'order_rows' => [{ 'article_number' => '101' }])
444
+ ```
445
+
446
+ An attribute the resource or struct doesn't declare raises
447
+ `Fortnox::UnknownAttributeError` rather than being dropped, so a typo or a
448
+ stray param surfaces at the call site instead of producing a record that is
449
+ quietly missing a field. Filter params to the attributes you mean to accept,
450
+ as you would for a model.
451
+
400
452
  ### Gotchas
401
453
 
402
454
  See [docs/gotchas.md](docs/gotchas.md) for known quirks and edge cases in the
data/fortnox.gemspec CHANGED
@@ -21,10 +21,10 @@ Gem::Specification.new do |spec|
21
21
 
22
22
  spec.required_ruby_version = '>= 3.2.0'
23
23
 
24
- spec.add_dependency 'base64'
24
+ spec.add_dependency 'base64', '~> 0.2'
25
25
  spec.add_dependency 'countries', '~> 7.1'
26
26
  spec.add_dependency 'dry-struct', '~> 1.5'
27
- spec.add_dependency 'rest-easy', '~> 1.4.0'
27
+ spec.add_dependency 'rest-easy', '~> 1.4.1'
28
28
 
29
29
  spec.metadata['rubygems_mfa_required'] = 'true'
30
30
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fortnox
4
+ # Checks the attribute names a caller passes to `new`, `stub` and `update`.
5
+ #
6
+ # Both rest-easy and Dry::Struct build from the attributes they recognise and
7
+ # ignore everything else, so a typo or a string key used to vanish without a
8
+ # word: the request went out missing that field and Fortnox accepted it. The
9
+ # bigger the payload, the easier that is to miss.
10
+ #
11
+ # This only guards data a caller supplies. Parsing an API response stays
12
+ # tolerant of fields the gem doesn't declare — Fortnox adds them over time,
13
+ # and a response must not fail to parse because of one.
14
+ module AttributeKeys
15
+ module_function
16
+
17
+ def check(data, known:, subject:)
18
+ normalised = normalise(data)
19
+ unknown = normalised.keys - known
20
+ raise Fortnox::UnknownAttributeError.new(unknown, subject) unless unknown.empty?
21
+
22
+ normalised
23
+ end
24
+
25
+ # Rails hands params through with string keys. Accept them rather than
26
+ # reporting every attribute in the hash as unknown.
27
+ def normalise(data)
28
+ data.to_h { |key, value| [symbolise(key), value] }
29
+ end
30
+
31
+ # Left alone if it can't be a symbol, so it falls through to the unknown
32
+ # check and is reported as an attribute error rather than a NoMethodError.
33
+ def symbolise(key)
34
+ key.respond_to?(:to_sym) ? key.to_sym : key
35
+ end
36
+ end
37
+ end
@@ -7,6 +7,7 @@ module Fortnox
7
7
  # the pagination metadata Fortnox returns alongside collection responses.
8
8
  class Collection
9
9
  include Enumerable
10
+ include Serialisation::CollectionJSON
10
11
  extend Forwardable
11
12
 
12
13
  def_delegators :@items, :each, :first, :last, :size, :length, :empty?, :[], :to_a
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fortnox
4
+ # Translates rest-easy errors at the gem boundary so callers only see
5
+ # Fortnox-namespaced exceptions. Mixed into Fortnox::Resource both ways —
6
+ # `include` for the instance methods, `extend` for the class-level ones.
7
+ module ErrorTranslation
8
+ private
9
+
10
+ def with_translated_errors
11
+ yield
12
+ rescue RestEasy::ConstraintError => e
13
+ raise Fortnox::ConstraintError.new(e.attribute_name, e.value, e.message)
14
+ rescue RestEasy::MissingAttributeError => e
15
+ raise Fortnox::MissingAttributeError, e.attribute_name
16
+ rescue RestEasy::AttributeError => e
17
+ raise Fortnox::AttributeError, e.message
18
+ rescue RestEasy::RequestError => e
19
+ raise Fortnox::RequestError, e.response || e.message
20
+ end
21
+ end
22
+ end
@@ -33,7 +33,11 @@ module Fortnox
33
33
  attributes = data.transform_keys do |api_key|
34
34
  api_to_model_map[api_key] || CONVENTION.parse(api_key)
35
35
  end
36
- struct_class.new(attributes)
36
+ # Drop fields the struct doesn't declare. Fortnox adds them over
37
+ # time, and a response must not fail to parse because of one — the
38
+ # strictness on `new` is aimed at what a caller passes, not at what
39
+ # the API returns.
40
+ struct_class.new(attributes.slice(*struct_class.attribute_names))
37
41
  end
38
42
 
39
43
  def serialise(struct)
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fortnox'
4
+ require 'active_support/json'
5
+
6
+ # Optional Rails integration. Not loaded with the rest of the gem — require it
7
+ # explicitly, e.g. from config/initializers/fortnox.rb:
8
+ #
9
+ # require 'fortnox/rails'
10
+ #
11
+ # Rails serialises nested objects by calling `as_json` on them. Anything that
12
+ # doesn't define it falls through to `Object#as_json`, which dumps instance
13
+ # variables — so without this, `render json: { invoices: [invoice] }` puts the
14
+ # gem's internals (`api_data`, `model_attributes`, `changes`, `meta`) in the
15
+ # response body. `to_json` needs no help here; it is correct without Rails.
16
+ module Fortnox
17
+ # Deliberately not named `Rails`: inside `module Fortnox` that constant would
18
+ # shadow the framework's own for every other file in the gem.
19
+ module RailsSerialisation
20
+ # `:only` / `:except` are applied by Fortnox::Serialisation rather than by
21
+ # ActiveSupport, so they accept string and symbol names alike. The inner
22
+ # `as_json` is then called without options — it only needs to stringify
23
+ # keys and recurse into nested structs.
24
+ module ResourceMethods
25
+ # Same representation `to_json` produces: model attribute names.
26
+ def as_json(options = nil)
27
+ Serialisation.filter(model.attributes, options).as_json
28
+ end
29
+ end
30
+
31
+ module StructMethods
32
+ def as_json(options = nil)
33
+ Serialisation.filter(to_h, options).as_json
34
+ end
35
+ end
36
+
37
+ module CollectionMethods
38
+ def as_json(options = nil)
39
+ to_a.map { |item| item.as_json(options) }
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ Fortnox::Resource.include(Fortnox::RailsSerialisation::ResourceMethods)
46
+ Fortnox::Struct.include(Fortnox::RailsSerialisation::StructMethods)
47
+ Fortnox::Collection.include(Fortnox::RailsSerialisation::CollectionMethods)
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fortnox
4
+ # TODO: this class is still over the length limit even with error
5
+ # translation extracted — see todo.md.
6
+ # rubocop:disable Metrics/ClassLength
4
7
  class Resource < RestEasy::Resource
5
8
  include Fortnox::Types
9
+ include Serialisation::ResourceJSON
6
10
 
7
11
  settings do
8
12
  setting :instance_wrapper, reader: true
@@ -26,24 +30,6 @@ module Fortnox
26
30
 
27
31
  after_serialise { |data| default_after_serialise(data) }
28
32
 
29
- # Translate rest-easy errors at the gem boundary so callers only see
30
- # Fortnox-namespaced exceptions.
31
- module ErrorTranslation
32
- private
33
-
34
- def with_translated_errors
35
- yield
36
- rescue RestEasy::ConstraintError => e
37
- raise Fortnox::ConstraintError.new(e.attribute_name, e.value, e.message)
38
- rescue RestEasy::MissingAttributeError => e
39
- raise Fortnox::MissingAttributeError, e.attribute_name
40
- rescue RestEasy::AttributeError => e
41
- raise Fortnox::AttributeError, e.message
42
- rescue RestEasy::RequestError => e
43
- raise Fortnox::RequestError, e.response || e.message
44
- end
45
- end
46
-
47
33
  include ErrorTranslation # instance-level methods
48
34
  extend ErrorTranslation # class-level methods
49
35
 
@@ -63,12 +49,12 @@ module Fortnox
63
49
  end
64
50
  end
65
51
 
66
- def new(...)
67
- with_translated_errors { super }
52
+ def new(model_data = {}, **kwargs)
53
+ with_translated_errors { super(checked_attributes(model_data.merge(kwargs))) }
68
54
  end
69
55
 
70
56
  def stub(**model_data)
71
- with_translated_errors { super }
57
+ with_translated_errors { super(**checked_attributes(model_data)) }
72
58
  end
73
59
 
74
60
  def save(instance)
@@ -119,6 +105,13 @@ module Fortnox
119
105
 
120
106
  private
121
107
 
108
+ # Attribute names a caller may pass. Read-only attributes are included:
109
+ # rest-easy drops them from the payload later, and rejecting them here
110
+ # would break round-tripping a parsed record back through `update`.
111
+ def checked_attributes(data)
112
+ AttributeKeys.check(data, known: all_attribute_definitions.keys, subject: self)
113
+ end
114
+
122
115
  def extract_pagination(data)
123
116
  return {} unless data.is_a?(Hash) && data.key?('MetaInformation')
124
117
 
@@ -131,8 +124,8 @@ module Fortnox
131
124
  end
132
125
  end
133
126
 
134
- def update(...)
135
- with_translated_errors { super }
127
+ def update(changes = {}, **kwargs)
128
+ with_translated_errors { super(self.class.send(:checked_attributes, changes.merge(kwargs))) }
136
129
  end
137
130
 
138
131
  def serialise(...)
@@ -159,4 +152,5 @@ module Fortnox
159
152
  { config.instance_wrapper => data }
160
153
  end
161
154
  end
155
+ # rubocop:enable Metrics/ClassLength
162
156
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fortnox
4
+ # JSON rendering for the three types a consumer can hold.
5
+ #
6
+ # rest-easy generates JSON straight from the attribute hash, and `JSON`
7
+ # renders anything it doesn't recognise through `to_s`. Nested structs are
8
+ # the case that matters: without this, an invoice with rows serialises them
9
+ # as `"#<Fortnox::Structs::InvoiceRow:0x…>"`.
10
+ #
11
+ # All three render the model representation — snake_case attribute names —
12
+ # not the Fortnox wire format. Use `to_api` for the latter.
13
+ module Serialisation
14
+ # Applies Rails' `:only` / `:except` render options.
15
+ #
16
+ # ActiveSupport's `Hash#as_json` slices a symbol-keyed hash, so a caller
17
+ # passing `only: ['name']` would match nothing and silently render `{}`.
18
+ # Normalise both sides instead, and accept either spelling.
19
+ #
20
+ # Anything that isn't an options hash is ignored — `JSON.generate` passes a
21
+ # `JSON::State` here when a resource is nested inside another structure.
22
+ def self.filter(attributes, options)
23
+ return attributes unless options.is_a?(::Hash)
24
+
25
+ if options[:only]
26
+ attributes.slice(*Array(options[:only]).map(&:to_sym))
27
+ elsif options[:except]
28
+ attributes.except(*Array(options[:except]).map(&:to_sym))
29
+ else
30
+ attributes
31
+ end
32
+ end
33
+
34
+ # Converts model values into JSON-safe primitives.
35
+ def self.json_safe(value)
36
+ case value
37
+ when Fortnox::Struct then json_safe(value.to_h)
38
+ when ::Hash then value.to_h { |key, nested| [key.to_s, json_safe(nested)] }
39
+ when ::Array then value.map { |element| json_safe(element) }
40
+ else value
41
+ end
42
+ end
43
+
44
+ module ResourceJSON
45
+ def to_json(options = nil)
46
+ ::JSON.generate(Serialisation.json_safe(Serialisation.filter(model.attributes, options)))
47
+ end
48
+ end
49
+
50
+ module StructJSON
51
+ def to_json(options = nil)
52
+ ::JSON.generate(Serialisation.json_safe(Serialisation.filter(to_h, options)))
53
+ end
54
+ end
55
+
56
+ module CollectionJSON
57
+ # Renders as its records, the way an Array of them would. Pagination
58
+ # metadata stays addressable on the collection for callers that want to
59
+ # render it alongside.
60
+ def to_json(options = nil)
61
+ rendered = to_a.map do |item|
62
+ Serialisation.json_safe(Serialisation.filter(item.model.attributes, options))
63
+ end
64
+ ::JSON.generate(rendered)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -4,6 +4,24 @@ require 'dry-struct'
4
4
 
5
5
  module Fortnox
6
6
  class Struct < Dry::Struct
7
+ include Serialisation::StructJSON
8
+
9
+ # Accept the string keys Rails params arrive with. Not `&:to_sym` — a key
10
+ # that can't be symbolised would raise NoMethodError from outside the
11
+ # Fortnox::Error hierarchy instead of being reported as unknown.
12
+ transform_keys { |key| AttributeKeys.symbolise(key) }
13
+
14
+ # Reject attribute names the struct doesn't declare. Dry::Struct ignores
15
+ # them by default, so a misspelled key silently produced an empty row that
16
+ # Fortnox then accepted. Parsing stays tolerant of undeclared fields —
17
+ # Mappers::Struct slices API data to the declared attributes first.
18
+ schema schema.strict
19
+
20
+ # Lets us tell "no attributes given" apart from "given an empty hash", so
21
+ # Dry::Struct still gets to apply its own defaults in the former case.
22
+ NO_ATTRIBUTES = ::Object.new.freeze
23
+ private_constant :NO_ATTRIBUTES
24
+
7
25
  class << self
8
26
  def read_only_attributes
9
27
  @read_only_attributes ||=
@@ -19,6 +37,76 @@ module Fortnox
19
37
  read_only_attributes << name if flags.include?(:read_only)
20
38
  super(name, type)
21
39
  end
40
+
41
+ # Dry::Struct signals a bad value with Dry::Struct::Error, a TypeError
42
+ # that sits outside the Fortnox::Error hierarchy — so a caller's
43
+ # `rescue Fortnox::AttributeError` misses it and the failure surfaces as
44
+ # an unhandled crash. Structs are reachable through the same public
45
+ # entry points as resources (`Resource.stub` coerces nested hashes into
46
+ # structs), so they translate their errors the same way.
47
+ # rubocop:disable Style/OptionalBooleanParameter
48
+ # `safe` is Dry::Struct's own second positional parameter — it picks the
49
+ # non-raising coercion path — so it has to stay positional here. We only
50
+ # pass it through.
51
+ def new(attributes = NO_ATTRIBUTES, safe = false, &)
52
+ return super() if NO_ATTRIBUTES.equal?(attributes)
53
+
54
+ super
55
+ rescue Dry::Struct::Error => e
56
+ raise translated_error(e, attributes)
57
+ end
58
+ # rubocop:enable Style/OptionalBooleanParameter
59
+
60
+ # Attribute names this struct accepts.
61
+ def attribute_names
62
+ schema.keys.map(&:name)
63
+ end
64
+
65
+ private
66
+
67
+ def translated_error(error, attributes)
68
+ return Fortnox::AttributeError.new(error.message) unless attributes.is_a?(::Hash)
69
+
70
+ unknown_attribute_error(attributes) ||
71
+ failing_attribute_error(attributes) ||
72
+ Fortnox::AttributeError.new(error.message)
73
+ end
74
+
75
+ # Dry::Struct reports a strict-schema rejection as "unexpected keys […]"
76
+ # in the message only. Recompute the names from the input rather than
77
+ # parsing that string back out.
78
+ def unknown_attribute_error(attributes)
79
+ unknown = AttributeKeys.normalise(attributes).keys - attribute_names
80
+ return nil if unknown.empty?
81
+
82
+ Fortnox::UnknownAttributeError.new(unknown, self)
83
+ end
84
+
85
+ # Dry::Struct discards the underlying coercion error, keeping only its
86
+ # message, so re-run each value through its own key type to recover
87
+ # which attribute was at fault and what it was given.
88
+ def failing_attribute_error(attributes)
89
+ keys = schema.keys.to_h { |key| [key.name, key] }
90
+
91
+ attributes.each do |name, value|
92
+ key = keys[name.to_s.to_sym]
93
+ next if key.nil?
94
+
95
+ error = coercion_error_for(key, value)
96
+ return error if error
97
+ end
98
+
99
+ nil
100
+ end
101
+
102
+ def coercion_error_for(key, value)
103
+ key.call(value)
104
+ nil
105
+ rescue Dry::Types::CoercionError => e
106
+ # Report the key type's own message rather than Dry::Struct's wrapper,
107
+ # so struct and resource failures read identically.
108
+ Fortnox::ConstraintError.new(key.name, value, "Attribute '#{key.name}': #{e.message}")
109
+ end
22
110
  end
23
111
  end
24
112
  end
@@ -34,7 +34,7 @@ module Fortnox
34
34
  attribute? :discount_type, Types::DiscountTypes
35
35
 
36
36
  # HouseWork If the row is housework
37
- attribute? :housework, Types::Bool.optional
37
+ attribute? :housework, Types::CoercibleBool.optional
38
38
 
39
39
  # HouseWorkHoursToReport Hours to be reported if the quantity of the row
40
40
  # should not be used as hours. 5 digits
data/lib/fortnox/types.rb CHANGED
@@ -7,7 +7,13 @@ module Fortnox
7
7
  module Types
8
8
  include Dry.Types()
9
9
 
10
- THE_TRUTH = { true => true, 'true' => true, false => false, 'false' => false }.freeze
10
+ # Booleans reach us as strings whenever the caller is a Rails app passing
11
+ # controller params through. Resource attributes already coerce those:
12
+ # rest-easy's `Boolean` is `Dry::Types['params.bool']`, and it wins over
13
+ # the strict type declared alongside it. Struct attributes have to ask for
14
+ # the same coercion explicitly, or the two paths disagree about whether
15
+ # 'true' is a boolean.
16
+ CoercibleBool = Types::Params::Bool
11
17
 
12
18
  ArticleTypes = Types::Strict::String.enum(
13
19
  'SERVICE', 'STOCK'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fortnox
4
- VERSION = '1.0.0.rc13'
4
+ VERSION = '1.0.0.rc15'
5
5
  end
data/lib/fortnox.rb CHANGED
@@ -8,6 +8,9 @@ require 'zeitwerk'
8
8
  module Fortnox
9
9
  @loader = Zeitwerk::Loader.for_gem
10
10
  @loader.collapse("#{__dir__}/fortnox/resources")
11
+ # Optional Rails integration — required explicitly by consumers, never
12
+ # autoloaded, and it defines no constant matching its path.
13
+ @loader.ignore("#{__dir__}/fortnox/rails.rb")
11
14
  @loader.inflector.inflect(
12
15
  'edi_information' => 'EDIInformation'
13
16
  )
@@ -89,6 +92,21 @@ module Fortnox
89
92
  end
90
93
  end
91
94
 
95
+ class UnknownAttributeError < AttributeError
96
+ attr_reader :attribute_names, :subject
97
+
98
+ def initialize(attribute_names, subject = nil)
99
+ @attribute_names = Array(attribute_names)
100
+ @subject = subject
101
+ super("Unknown attribute#{'s' if @attribute_names.length > 1} for #{subject}: " \
102
+ "#{@attribute_names.map(&:inspect).join(', ')}")
103
+ end
104
+
105
+ def attribute_name
106
+ @attribute_names.first
107
+ end
108
+ end
109
+
92
110
  class MissingAccessToken < Error; end
93
111
 
94
112
  OAUTH_TOKEN_URL = 'https://apps.fortnox.se/oauth-v1/token'
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.rc13
4
+ version: 1.0.0.rc15
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonas Schubert Erlandsson
@@ -11,22 +11,22 @@ authors:
11
11
  autorequire:
12
12
  bindir: bin
13
13
  cert_chain: []
14
- date: 2026-08-04 00:00:00.000000000 Z
14
+ date: 2026-08-18 00:00:00.000000000 Z
15
15
  dependencies:
16
16
  - !ruby/object:Gem::Dependency
17
17
  name: base64
18
18
  requirement: !ruby/object:Gem::Requirement
19
19
  requirements:
20
- - - ">="
20
+ - - "~>"
21
21
  - !ruby/object:Gem::Version
22
- version: '0'
22
+ version: '0.2'
23
23
  type: :runtime
24
24
  prerelease: false
25
25
  version_requirements: !ruby/object:Gem::Requirement
26
26
  requirements:
27
- - - ">="
27
+ - - "~>"
28
28
  - !ruby/object:Gem::Version
29
- version: '0'
29
+ version: '0.2'
30
30
  - !ruby/object:Gem::Dependency
31
31
  name: countries
32
32
  requirement: !ruby/object:Gem::Requirement
@@ -61,14 +61,14 @@ dependencies:
61
61
  requirements:
62
62
  - - "~>"
63
63
  - !ruby/object:Gem::Version
64
- version: 1.4.0
64
+ version: 1.4.1
65
65
  type: :runtime
66
66
  prerelease: false
67
67
  version_requirements: !ruby/object:Gem::Requirement
68
68
  requirements:
69
69
  - - "~>"
70
70
  - !ruby/object:Gem::Version
71
- version: 1.4.0
71
+ version: 1.4.1
72
72
  description: Fortnox F3 REST API library, based on rest-easy.
73
73
  email:
74
74
  - info@accodeing.com
@@ -85,8 +85,10 @@ files:
85
85
  - bin/fortnox-update-env
86
86
  - fortnox.gemspec
87
87
  - lib/fortnox.rb
88
+ - lib/fortnox/attribute_keys.rb
88
89
  - lib/fortnox/auth/thread_local.rb
89
90
  - lib/fortnox/collection.rb
91
+ - lib/fortnox/error_translation.rb
90
92
  - lib/fortnox/mappers/country_code.rb
91
93
  - lib/fortnox/mappers/date.rb
92
94
  - lib/fortnox/mappers/document_row.rb
@@ -97,6 +99,7 @@ files:
97
99
  - lib/fortnox/mappers/order_row.rb
98
100
  - lib/fortnox/mappers/struct.rb
99
101
  - lib/fortnox/mappers/struct_array.rb
102
+ - lib/fortnox/rails.rb
100
103
  - lib/fortnox/resource.rb
101
104
  - lib/fortnox/resources/article.rb
102
105
  - lib/fortnox/resources/customer.rb
@@ -107,6 +110,7 @@ files:
107
110
  - lib/fortnox/resources/project.rb
108
111
  - lib/fortnox/resources/terms_of_payment.rb
109
112
  - lib/fortnox/resources/unit.rb
113
+ - lib/fortnox/serialisation.rb
110
114
  - lib/fortnox/struct.rb
111
115
  - lib/fortnox/structs/default_delivery_types.rb
112
116
  - lib/fortnox/structs/default_templates.rb