moloni 0.5.0
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 +7 -0
- data/.gitignore +14 -0
- data/.rspec +4 -0
- data/.rubocop.yml +76 -0
- data/.travis.yml +6 -0
- data/AGENTS.md +41 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +140 -0
- data/LICENSE.txt +21 -0
- data/MOLONI_API_DOC.md +328 -0
- data/README.md +184 -0
- data/Rakefile +8 -0
- data/bin/auth +16 -0
- data/bin/console +34 -0
- data/bin/setup +8 -0
- data/lib/moloni/auth.rb +105 -0
- data/lib/moloni/base_model.rb +174 -0
- data/lib/moloni/cli/oauth_callback_command.rb +54 -0
- data/lib/moloni/cli/oauth_callback_server.rb +24 -0
- data/lib/moloni/cli/views/variables.erb +80 -0
- data/lib/moloni/configuration.rb +46 -0
- data/lib/moloni/errors.rb +21 -0
- data/lib/moloni/models/company.rb +18 -0
- data/lib/moloni/models/country.rb +13 -0
- data/lib/moloni/models/customer.rb +47 -0
- data/lib/moloni/models/document.rb +18 -0
- data/lib/moloni/models/document_set.rb +9 -0
- data/lib/moloni/models/invoice.rb +9 -0
- data/lib/moloni/models/invoice_receipt.rb +9 -0
- data/lib/moloni/models/language.rb +25 -0
- data/lib/moloni/models/maturity_date.rb +13 -0
- data/lib/moloni/models/payment_method.rb +13 -0
- data/lib/moloni/models/printer.rb +13 -0
- data/lib/moloni/models/product.rb +20 -0
- data/lib/moloni/models/product_category.rb +9 -0
- data/lib/moloni/models/product_stock.rb +9 -0
- data/lib/moloni/models/simplified_invoice.rb +9 -0
- data/lib/moloni/models/subscription.rb +9 -0
- data/lib/moloni/models/supplier.rb +17 -0
- data/lib/moloni/models/tax.rb +33 -0
- data/lib/moloni/models/user.rb +13 -0
- data/lib/moloni/version.rb +5 -0
- data/lib/moloni.rb +55 -0
- data/moloni.gemspec +45 -0
- metadata +271 -0
data/README.md
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# Moloni
|
|
2
|
+
|
|
3
|
+
A modern Ruby wrapper for the [Moloni API](https://www.moloni.pt/dev/). Works with Ruby 3.2+ and Rails 8.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add this line to your application's Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem 'moloni'
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
And then execute:
|
|
14
|
+
|
|
15
|
+
$ bundle
|
|
16
|
+
|
|
17
|
+
Or install it yourself as:
|
|
18
|
+
|
|
19
|
+
$ gem install moloni
|
|
20
|
+
|
|
21
|
+
## Setup
|
|
22
|
+
|
|
23
|
+
### In a Rails app
|
|
24
|
+
|
|
25
|
+
Create `config/initializers/moloni.rb`:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
Moloni.configure do |config|
|
|
29
|
+
config.developer_id = ENV.fetch('MOLONI_DEVELOPER_ID')
|
|
30
|
+
config.redirect_uri = ENV.fetch('MOLONI_REDIRECT_URI')
|
|
31
|
+
config.client_secret = ENV.fetch('MOLONI_CLIENT_SECRET')
|
|
32
|
+
config.access_token = ENV.fetch('MOLONI_ACCESS_TOKEN')
|
|
33
|
+
config.refresh_token = ENV.fetch('MOLONI_REFRESH_TOKEN')
|
|
34
|
+
config.company_id = ENV.fetch('MOLONI_COMPANY_ID', 0)
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Environment Variables
|
|
39
|
+
|
|
40
|
+
| Variable | Purpose |
|
|
41
|
+
|----------|---------|
|
|
42
|
+
| `MOLONI_DEVELOPER_ID` | OAuth `client_id` from Moloni developer area |
|
|
43
|
+
| `MOLONI_REDIRECT_URI` | OAuth callback URL |
|
|
44
|
+
| `MOLONI_CLIENT_SECRET` | OAuth `client_secret` |
|
|
45
|
+
| `MOLONI_ACCESS_TOKEN` | Short-lived API token (1h) |
|
|
46
|
+
| `MOLONI_REFRESH_TOKEN` | Long-lived refresh token (14 days) |
|
|
47
|
+
| `MOLONI_COMPANY_ID` | Default company for API calls |
|
|
48
|
+
|
|
49
|
+
## Authentication
|
|
50
|
+
|
|
51
|
+
The gem supports OAuth 2.0. Use the built-in CLI tool to obtain tokens:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
bin/auth
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
This starts a local callback server and opens the browser for Moloni authorization. After approval, tokens are printed to the console.
|
|
58
|
+
|
|
59
|
+
**Token auto-refresh:** The gem automatically refreshes expired `access_token`s using the `refresh_token` before each API call (with a 5-minute safety margin). If the refresh token also expires, a `Moloni::TokenExpiredError` is raised.
|
|
60
|
+
|
|
61
|
+
## Usage
|
|
62
|
+
|
|
63
|
+
### Basic API calls
|
|
64
|
+
|
|
65
|
+
All API calls use `POST` under the hood, including reads. The gem automatically injects `company_id`, `access_token`, `json=true`, and `human_errors=true`.
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
# List all products
|
|
69
|
+
products = Moloni::Product.getAll
|
|
70
|
+
|
|
71
|
+
# Find one product
|
|
72
|
+
product = Moloni::Product.getOne(product_id: 123)
|
|
73
|
+
|
|
74
|
+
# Search by EAN (or any future method)
|
|
75
|
+
product = Moloni::Product.getByEAN(ean: '5601234567890')
|
|
76
|
+
|
|
77
|
+
# Snake_case also works
|
|
78
|
+
product = Moloni::Product.get_by_ean(ean: '5601234567890')
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Dynamic dispatch
|
|
82
|
+
|
|
83
|
+
Any method name not explicitly defined on a model is automatically translated to a Moloni API endpoint. This means the gem supports new Moloni methods as soon as they are released, without code changes.
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
# These all work immediately, even if not explicitly defined:
|
|
87
|
+
Moloni::Product.getByReference(reference: 'ABC-123')
|
|
88
|
+
Moloni::Product.get_by_reference(reference: 'ABC-123')
|
|
89
|
+
Moloni::Invoice.getByNumber(number: 'FT 2025/001')
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Customers
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
# Find by VAT, email, or customer number
|
|
96
|
+
customer = Moloni::Customer.getByVat(vat: '123456789')
|
|
97
|
+
customer = Moloni::Customer.find_by_vat(vat: '123456789')
|
|
98
|
+
|
|
99
|
+
# Create a customer
|
|
100
|
+
customer = Moloni::Customer.insert(
|
|
101
|
+
name: 'Acme Corp',
|
|
102
|
+
vat: '123456789',
|
|
103
|
+
number: 'C001',
|
|
104
|
+
language_id: 1,
|
|
105
|
+
address: 'Main St 1',
|
|
106
|
+
city: 'Lisbon',
|
|
107
|
+
country_id: 1,
|
|
108
|
+
maturity_date_id: Moloni::MaturityDate.pronto_pagamento,
|
|
109
|
+
payment_method_id: Moloni::PaymentMethod.numerario
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Invoices and Documents
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
# Create an invoice
|
|
117
|
+
invoice = Moloni::Invoice.insert(
|
|
118
|
+
document_set_id: 332_429,
|
|
119
|
+
date: Date.today,
|
|
120
|
+
expiration_date: Date.today + 30,
|
|
121
|
+
customer_id: 38_096_359,
|
|
122
|
+
products: [
|
|
123
|
+
{
|
|
124
|
+
product_id: 70_241_498,
|
|
125
|
+
name: 'Consulting',
|
|
126
|
+
qty: 1,
|
|
127
|
+
price: 100.00,
|
|
128
|
+
taxes: [{ tax_id: Moloni::Tax.iva_normal_id }]
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Get PDF link
|
|
134
|
+
pdf_url = Moloni::Document.getPDFLink(document_id: invoice[:document_id])
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Taxes and Settings
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
# Get hardcoded tax IDs
|
|
141
|
+
normal_iva = Moloni::Tax.iva_normal_id # 2072734
|
|
142
|
+
intermedio = Moloni::Tax.iva_intermedio_id # 2072748
|
|
143
|
+
reduzido = Moloni::Tax.iva_reduzido_id # 2072741
|
|
144
|
+
|
|
145
|
+
# Get full tax objects
|
|
146
|
+
normal_tax = Moloni::Tax.iva_normal
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Error Handling
|
|
150
|
+
|
|
151
|
+
The gem raises specific exceptions for different failure modes:
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
begin
|
|
155
|
+
Moloni::Product.getOne(product_id: 999_999)
|
|
156
|
+
rescue Moloni::APIKeyError => e
|
|
157
|
+
# HTTP 401 — access token invalid or expired
|
|
158
|
+
rescue Moloni::APIError => e
|
|
159
|
+
# Validation errors (missing fields, invalid format, etc.)
|
|
160
|
+
e.errors.each do |err|
|
|
161
|
+
puts "#{err[:field]}: #{err[:message]}"
|
|
162
|
+
end
|
|
163
|
+
rescue Moloni::TokenExpiredError => e
|
|
164
|
+
# Both access and refresh tokens expired — re-authentication needed
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Breaking Changes (0.4 → 0.5)
|
|
169
|
+
|
|
170
|
+
- **Ruby requirement:** Now requires Ruby 3.2 or later.
|
|
171
|
+
- **HTTP method:** All API calls now use `POST` (previously some reads used `GET`).
|
|
172
|
+
- **Removed methods:** Redundant explicit methods (`all`, `count`, `find`, `create`) were removed from models where they offered no custom logic. Use dynamic dispatch (`getAll`, `getOne`, `insert`) instead.
|
|
173
|
+
- **Auth URL fix:** `Moloni::Auth.auth_url` now correctly points to `https://www.moloni.pt/ac/root/oauth/`.
|
|
174
|
+
- **Multi-json removed:** Replaced `multi_json` with standard library `JSON`.
|
|
175
|
+
|
|
176
|
+
## Development
|
|
177
|
+
|
|
178
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt.
|
|
179
|
+
|
|
180
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`.
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/bin/auth
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'bundler/setup'
|
|
5
|
+
|
|
6
|
+
require 'dotenv'
|
|
7
|
+
Dotenv.load
|
|
8
|
+
|
|
9
|
+
require 'moloni'
|
|
10
|
+
require 'moloni/auth'
|
|
11
|
+
require 'moloni/cli/oauth_callback_command'
|
|
12
|
+
|
|
13
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
|
14
|
+
# with your gem easier. You can also use a different console, if you like.
|
|
15
|
+
|
|
16
|
+
Moloni::Cli::OauthCallbackCommand.new(ENV['DEVELOPER_ID'], ENV['CLIENT_SECRET']).run
|
data/bin/console
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'bundler/setup'
|
|
5
|
+
require 'moloni'
|
|
6
|
+
|
|
7
|
+
require 'dotenv'
|
|
8
|
+
Dotenv.load
|
|
9
|
+
|
|
10
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
|
11
|
+
# with your gem easier. You can also use a different console, if you like.
|
|
12
|
+
|
|
13
|
+
Moloni.configure do |config|
|
|
14
|
+
config.developer_id = ENV['DEVELOPER_ID']
|
|
15
|
+
config.redirect_uri = ENV['REDIRECT_URI']
|
|
16
|
+
config.client_secret = ENV['CLIENT_SECRET']
|
|
17
|
+
config.refresh_token = ENV['REFRESH_TOKEN']
|
|
18
|
+
config.access_token = ENV['ACCESS_TOKEN']
|
|
19
|
+
config.company_id = ENV['COMPANY_ID']
|
|
20
|
+
config.debug = true
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Allow direct access to Moloni classes
|
|
24
|
+
send(:include, Moloni)
|
|
25
|
+
|
|
26
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
|
27
|
+
# require "pry"
|
|
28
|
+
# Pry.start
|
|
29
|
+
|
|
30
|
+
# require 'irb'
|
|
31
|
+
# IRB.start(__FILE__)
|
|
32
|
+
|
|
33
|
+
require 'pry'
|
|
34
|
+
Pry.start
|
data/bin/setup
ADDED
data/lib/moloni/auth.rb
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'addressable'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Moloni
|
|
7
|
+
class Auth
|
|
8
|
+
WEB_AUTH_URL = 'https://www.moloni.pt/ac/root/oauth/'
|
|
9
|
+
API_TOKEN_PATH = 'grant/'
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@config = Moloni.config
|
|
13
|
+
@api_domain = API_BASE_URL
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.auth_url
|
|
17
|
+
new.auth_url
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def auth_url
|
|
21
|
+
uri = Addressable::URI.parse(WEB_AUTH_URL)
|
|
22
|
+
|
|
23
|
+
query = {
|
|
24
|
+
response_type: 'code',
|
|
25
|
+
client_id: @config.developer_id,
|
|
26
|
+
redirect_uri: @config.redirect_uri
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
uri.query_values = query
|
|
30
|
+
|
|
31
|
+
Addressable::URI.unencode(uri.to_s)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def token_full_uri
|
|
35
|
+
Addressable::URI.join(API_BASE_URL, API_TOKEN_PATH)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.get_tokens(authorization_code)
|
|
39
|
+
new.get_token(authorization_code)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def get_token(authorization_code)
|
|
43
|
+
result = Faraday.get(get_access_token_url(authorization_code))
|
|
44
|
+
|
|
45
|
+
parse_and_update(result.body)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def get_access_token_url(authorization_code)
|
|
49
|
+
uri = token_full_uri
|
|
50
|
+
|
|
51
|
+
uri.query_values = {
|
|
52
|
+
client_id: @config.developer_id,
|
|
53
|
+
client_secret: @config.client_secret,
|
|
54
|
+
code: authorization_code,
|
|
55
|
+
redirect_uri: @config.redirect_uri,
|
|
56
|
+
grant_type: 'authorization_code'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
Addressable::URI.unencode(uri.to_s)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.refresh_tokens(refresh_token = nil)
|
|
63
|
+
new.refresh_tokens(refresh_token)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def refresh_tokens(refresh_token = nil)
|
|
67
|
+
token = refresh_token || @config.refresh_token
|
|
68
|
+
|
|
69
|
+
raise TokenExpiredError, 'Refresh token is missing or expired' if token.nil? || token.empty?
|
|
70
|
+
raise TokenExpiredError, 'Refresh token has expired' if @config.refresh_token_expired?
|
|
71
|
+
|
|
72
|
+
result = Faraday.get(refresh_tokens_url(token))
|
|
73
|
+
|
|
74
|
+
parse_and_update(result.body)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def refresh_tokens_url(refresh_token)
|
|
78
|
+
uri = token_full_uri
|
|
79
|
+
|
|
80
|
+
uri.query_values = {
|
|
81
|
+
client_id: @config.developer_id,
|
|
82
|
+
client_secret: @config.client_secret,
|
|
83
|
+
refresh_token:,
|
|
84
|
+
grant_type: 'refresh_token'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
Addressable::URI.unencode(uri.to_s)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def parse_and_update(body)
|
|
93
|
+
parsed = JSON.parse(body, symbolize_names: true)
|
|
94
|
+
|
|
95
|
+
raise APIError, "#{parsed[:error]}: #{parsed[:error_description]}" if parsed[:error]
|
|
96
|
+
|
|
97
|
+
@config.access_token = parsed[:access_token]
|
|
98
|
+
@config.refresh_token = parsed[:refresh_token]
|
|
99
|
+
@config.access_token_expires_at = Time.now + (parsed[:expires_in] || 3600)
|
|
100
|
+
@config.refresh_token_expires_at = Time.now + (14 * 24 * 60 * 60) # 14 days
|
|
101
|
+
|
|
102
|
+
parsed
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'addressable'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
require 'moloni/errors'
|
|
7
|
+
|
|
8
|
+
module Moloni
|
|
9
|
+
class BaseModel
|
|
10
|
+
@intermediate_path = ''
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
attr_accessor :intermediate_path
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.format_url(url, params)
|
|
17
|
+
formatted = url.dup.strip
|
|
18
|
+
|
|
19
|
+
params.each { |key, value| formatted.sub!(":#{key}", value.to_s) }
|
|
20
|
+
|
|
21
|
+
formatted
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def format_url(url, params)
|
|
25
|
+
self.class.format_url(url, params)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.post(method_path, opts = {})
|
|
29
|
+
ensure_token_valid!
|
|
30
|
+
|
|
31
|
+
full_uri = build_full_uri(method_path)
|
|
32
|
+
full_params = composed_params(opts)
|
|
33
|
+
|
|
34
|
+
result = api_post(full_uri.to_s, full_params)
|
|
35
|
+
|
|
36
|
+
parse_response(result)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.composed_params(opts)
|
|
40
|
+
trimmed_opts = opts.compact
|
|
41
|
+
{
|
|
42
|
+
company_id: Moloni.config.company_id
|
|
43
|
+
}.merge(trimmed_opts)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Dynamic dispatch for any Moloni API method.
|
|
47
|
+
# Examples:
|
|
48
|
+
# Product.getByEAN(ean: '123')
|
|
49
|
+
# Product.get_by_ean(ean: '123') # snake_case alias
|
|
50
|
+
# Invoice.insert(args)
|
|
51
|
+
def self.method_missing(method_name, *args, &_block)
|
|
52
|
+
args = [{}] if args.empty?
|
|
53
|
+
camelized_method = camelize_method_name(method_name.to_s)
|
|
54
|
+
post("#{camelized_method}/", args.first || {})
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.respond_to_missing?(_method_name, _include_private = false)
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Backward-compatible aliases for generic CRUD operations.
|
|
62
|
+
# These map common Ruby method names to the actual Moloni API endpoints.
|
|
63
|
+
def self.all(args = {})
|
|
64
|
+
post('getAll/', args)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.find(args = {})
|
|
68
|
+
post('getOne/', args)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.count(args = {})
|
|
72
|
+
post('count/', args)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.create(args = {})
|
|
76
|
+
post('insert/', args)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.delete(args = {})
|
|
80
|
+
post('delete/', args)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private_class_method def self.ensure_token_valid!
|
|
84
|
+
return unless Moloni.config.access_token_expired?
|
|
85
|
+
|
|
86
|
+
if Moloni.config.refresh_token_expired?
|
|
87
|
+
raise TokenExpiredError,
|
|
88
|
+
'Access token expired and refresh token is no longer valid. ' \
|
|
89
|
+
'Re-authentication required.'
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
Auth.refresh_tokens
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private_class_method def self.build_full_uri(method_path)
|
|
96
|
+
full_uri = Addressable::URI.join(intermediate_path, method_path)
|
|
97
|
+
full_uri.query_values = {
|
|
98
|
+
json: 'true',
|
|
99
|
+
human_errors: 'true',
|
|
100
|
+
access_token: Moloni.config.access_token
|
|
101
|
+
}
|
|
102
|
+
full_uri
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private_class_method def self.api_post(path_uri, opts = {})
|
|
106
|
+
request_path = Addressable::URI.unencode(path_uri)
|
|
107
|
+
Moloni.connection.post(request_path, JSON.generate(opts))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private_class_method def self.parse_response(result)
|
|
111
|
+
raise APIKeyError, result.body[:error] if result.status == 401
|
|
112
|
+
|
|
113
|
+
body = result.body
|
|
114
|
+
|
|
115
|
+
# Data validation errors come as an array of strings like "1 name"
|
|
116
|
+
if body.is_a?(Array) && body.any? { |e| e.is_a?(String) && e.match?(/\A\d+\s+/) }
|
|
117
|
+
errors = body.map { |raw| parse_validation_error(raw) }
|
|
118
|
+
messages = errors.map { |e| "#{e[:field]}: #{e[:message]}" }.join(', ')
|
|
119
|
+
error = APIError.new("Validation failed: #{messages}", errors)
|
|
120
|
+
raise error
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
body
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
ERROR_MESSAGES = {
|
|
127
|
+
1 => 'is required',
|
|
128
|
+
2 => 'must be numeric',
|
|
129
|
+
3 => 'must be a valid email',
|
|
130
|
+
4 => 'must be unique',
|
|
131
|
+
5 => 'must be one of accepted values',
|
|
132
|
+
6 => 'must be a valid URL',
|
|
133
|
+
7 => 'must be a valid postal code',
|
|
134
|
+
8 => 'must be a valid Portuguese VAT number',
|
|
135
|
+
9 => 'must be a date in YYYY-mm-dd format',
|
|
136
|
+
10 => 'has an invalid document association',
|
|
137
|
+
11 => 'cannot be sent to Tax Authority',
|
|
138
|
+
12 => 'has an invalid date comparison',
|
|
139
|
+
13 => 'must be a valid phone contact',
|
|
140
|
+
14 => 'has invalid tax configuration',
|
|
141
|
+
15 => 'has more than one VAT',
|
|
142
|
+
16 => 'customer identification does not meet legal requirements',
|
|
143
|
+
17 => 'field limit reached'
|
|
144
|
+
}.freeze
|
|
145
|
+
|
|
146
|
+
private_class_method def self.parse_validation_error(raw)
|
|
147
|
+
parts = raw.to_s.split
|
|
148
|
+
code = parts[0].to_i
|
|
149
|
+
field = parts[1]
|
|
150
|
+
params = parts[2..]
|
|
151
|
+
|
|
152
|
+
message = ERROR_MESSAGES[code] || 'is invalid'
|
|
153
|
+
message += " (#{params.join(', ')})" if code == 2 && params.any?
|
|
154
|
+
|
|
155
|
+
{ code:, field:, message:, raw: }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
COMMON_ACRONYMS = %w[ean sku nif iban].freeze
|
|
159
|
+
|
|
160
|
+
private_class_method def self.camelize_method_name(name)
|
|
161
|
+
return name unless name.include?('_')
|
|
162
|
+
|
|
163
|
+
name.split('_').each_with_index.map do |part, index|
|
|
164
|
+
if index.zero?
|
|
165
|
+
part
|
|
166
|
+
elsif COMMON_ACRONYMS.include?(part)
|
|
167
|
+
part.upcase
|
|
168
|
+
else
|
|
169
|
+
part.capitalize
|
|
170
|
+
end
|
|
171
|
+
end.join
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'launchy'
|
|
4
|
+
|
|
5
|
+
require 'moloni'
|
|
6
|
+
require 'moloni/cli/oauth_callback_server'
|
|
7
|
+
|
|
8
|
+
require 'dotenv'
|
|
9
|
+
Dotenv.load
|
|
10
|
+
|
|
11
|
+
module Moloni
|
|
12
|
+
module Cli
|
|
13
|
+
class OauthCallbackCommand
|
|
14
|
+
def initialize(developer_id, client_secret)
|
|
15
|
+
@options = {
|
|
16
|
+
port: default_port,
|
|
17
|
+
client_secret:,
|
|
18
|
+
developer_id:
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def default_port
|
|
23
|
+
Moloni::Cli::OauthCallbackServer.settings.port
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run(_argv = ARGV, _env = ENV)
|
|
27
|
+
Moloni::Cli::OauthCallbackServer.set(:port, @options[:port]) if @options[:port]
|
|
28
|
+
|
|
29
|
+
callback_path = Moloni::Cli::OauthCallbackServer::CALLBACK_PATH
|
|
30
|
+
bind_port = Moloni::Cli::OauthCallbackServer.settings.port
|
|
31
|
+
bind_address = Moloni::Cli::OauthCallbackServer.settings.bind
|
|
32
|
+
|
|
33
|
+
callback_url = "http://#{bind_address}:#{bind_port}/#{callback_path}"
|
|
34
|
+
|
|
35
|
+
Moloni.configure do |config|
|
|
36
|
+
config.developer_id = @options[:developer_id] || ENV['DEVELOPER_ID']
|
|
37
|
+
config.redirect_uri = ENV['REDIRECT_URI']
|
|
38
|
+
config.client_secret = @options[:client_secret] || ENV['CLIENT_SECRET']
|
|
39
|
+
config.debug = true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
url = Moloni::Auth.auth_url
|
|
43
|
+
|
|
44
|
+
puts "Callback URL: #{callback_url}"
|
|
45
|
+
puts "Open this url to authenticate: #{url}"
|
|
46
|
+
|
|
47
|
+
Launchy.open(url)
|
|
48
|
+
|
|
49
|
+
puts 'Running callback server....'
|
|
50
|
+
Moloni::Cli::OauthCallbackServer.run!
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'sinatra/base'
|
|
4
|
+
|
|
5
|
+
module Moloni
|
|
6
|
+
module Cli
|
|
7
|
+
class OauthCallbackServer < Sinatra::Base
|
|
8
|
+
enable :logging
|
|
9
|
+
|
|
10
|
+
CALLBACK_PATH = 'oauth2callback'
|
|
11
|
+
|
|
12
|
+
get "/#{CALLBACK_PATH}" do
|
|
13
|
+
grant_token = params[:code]
|
|
14
|
+
|
|
15
|
+
# This will trigger a post request to get both the token and the refresh token
|
|
16
|
+
@variables = Moloni::Auth.get_tokens(grant_token)
|
|
17
|
+
|
|
18
|
+
puts "Variables: #{@variables.inspect}"
|
|
19
|
+
|
|
20
|
+
erb :variables
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" dir="ltr">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Moloni OAuth2 Dev Server</title>
|
|
6
|
+
<style media="screen">
|
|
7
|
+
body {
|
|
8
|
+
font-family: sans-serif;
|
|
9
|
+
padding: 20px 50px;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
ul.variables {
|
|
13
|
+
padding: 20px;
|
|
14
|
+
background-color: #eaeaea;
|
|
15
|
+
font-family: monospace;
|
|
16
|
+
list-style: none;
|
|
17
|
+
line-height: 1.5em;
|
|
18
|
+
margin-bottom: 50px;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
li.error {
|
|
22
|
+
color: #aa0000;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
div.config {
|
|
26
|
+
padding: 20px;
|
|
27
|
+
background-color: #eaeaea;
|
|
28
|
+
font-family: monospace;
|
|
29
|
+
line-height: 1.5em;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
pre {
|
|
33
|
+
margin: 0;
|
|
34
|
+
padding: 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
code {
|
|
38
|
+
padding: 0;
|
|
39
|
+
margin: 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
footer {
|
|
43
|
+
margin-top: 60px;
|
|
44
|
+
font-size: .8em;
|
|
45
|
+
}
|
|
46
|
+
</style>
|
|
47
|
+
</head>
|
|
48
|
+
|
|
49
|
+
<body>
|
|
50
|
+
<h2>Here's what you have to add to your environment variables:</h2>
|
|
51
|
+
|
|
52
|
+
<ul class="variables">
|
|
53
|
+
<li>DEVELOPER_ID=<%= Moloni.config.developer_id %></li>
|
|
54
|
+
<li>CLIENT_SECRET=<%= Moloni.config.client_secret %></li>
|
|
55
|
+
|
|
56
|
+
<% if @variables[:refresh_token] %>
|
|
57
|
+
<li>REFRESH_TOKEN=<%= @variables[:refresh_token] %></li>
|
|
58
|
+
<% else %>
|
|
59
|
+
<li class="error">REFRESH_TOKEN=Moloni didn't send a REFRESH_TOKEN</li>
|
|
60
|
+
<% end %>
|
|
61
|
+
|
|
62
|
+
<% if @variables[:access_token] %>
|
|
63
|
+
<li>ACCESS_TOKEN=<%= @variables[:access_token] %></li>
|
|
64
|
+
<% else %>
|
|
65
|
+
<li class="error">ACCESS_TOKEN=Moloni didn't send a ACCESS_TOKEN</li>
|
|
66
|
+
<% end %>
|
|
67
|
+
</ul>
|
|
68
|
+
|
|
69
|
+
<h2>Then you need a configuration block like this one:</h2>
|
|
70
|
+
<div class="config">
|
|
71
|
+
<pre><code>Moloni.configure do |config|
|
|
72
|
+
config.developer_id = ENV['DEVELOPER_ID']
|
|
73
|
+
config.client_secret = ENV['CLIENT_SECRET']
|
|
74
|
+
config.refresh_token = ENV['REFRESH_TOKEN']
|
|
75
|
+
end</code></pre>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<footer>Moloni Rubygem v<%= Moloni::VERSION %></footer>
|
|
79
|
+
</body>
|
|
80
|
+
</html>
|