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 +4 -4
- data/CHANGELOG.md +8 -0
- data/CLAUDE.md +79 -0
- data/README.md +6 -11
- data/bin/fortnox +285 -0
- data/docs/gotchas.md +146 -0
- data/lib/fortnox/api/types.rb +1 -1
- data/lib/fortnox/api/version.rb +1 -1
- data/spec/fortnox/api/repositories/customer_spec.rb +19 -0
- data/spec/fortnox/api/types/email_spec.rb +15 -2
- data/spec/support/vcr_setup.rb +2 -1
- data/spec/vcr_cassettes/customers/save_new_with_idn_email.yml +67 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a0605a1ea41ed7a7322a560e9e27255b60df149c486f875bd968354adbf1b826
|
|
4
|
+
data.tar.gz: b66ce8b6a31fa9ef28b5ae0a99e8531df1027253bde63338291c875bfaa15367
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](https://codeclimate.com/github/accodeing/fortnox-api/maintainability)
|
|
17
17
|
[](https://codeclimate.com/github/accodeing/fortnox-api/test_coverage)
|
|
18
18
|
|
|
19
|
-
The rough status of this project is as follows (as of spring
|
|
19
|
+
The rough status of this project is as follows (as of spring 2025):
|
|
20
20
|
|
|
21
|
-
- `master` branch and the released versions
|
|
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
|
|
179
|
-
>
|
|
180
|
-
>
|
|
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 **
|
|
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.
|
data/lib/fortnox/api/types.rb
CHANGED
|
@@ -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:]]+-
|
|
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
|
|
data/lib/fortnox/api/version.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/spec/support/vcr_setup.rb
CHANGED
|
@@ -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
|
|
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.
|
|
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:
|
|
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
|