super_pdp 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 80df88af44b2d803aa180b0d205329440167089782755f4ac1635b94a5066d54
4
+ data.tar.gz: 5083f9d514fb7163910a0d8ed9b3129762c1f561e11ceb50ec3f0f214148f56f
5
+ SHA512:
6
+ metadata.gz: 252df23df32876233d65684b4afb80b2756e788a7b51b0d6dc3fc6cc3b504dd83a15064ab69ff49a09432fd97b2dcccf7d5fb32180d04fbf21f3b19577cb5baf
7
+ data.tar.gz: 2ce4577ad5003b603dba86a1bdd1946bc45735b3631c686e82e883ff236e547152478b5992bc18375241fc4a697248a63d4631caa6651c0e54dd11e81e27d895
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas Demoncy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # super_pdp
2
+
3
+ Thin, dependency-free Ruby client for the [SUPER PDP API](https://api.superpdp.tech) —
4
+ send and receive invoices under France's e-invoicing reform (Peppol network).
5
+
6
+ Stdlib only (`net/http` + `json`). No Faraday, no generated model classes.
7
+
8
+ > **Unofficial.** This is an independent, community-maintained client. It is
9
+ > **not** affiliated with, endorsed by, or supported by SUPER PDP. "SUPER PDP"
10
+ > and any related marks belong to their respective owners; they are used here
11
+ > only to describe what this gem talks to (nominative use). The gem calls the
12
+ > publicly documented HTTP API — no proprietary code is included — and is
13
+ > provided "as is" under the MIT license, with no warranty. The maintainer may
14
+ > update or **yank this gem from RubyGems at any time**; pin a version if you
15
+ > depend on it.
16
+
17
+ ## Install
18
+
19
+ ```ruby
20
+ gem "super_pdp"
21
+ ```
22
+
23
+ ## Auth
24
+
25
+ Two modes, both OAuth2:
26
+
27
+ ```ruby
28
+ # Client credentials — acts on your own data (api-key-like). Token is fetched
29
+ # and refreshed automatically.
30
+ pdp = SuperPDP.new(client_id: "...", client_secret: "...")
31
+
32
+ # Bearer token — acts on a user's behalf. You obtain the token yourself via the
33
+ # authorization_code flow (/oauth2/authorize) and pass it in.
34
+ pdp = SuperPDP.new(access_token: "user-token")
35
+ ```
36
+
37
+ ## Use
38
+
39
+ ```ruby
40
+ pdp.companies_me
41
+ pdp.create_invoice({ en_invoice: { ... } }) # body is a positional Hash
42
+ pdp.create_invoice({ en_invoice: { ... } }, expand: %w[events]) # + query params
43
+ pdp.invoice(42, expand: %w[en_invoice events])
44
+ pdp.download_invoice(42) # raw bytes (PDF/XML), not JSON
45
+ pdp.create_invoice_event(invoice_id: 42, status: "fr:204")
46
+
47
+ # Cursor pagination handled for you:
48
+ pdp.each_item("/invoices", direction: "outgoing").each do |inv|
49
+ puts inv["id"]
50
+ end
51
+ ```
52
+
53
+ Named helpers exist for every endpoint (companies, invoices, invoice_events,
54
+ ereportings, b2c_transactions, b2c_payments, directory_entries,
55
+ french_directory, validation_reports, oauth2_sessions). For anything not
56
+ wrapped, drop to the raw verbs:
57
+
58
+ ```ruby
59
+ pdp.get("/invoices", limit: 10)
60
+ pdp.post("/some/new/route", { key: "value" })
61
+ ```
62
+
63
+ Responses are parsed JSON (`Hash`/`Array`). Non-2xx raises `SuperPDP::APIError`
64
+ (`#status`, `#body`).
65
+
66
+ ## Test
67
+
68
+ ```
69
+ bundle exec rake test # or: bundle exec rake (runs tests + rubocop)
70
+ ```
71
+
72
+ ## Contributing
73
+
74
+ Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). In short:
75
+ open an issue to discuss anything non-trivial, then send a PR with `bundle exec
76
+ rake` (tests + rubocop) passing.
77
+
78
+ ## Releasing (maintainers)
79
+
80
+ Bump `SuperPDP::VERSION`, update `CHANGELOG.md`, then:
81
+
82
+ ```
83
+ bundle exec rake release # tags the version and pushes the .gem to RubyGems
84
+ ```
85
+
86
+ ## License
87
+
88
+ MIT — see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module SuperPDP
8
+ # Thin HTTP client for the SUPER PDP API.
9
+ #
10
+ # Auth: pass an +access_token+ directly (e.g. one you obtained via the
11
+ # authorization_code flow to act on a user's behalf), or pass
12
+ # +client_id+/+client_secret+ to have the client fetch and refresh a
13
+ # client_credentials token automatically (acts on your own data, api-key-like).
14
+ class Client
15
+ DEFAULT_BASE_URL = "https://api.superpdp.tech"
16
+ API_PREFIX = "/v1.beta"
17
+
18
+ attr_reader :base_url
19
+
20
+ def initialize(access_token: nil, client_id: nil, client_secret: nil,
21
+ base_url: DEFAULT_BASE_URL, open_timeout: 10, read_timeout: 60)
22
+ @access_token = access_token
23
+ @client_id = client_id
24
+ @client_secret = client_secret
25
+ @base_url = base_url
26
+ @open_timeout = open_timeout
27
+ @read_timeout = read_timeout
28
+ @token_expires_at = nil
29
+ @token_mutex = Mutex.new
30
+
31
+ return if access_token || (client_id && client_secret)
32
+
33
+ raise ArgumentError, "provide access_token, or client_id and client_secret"
34
+ end
35
+
36
+ # --- Resource helpers (thin wrappers over the raw verbs) --------------
37
+
38
+ def companies_me(**params) = get("/companies/me", params)
39
+ def enroll_company(body) = post("/companies", body)
40
+ def update_company_vat_regime(body) = patch("/companies", body)
41
+
42
+ def invoices(**params) = get("/invoices", params)
43
+ def invoice(id, **params) = get("/invoices/#{id}", params)
44
+ def create_invoice(body, **params) = post("/invoices", body, params)
45
+ def convert_invoice(body, **params) = post("/invoices/convert", body, params)
46
+ def validation_report(body) = post("/validation_reports", body)
47
+ def generate_test_invoice(**params) = get("/invoices/generate_test_invoice", params)
48
+
49
+ # Raw invoice file bytes (not JSON) — returns the String body.
50
+ def download_invoice(id, **params) = request(:get, "/invoices/#{id}/download", query: params, raw: true)
51
+
52
+ def invoice_events(**params) = get("/invoice_events", params)
53
+ def create_invoice_event(body) = post("/invoice_events", body)
54
+
55
+ def ereportings(**params) = get("/ereportings", params)
56
+ def ereporting(id, **params) = get("/ereportings/#{id}", params)
57
+ def preview_ereporting(**params) = get("/ereportings/preview", params)
58
+
59
+ def b2c_transactions(**params) = get("/b2c_transactions", params)
60
+ def create_b2c_transactions(body) = post("/b2c_transactions", body)
61
+ def b2c_payments(**params) = get("/b2c_payments", params)
62
+ def create_b2c_payment(body) = post("/b2c_payments", body)
63
+
64
+ def directory_entries(**params) = get("/directory_entries", params)
65
+ def directory_entry(id, **params) = get("/directory_entries/#{id}", params)
66
+ def create_directory_entry(body) = post("/directory_entries", body)
67
+ def delete_directory_entry(id) = delete("/directory_entries/#{id}")
68
+
69
+ def french_directory_companies(**params) = get("/french_directory/companies", params)
70
+ def french_directory_entries(**params) = get("/french_directory/entries", params)
71
+
72
+ def oauth2_session_me(**params) = get("/oauth2_sessions/me", params)
73
+
74
+ # Iterate a list endpoint across all pages, yielding each item.
75
+ # Uses the API's cursor pagination (starting_after_id + has_after).
76
+ # Returns an Enumerator when no block is given.
77
+ def each_item(path, **params, &block)
78
+ return enum_for(:each_item, path, **params) unless block_given?
79
+
80
+ cursor = params[:starting_after_id]
81
+ loop do
82
+ page = get(path, params.merge(starting_after_id: cursor).compact)
83
+ data = page["data"] || []
84
+ data.each(&block)
85
+ break unless page["has_after"] && !data.empty?
86
+
87
+ cursor = data.last["id"]
88
+ end
89
+ end
90
+
91
+ # --- Raw verbs --------------------------------------------------------
92
+
93
+ def get(path, query = {}) = request(:get, path, query: query)
94
+ def post(path, body = {}, query = {}) = request(:post, path, body: body, query: query)
95
+ def patch(path, body = {}, query = {}) = request(:patch, path, body: body, query: query)
96
+ def delete(path, query = {}) = request(:delete, path, query: query)
97
+
98
+ def request(method, path, query: {}, body: nil, raw: false)
99
+ uri = build_uri(path, query)
100
+ req = build_request(method, uri, body)
101
+ req["Authorization"] = "Bearer #{token}"
102
+
103
+ res = http(uri).request(req)
104
+ handle_response(res, raw: raw)
105
+ end
106
+
107
+ private
108
+
109
+ def build_uri(path, query)
110
+ full = path.start_with?("/v1") || path.start_with?("http") ? path : "#{API_PREFIX}#{path}"
111
+ uri = URI.join(@base_url, full)
112
+ q = flatten_query(query || {})
113
+ uri.query = URI.encode_www_form(q) unless q.empty?
114
+ uri
115
+ end
116
+
117
+ # Encode array params as repeated `key[]=v` pairs (matches expand[] etc).
118
+ def flatten_query(query)
119
+ query.compact.flat_map do |k, v|
120
+ v.is_a?(Array) ? v.map { |item| ["#{k}[]", item] } : [[k.to_s, v]]
121
+ end
122
+ end
123
+
124
+ def build_request(method, uri, body)
125
+ klass = {
126
+ get: Net::HTTP::Get, post: Net::HTTP::Post,
127
+ patch: Net::HTTP::Patch, delete: Net::HTTP::Delete
128
+ }.fetch(method)
129
+ req = klass.new(uri)
130
+ req["Accept"] = "application/json"
131
+ if body
132
+ req["Content-Type"] = "application/json"
133
+ req.body = body.is_a?(String) ? body : JSON.generate(body)
134
+ end
135
+ req
136
+ end
137
+
138
+ def http(uri)
139
+ Net::HTTP.new(uri.host, uri.port).tap do |h|
140
+ h.use_ssl = uri.scheme == "https"
141
+ h.open_timeout = @open_timeout
142
+ h.read_timeout = @read_timeout
143
+ end
144
+ end
145
+
146
+ def handle_response(res, raw:)
147
+ status = res.code.to_i
148
+ body = raw ? res.body : parse_json(res.body)
149
+ return body if status.between?(200, 299)
150
+
151
+ raise APIError.new(status, body)
152
+ end
153
+
154
+ def parse_json(str)
155
+ return nil if str.nil? || str.empty?
156
+
157
+ JSON.parse(str)
158
+ rescue JSON::ParserError
159
+ str
160
+ end
161
+
162
+ # --- OAuth2 -----------------------------------------------------------
163
+
164
+ def token
165
+ # Lock so a shared client in a threaded server (Puma/Sidekiq) doesn't
166
+ # race two concurrent refreshes. Double-checked inside the lock.
167
+ @token_mutex.synchronize do
168
+ return @access_token if @access_token && !expired?
169
+
170
+ fetch_client_credentials_token
171
+ end
172
+ end
173
+
174
+ def expired?
175
+ @token_expires_at && Time.now >= @token_expires_at
176
+ end
177
+
178
+ def fetch_client_credentials_token
179
+ unless @client_id && @client_secret
180
+ raise AuthError, "access_token missing/expired and no client credentials to refresh it"
181
+ end
182
+
183
+ uri = URI.join(@base_url, "/oauth2/token")
184
+ req = Net::HTTP::Post.new(uri)
185
+ req["Accept"] = "application/json"
186
+ req.set_form_data(
187
+ grant_type: "client_credentials",
188
+ client_id: @client_id,
189
+ client_secret: @client_secret
190
+ )
191
+ res = http(uri).request(req)
192
+ raise AuthError, "token request failed (#{res.code}): #{res.body}" unless res.code.to_i.between?(200, 299)
193
+
194
+ data = JSON.parse(res.body)
195
+ @access_token = data.fetch("access_token")
196
+ # Refresh 60s early. Default to 1h if the server omits expires_in.
197
+ @token_expires_at = Time.now + (data["expires_in"] || 3600).to_i - 60
198
+ @access_token
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperPDP
4
+ class Error < StandardError; end
5
+
6
+ # Raised for any non-2xx HTTP response. Carries the status and parsed body.
7
+ class APIError < Error
8
+ attr_reader :status, :body
9
+
10
+ def initialize(status, body)
11
+ @status = status
12
+ @body = body
13
+ message = body.is_a?(Hash) ? (body["message"] || body["error"] || body.to_s) : body.to_s
14
+ super("SuperPDP API error #{status}: #{message}")
15
+ end
16
+ end
17
+
18
+ class AuthError < Error; end
19
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperPDP
4
+ VERSION = "0.1.0"
5
+ end
data/lib/super_pdp.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "super_pdp/version"
4
+ require_relative "super_pdp/error"
5
+ require_relative "super_pdp/client"
6
+
7
+ module SuperPDP
8
+ def self.new(...) = Client.new(...)
9
+ end
metadata ADDED
@@ -0,0 +1,121 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: super_pdp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Thomas Demoncy
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rubocop
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.60'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.60'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rubocop-minitest
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.35'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.35'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rubocop-rake
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.6'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.6'
82
+ description: Thin, dependency-free Ruby client for the SUPER PDP API (France electronic
83
+ invoicing reform / Peppol).
84
+ email:
85
+ - thomas.demoncy@gmail.com
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - LICENSE.txt
91
+ - README.md
92
+ - lib/super_pdp.rb
93
+ - lib/super_pdp/client.rb
94
+ - lib/super_pdp/error.rb
95
+ - lib/super_pdp/version.rb
96
+ homepage: https://api.superpdp.tech
97
+ licenses:
98
+ - MIT
99
+ metadata:
100
+ homepage_uri: https://api.superpdp.tech
101
+ source_code_uri: https://github.com/ThomasDmnc/supepdp-gem
102
+ changelog_uri: https://github.com/ThomasDmnc/supepdp-gem/blob/main/CHANGELOG.md
103
+ rubygems_mfa_required: 'true'
104
+ rdoc_options: []
105
+ require_paths:
106
+ - lib
107
+ required_ruby_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '3.0'
112
+ required_rubygems_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ requirements: []
118
+ rubygems_version: 3.6.9
119
+ specification_version: 4
120
+ summary: Ruby client for the SUPER PDP e-invoicing API
121
+ test_files: []