einvoicing-connect 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 54ce574178b7dcbed5b437a34f88672ae3d0d5923f7262d679cbc927d582265e
4
- data.tar.gz: 96f661a9749c2b1c41b79bc5f52c9c48823bef8d314a7432e783137c560be7c8
3
+ metadata.gz: a3d4d4580d842a03b65af1d21a25c888685ad7fa8f69e3ee2b8b537c51bab015
4
+ data.tar.gz: bc35697fb4f88aa875b9460a106bffc2dc2578a57b0d8a19b32065a71bfb1c30
5
5
  SHA512:
6
- metadata.gz: 4d32efd286fb9e98e0b3e9038e409e40f4f44f9d3b641e4dccb94584e4866daa076146799d8d2985dfbe5799966d8f3c8af43332d9f70a8daafeeface89c9909
7
- data.tar.gz: 03df43147ea54a77683c0775bfda4f94c0e492cfca4427c8e7a1ded0215d266f79fe845984fb7864ef70f783cf13fb93d80f848ed8656860ebb5787a4ed47c90
6
+ metadata.gz: '080192ef85f10fe22030b187abadcebcb44efc726c9438af82235d6e6e81a9df38c932ccbbf22bcc43941bad9165103c29620d8ef5f297bb545aa38a37ab90a7'
7
+ data.tar.gz: eafe755d1d350e52efb9096170030a417e4db99d6dd420d0f773cbd8ace143482e561669bf2fe13791d47c910b307ab7229fb67862b4eb9cf713b8d1f3b39bc4
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SXN Labs
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,220 @@
1
+ # einvoicing-connect
2
+
3
+ Platform connectors for the [einvoicing](https://www.sxnlabs.com/en/gems/einvoicing/) gem — Pennylane, PPF/Chorus Pro, and SIRET lookup for French e-invoicing.
4
+
5
+ → **[Full documentation and guides](https://www.sxnlabs.com/en/gems/einvoicing/)**
6
+
7
+ ## Installation
8
+
9
+ ```ruby
10
+ gem "einvoicing-connect"
11
+ ```
12
+
13
+ `hexapdf` is required for Factur-X PDF generation (Pennylane connector):
14
+
15
+ ```ruby
16
+ gem "hexapdf"
17
+ ```
18
+
19
+ ## Connectors
20
+
21
+ ### Pennylane (`Connect::FR::Pennylane`)
22
+
23
+ Submits invoices to [Pennylane](https://www.pennylane.com/) via the Factur-X e-invoice import API. The gem generates a standards-compliant CII XML document, embeds it into your PDF (producing a Factur-X PDF/A-3), and uploads it to Pennylane.
24
+
25
+ #### Authentication
26
+
27
+ **Companies and Firms** use a personal access token generated in Pennylane account settings:
28
+
29
+ ```ruby
30
+ creds = Einvoicing::Connect::FR::Pennylane::Credentials.api_key("tok_xxx")
31
+ ```
32
+
33
+ **Integration Partners** use OAuth2 with an access token + refresh token obtained via the authorization code flow:
34
+
35
+ ```ruby
36
+ creds = Einvoicing::Connect::FR::Pennylane::Credentials.oauth(
37
+ access_token: "...",
38
+ refresh_token: "...",
39
+ client_id: "...",
40
+ client_secret: "...",
41
+ expires_at: Time.now + 3600 # optional
42
+ )
43
+ ```
44
+
45
+ OAuth credentials are mutable — when an expired token is refreshed automatically, `creds.access_token`, `creds.refresh_token`, and `creds.expires_at` are updated in place so you can persist the new values.
46
+
47
+ #### Submitting an e-invoice
48
+
49
+ ```ruby
50
+ require "hexapdf"
51
+
52
+ creds = Einvoicing::Connect::FR::Pennylane::Credentials.api_key(ENV["PENNYLANE_API_KEY"])
53
+ submitter = Einvoicing::Connect::FR::Pennylane::EInvoiceSubmitter.new(credentials: creds)
54
+
55
+ invoice = Einvoicing::Invoice.new(
56
+ invoice_number: "INV-2025-001",
57
+ issue_date: Date.today,
58
+ due_date: Date.today + 30,
59
+ currency: "EUR",
60
+ seller: Einvoicing::Party.new(name: "Acme SAS", siret: "35600000000048"),
61
+ buyer: Einvoicing::Party.new(
62
+ name: "Client SA",
63
+ siret: "55203253400017",
64
+ street: "1 rue de la Paix",
65
+ city: "Paris",
66
+ postal_code: "75001"
67
+ ),
68
+ lines: [
69
+ Einvoicing::LineItem.new(description: "Consulting", quantity: 5,
70
+ unit_price: 800.00, vat_rate: 0.20),
71
+ Einvoicing::LineItem.new(description: "Licence ERP", quantity: 1,
72
+ unit_price: 1_200.00, vat_rate: 0.20),
73
+ Einvoicing::LineItem.new(description: "Remise fidélité", quantity: 1,
74
+ unit_price: -200.00, vat_rate: 0.20),
75
+ ]
76
+ )
77
+
78
+ # pdf is the binary content of a PDF (your human-readable invoice document)
79
+ pdf = File.binread("invoice.pdf")
80
+ result = submitter.submit(invoice, pdf: pdf)
81
+
82
+ puts result["id"] # Pennylane invoice ID
83
+ puts result["url"] # Pennylane invoice URL
84
+ ```
85
+
86
+ #### Checking invoice status
87
+
88
+ ```ruby
89
+ client = Einvoicing::Connect::FR::Pennylane::Client.new(credentials: creds)
90
+ status = client.invoice_status(result["id"])
91
+ puts status["status"] # e.g. "processing", "sent"
92
+ ```
93
+
94
+ #### Sandbox
95
+
96
+ Pennylane's sandbox environment uses a separate subdomain:
97
+
98
+ ```ruby
99
+ submitter = Einvoicing::Connect::FR::Pennylane::EInvoiceSubmitter.new(
100
+ credentials: creds,
101
+ sandbox: true
102
+ )
103
+ ```
104
+
105
+ ---
106
+
107
+ ### PPF / Chorus Pro (`Connect::FR::PPF`)
108
+
109
+ Submits invoices to the French government's Chorus Pro platform (PPF) via the PISTE API.
110
+
111
+ #### Authentication
112
+
113
+ Chorus Pro uses OAuth2 client credentials:
114
+
115
+ ```ruby
116
+ client = Einvoicing::Connect::FR::PPF::Client.new(
117
+ client_id: ENV["CPP_CLIENT_ID"],
118
+ client_secret: ENV["CPP_CLIENT_SECRET"],
119
+ sandbox: true
120
+ )
121
+ ```
122
+
123
+ For **technical account** (compte technique) submission, add the optional credentials:
124
+
125
+ ```ruby
126
+ client = Einvoicing::Connect::FR::PPF::Client.new(
127
+ client_id: ENV["CPP_CLIENT_ID"],
128
+ client_secret: ENV["CPP_CLIENT_SECRET"],
129
+ technical_login: ENV["CPP_TECHNICAL_LOGIN"],
130
+ technical_password: ENV["CPP_TECHNICAL_PASSWORD"]
131
+ )
132
+ ```
133
+
134
+ #### Submitting an invoice
135
+
136
+ ```ruby
137
+ submitter = Einvoicing::Connect::FR::PPF::Submitter.new(client)
138
+ result = submitter.submit(invoice)
139
+
140
+ puts result["numeroFlux"] # submission reference
141
+ puts result["statut"] # e.g. "A_TRAITER"
142
+ ```
143
+
144
+ Optional parameters:
145
+
146
+ ```ruby
147
+ submitter.submit(invoice,
148
+ code_service: "SRV001", # Chorus Pro service code
149
+ engagement_number: "ENG-2025" # buyer engagement/PO number
150
+ )
151
+ ```
152
+
153
+ The submitter automatically resolves the buyer's `idStructureCPP` from their SIRET via `find_structure` before submitting.
154
+
155
+ ---
156
+
157
+ ### SIRET Lookup (`Connect::FR::SiretLookup`)
158
+
159
+ Enriches a `Party` with a SIRET from the French government company search API, given only a SIREN.
160
+
161
+ ```ruby
162
+ buyer = Einvoicing::Party.new(name: "Client SA", siren: "552032534")
163
+ enriched = Einvoicing::Connect::FR::SiretLookup.enrich!(buyer)
164
+ enriched.siret # => "55203253400017"
165
+ ```
166
+
167
+ Returns a new `Party` instance (non-destructive). Returns the original party unchanged if SIRET is already present or the lookup fails.
168
+
169
+ ---
170
+
171
+ ### E-invoicing directory (`Connect::FR::Directory`)
172
+
173
+ Resolves **where to deliver an invoice** for a French company. Under the 2026 reform, every invoice is routed through the central directory (the PPF directory operated by DGFiP/AIFE): the issuing platform looks up the recipient's SIREN/SIRET to obtain the registered reception platform (PDP) and its technical routing code.
174
+
175
+ ```ruby
176
+ routing = Einvoicing::Connect::FR::Directory.lookup("55203253400017")
177
+ routing[:routing_code] # => "PDP000123" (technical routing code)
178
+ routing[:platform_name] # => "Acme PDP"
179
+ routing[:level] # => "SIRET" (or "SIREN")
180
+
181
+ # Or directly from a Party (prefers SIRET, falls back to SIREN):
182
+ buyer = Einvoicing::Party.new(name: "Client SA", siret: "55203253400017")
183
+ routing = Einvoicing::Connect::FR::Directory.route(buyer)
184
+ ```
185
+
186
+ Both methods return `nil` on any error or when the recipient is not found in the directory.
187
+
188
+ > ⚠️ **Preview.** The official DGFiP/AIFE directory API specification is not yet final (reform pilot opened 2026‑02‑27, general availability 2026‑09‑01). The endpoint and response shape are expected to evolve — set `Einvoicing::Connect::FR::Directory.api_url = "..."` (or pass `api_url:` per call) to point at the production endpoint once confirmed.
189
+
190
+ ---
191
+
192
+ ## Error handling
193
+
194
+ Each connector defines its own error hierarchy:
195
+
196
+ ```
197
+ Pennylane::Error
198
+ ├── Pennylane::AuthError # invalid API key (401)
199
+ ├── Pennylane::OAuthError # token refresh failed
200
+ └── Pennylane::SubmissionError # other API errors (4xx/5xx)
201
+
202
+ PPF::Error
203
+ ├── PPF::AuthenticationError # OAuth token request failed
204
+ ├── PPF::AuthorizationError # 403 Forbidden
205
+ ├── PPF::NotFoundError # 404 Not Found
206
+ ├── PPF::APIError # other API errors
207
+ └── PPF::ValidationError # e.g. buyer SIRET not found in Chorus Pro
208
+ ```
209
+
210
+ ## Re-recording integration test cassettes
211
+
212
+ Integration tests use VCR cassettes (committed to the repo, token scrubbed). To re-record against the real API:
213
+
214
+ ```bash
215
+ PENNYLANE_API_KEY=your_token bundle exec rspec spec/integration/
216
+ ```
217
+
218
+ ## License
219
+
220
+ MIT
@@ -8,6 +8,12 @@ en:
8
8
  not_found: "Not found: %{body}"
9
9
  api_error: "API error %{code}: %{body}"
10
10
  structure_not_found: "Buyer SIRET %{siret} not found in Chorus Pro"
11
+ missing_technical_account: "Chorus Pro technical account (login/password) is required for API submission"
12
+ pennylane:
13
+ auth_failed: "Pennylane authentication failed — check your API key"
14
+ oauth_failed: "Pennylane OAuth token refresh failed (HTTP %{code}): %{body}"
15
+ submission_failed: "Pennylane invoice submission failed: %{code} %{body}"
16
+ invalid_invoice: "Invoice is missing required fields for Pennylane submission"
11
17
  fr:
12
18
  siret_api_error: "SIRET lookup failed: %{message}"
13
19
  siret_not_found: "No company found for SIREN: %{siren}"
@@ -8,6 +8,12 @@ fr:
8
8
  not_found: "Non trouvé : %{body}"
9
9
  api_error: "Erreur API %{code} : %{body}"
10
10
  structure_not_found: "SIRET acheteur %{siret} non trouvé dans Chorus Pro"
11
+ missing_technical_account: "Le compte technique Chorus Pro (identifiant/mot de passe) est requis pour la soumission API"
12
+ pennylane:
13
+ auth_failed: "Échec de l'authentification Pennylane — vérifiez votre clé API"
14
+ oauth_failed: "Échec du renouvellement du jeton OAuth Pennylane (HTTP %{code}) : %{body}"
15
+ submission_failed: "Échec de la soumission de la facture Pennylane : %{code} %{body}"
16
+ invalid_invoice: "La facture est incomplète pour la soumission Pennylane"
11
17
  fr:
12
18
  siret_api_error: "Échec de la recherche SIRET : %{message}"
13
19
  siret_not_found: "Aucune entreprise trouvée pour le SIREN : %{siren}"
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Einvoicing
8
+ module Connect
9
+ module FR
10
+ # Consultation of the central French e-invoicing directory (the PPF
11
+ # directory operated by DGFiP/AIFE).
12
+ #
13
+ # Given a French SIREN or SIRET, resolves the recipient's e-invoicing
14
+ # routing information: the registered reception platform (PDP) and its
15
+ # technical routing code. This is the lookup an issuing platform performs
16
+ # to know *where* to deliver an invoice for a company.
17
+ #
18
+ # NOTE: The official DGFiP/AIFE directory API specification is not yet
19
+ # final. The reform pilot opened on 2026-02-27, with general availability
20
+ # on 2026-09-01. The endpoint and response shape below follow the published
21
+ # interoperability framework and are expected to evolve — point
22
+ # +Directory.api_url=+ at the production endpoint once it is confirmed.
23
+ module Directory
24
+ # Placeholder endpoint, overridable via Directory.api_url= or per call.
25
+ DEFAULT_API_URL = "https://annuaire.facturation.gouv.fr/api/v1/destinataires" unless defined?(DEFAULT_API_URL)
26
+
27
+ class << self
28
+ attr_writer :api_url
29
+
30
+ def api_url
31
+ @api_url ||= DEFAULT_API_URL
32
+ end
33
+ end
34
+
35
+ # Look up the routing information for a recipient identified by SIREN
36
+ # (9 digits) or SIRET (14 digits).
37
+ #
38
+ # Returns a Hash on success, or nil on any error / no match:
39
+ # {
40
+ # identifier: "55203253400017",
41
+ # level: "SIRET", # or "SIREN"
42
+ # routing_code: "PDP000123", # technical routing code
43
+ # platform_id: "0000000000000", # PDP registration id
44
+ # platform_name: "Acme PDP",
45
+ # status: "active"
46
+ # }
47
+ def self.lookup(identifier, api_url: self.api_url)
48
+ id = identifier.to_s.gsub(/\s/, "")
49
+ return nil unless id.match?(/\A\d{9}(\d{5})?\z/)
50
+
51
+ uri = URI(api_url)
52
+ uri.query = URI.encode_www_form(identifiant: id)
53
+
54
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
55
+ open_timeout: 5, read_timeout: 10) do |http|
56
+ http.get(uri.request_uri)
57
+ end
58
+
59
+ return nil unless response.code == "200"
60
+
61
+ parse(JSON.parse(response.body))
62
+ rescue StandardError
63
+ nil
64
+ end
65
+
66
+ # Resolve the routing information for a Party, preferring its SIRET and
67
+ # falling back to its SIREN. Returns the routing Hash or nil.
68
+ def self.route(party, api_url: self.api_url)
69
+ identifier = party.siret.to_s.strip
70
+ identifier = party.siren.to_s.strip if identifier.empty? && party.respond_to?(:siren)
71
+ return nil if identifier.empty?
72
+
73
+ lookup(identifier, api_url: api_url)
74
+ end
75
+
76
+ # Internal: map a directory API payload to our routing Hash. The string
77
+ # keys below are the external API field names (kept in their original
78
+ # form, as with SiretLookup's Sirene keys).
79
+ def self.parse(data)
80
+ entry = data.is_a?(Hash) ? (data["destinataire"] || data["results"]&.first || data) : nil
81
+ return nil unless entry.is_a?(Hash)
82
+
83
+ routing_code = entry["codeRoutage"]
84
+ platform_id = entry["idPlateforme"]
85
+ # An entry is routable as long as it provides a delivery target:
86
+ # either a technical routing code or the recipient's registered
87
+ # platform. SIREN/SIRET-level entries may expose only the platform,
88
+ # with no codeRoutage sub-address — keep those instead of dropping them.
89
+ return nil if routing_code.to_s.empty? && platform_id.to_s.empty?
90
+
91
+ {
92
+ identifier: entry["identifiant"],
93
+ level: entry["maille"],
94
+ routing_code: routing_code,
95
+ platform_id: platform_id,
96
+ platform_name: entry["nomPlateforme"],
97
+ status: entry["statut"]
98
+ }
99
+ end
100
+ private_class_method :parse
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Einvoicing
4
+ module Connect
5
+ module FR
6
+ module Pennylane
7
+ class Adapter
8
+ def initialize(invoice)
9
+ @invoice = invoice
10
+ end
11
+
12
+ # Maps Einvoicing::Invoice to Pennylane customer_invoices payload
13
+ def to_payload
14
+ {
15
+ customer_invoice: {
16
+ date: @invoice.issue_date.iso8601,
17
+ deadline: @invoice.due_date&.iso8601,
18
+ invoice_number: @invoice.invoice_number,
19
+ currency: @invoice.currency || "EUR",
20
+ line_items_attributes: line_items,
21
+ customer_attributes: customer
22
+ }
23
+ }
24
+ end
25
+
26
+ private
27
+
28
+ def line_items
29
+ @invoice.lines.map do |line|
30
+ {
31
+ label: line.description,
32
+ quantity: line.quantity,
33
+ unit_price: line.unit_price,
34
+ vat_rate: (line.vat_rate * 100).round(2).to_s
35
+ }
36
+ end
37
+ end
38
+
39
+ def customer
40
+ b = @invoice.buyer
41
+ {
42
+ name: b.name,
43
+ reg_no: b.siret || b.siren,
44
+ address: b.street,
45
+ city: b.city,
46
+ postal_code: b.postal_code,
47
+ country_alpha2: b.country_code || "FR"
48
+ }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require "securerandom"
7
+
8
+ module Einvoicing
9
+ module Connect
10
+ module FR
11
+ module Pennylane
12
+ class Client
13
+ BASE_URL = "https://app.pennylane.com/api/external/v2"
14
+ SANDBOX_URL = "https://sandbox.pennylane.com/api/external/v2"
15
+
16
+ OAUTH_TOKEN_PATH = "/oauth/token"
17
+ OAUTH_BASE_URL = "https://app.pennylane.com"
18
+ OAUTH_SANDBOX_URL = "https://sandbox.pennylane.com"
19
+
20
+ def initialize(credentials:, sandbox: false)
21
+ @credentials = credentials
22
+ @base_url = sandbox ? SANDBOX_URL : BASE_URL
23
+ @oauth_base = sandbox ? OAUTH_SANDBOX_URL : OAUTH_BASE_URL
24
+ end
25
+
26
+ # Submit a Factur-X PDF binary to the e-invoice import endpoint.
27
+ # invoice_options: optional Hash pre-filling customer/line data.
28
+ def submit_einvoice(facturx_pdf, filename: "factur-x.pdf", invoice_options: nil)
29
+ post_multipart("/customer_invoices/e_invoices/imports",
30
+ file: facturx_pdf, filename: filename,
31
+ invoice_options: invoice_options)
32
+ end
33
+
34
+ # Get invoice status by Pennylane invoice ID.
35
+ def invoice_status(id)
36
+ get("/customer_invoices/#{id}")
37
+ end
38
+
39
+ private
40
+
41
+ def current_token
42
+ case @credentials
43
+ when Credentials::OAuth
44
+ refresh_oauth_token! if @credentials.expired?
45
+ @credentials.access_token
46
+ when Credentials::ApiKey
47
+ @credentials.api_key
48
+ end
49
+ end
50
+
51
+ def refresh_oauth_token!
52
+ uri = URI("#{@oauth_base}#{OAUTH_TOKEN_PATH}")
53
+ req = Net::HTTP::Post.new(uri)
54
+ req["Content-Type"] = "application/x-www-form-urlencoded"
55
+ req.body = URI.encode_www_form(
56
+ grant_type: "refresh_token",
57
+ refresh_token: @credentials.refresh_token,
58
+ client_id: @credentials.client_id,
59
+ client_secret: @credentials.client_secret
60
+ )
61
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
62
+ unless res.is_a?(Net::HTTPSuccess)
63
+ raise OAuthError, ::I18n.t("einvoicing.connect.pennylane.oauth_failed",
64
+ code: res.code, body: res.body.to_s[0, 200])
65
+ end
66
+
67
+ data = JSON.parse(res.body)
68
+ @credentials.access_token = data["access_token"]
69
+ @credentials.refresh_token = data["refresh_token"] if data["refresh_token"]
70
+ @credentials.expires_at = Time.now + data.fetch("expires_in", 3600).to_i - 60
71
+ end
72
+
73
+ def post(path, body)
74
+ uri = URI("#{@base_url}#{path}")
75
+ http = Net::HTTP.new(uri.host, uri.port)
76
+ http.use_ssl = true
77
+ req = Net::HTTP::Post.new(uri)
78
+ req["Authorization"] = "Bearer #{current_token}"
79
+ req["Content-Type"] = "application/json"
80
+ req.body = JSON.generate(body)
81
+ handle_response(http.request(req))
82
+ end
83
+
84
+ def post_multipart(path, file:, filename:, invoice_options: nil)
85
+ boundary = "RubyBoundary#{SecureRandom.hex(16)}"
86
+ uri = URI("#{@base_url}#{path}")
87
+ http = Net::HTTP.new(uri.host, uri.port)
88
+ http.use_ssl = true
89
+ req = Net::HTTP::Post.new(uri)
90
+ req["Authorization"] = "Bearer #{current_token}"
91
+ req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
92
+ req.body = build_multipart_body(boundary, file: file, filename: filename,
93
+ invoice_options: invoice_options)
94
+ handle_response(http.request(req))
95
+ end
96
+
97
+ def build_multipart_body(boundary, file:, filename:, invoice_options:)
98
+ body = +""
99
+ body << "--#{boundary}\r\n"
100
+ body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\"\r\n"
101
+ body << "Content-Type: application/pdf\r\n\r\n"
102
+ body << file.b
103
+ body << "\r\n"
104
+ if invoice_options
105
+ body << "--#{boundary}\r\n"
106
+ body << "Content-Disposition: form-data; name=\"invoice_options\"\r\n"
107
+ body << "Content-Type: application/json\r\n\r\n"
108
+ body << JSON.generate(invoice_options)
109
+ body << "\r\n"
110
+ end
111
+ body << "--#{boundary}--\r\n"
112
+ body.force_encoding("BINARY")
113
+ end
114
+
115
+ def get(path)
116
+ uri = URI("#{@base_url}#{path}")
117
+ http = Net::HTTP.new(uri.host, uri.port)
118
+ http.use_ssl = true
119
+ req = Net::HTTP::Get.new(uri)
120
+ req["Authorization"] = "Bearer #{current_token}"
121
+ handle_response(http.request(req))
122
+ end
123
+
124
+ def handle_response(response)
125
+ body = JSON.parse(response.body) rescue {}
126
+ case response.code.to_i
127
+ when 200..299
128
+ body
129
+ when 401
130
+ raise AuthError, ::I18n.t("einvoicing.connect.pennylane.auth_failed")
131
+ else
132
+ raise SubmissionError, ::I18n.t("einvoicing.connect.pennylane.submission_failed",
133
+ code: response.code,
134
+ body: response.body.to_s[0, 200])
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Einvoicing
4
+ module Connect
5
+ module FR
6
+ module Pennylane
7
+ # Encapsulates Pennylane API authentication credentials.
8
+ #
9
+ # Two modes are supported:
10
+ #
11
+ # 1. Static API key — for Companies and Firms using a personal access token:
12
+ # creds = Pennylane::Credentials.api_key("tok_xxx")
13
+ #
14
+ # 2. OAuth2 — for Integration Partners using the authorization code flow:
15
+ # creds = Pennylane::Credentials.oauth(
16
+ # access_token: "...",
17
+ # refresh_token: "...",
18
+ # client_id: "...",
19
+ # client_secret: "...",
20
+ # expires_at: Time.now + 3600 # optional
21
+ # )
22
+ #
23
+ # OAuth credentials are mutable: when the Client refreshes an expired access
24
+ # token it updates the object in place. The calling application can read back
25
+ # the updated tokens after any API call and persist them.
26
+ module Credentials
27
+ def self.api_key(key)
28
+ ApiKey.new(key)
29
+ end
30
+
31
+ def self.oauth(access_token:, refresh_token:, client_id:, client_secret:,
32
+ expires_at: nil)
33
+ OAuth.new(
34
+ access_token: access_token,
35
+ refresh_token: refresh_token,
36
+ client_id: client_id,
37
+ client_secret: client_secret,
38
+ expires_at: expires_at
39
+ )
40
+ end
41
+
42
+ # Static personal access token — never expires.
43
+ class ApiKey
44
+ attr_reader :api_key
45
+
46
+ def initialize(key)
47
+ @api_key = key
48
+ end
49
+ end
50
+
51
+ # OAuth2 access + refresh token pair. Mutable so the Client can update
52
+ # tokens in place after a refresh, making the new values available to the
53
+ # caller for persistence.
54
+ class OAuth
55
+ attr_reader :client_id, :client_secret
56
+ attr_accessor :access_token, :refresh_token, :expires_at
57
+
58
+ def initialize(access_token:, refresh_token:, client_id:, client_secret:,
59
+ expires_at: nil)
60
+ @access_token = access_token
61
+ @refresh_token = refresh_token
62
+ @client_id = client_id
63
+ @client_secret = client_secret
64
+ @expires_at = expires_at
65
+ end
66
+
67
+ def expired?
68
+ expires_at.nil? || Time.now >= expires_at
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end