einvoicing-connect 0.1.0 → 0.3.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: c1d5afb37eec4ff165c07ca7f35ca30470385c3c9820fe012f69bdb2b663d347
4
+ data.tar.gz: 79882e661a1683484e4a5ffdcc53640916e70b0b5e997a85b0a58ee14e74dfb2
5
5
  SHA512:
6
- metadata.gz: 4d32efd286fb9e98e0b3e9038e409e40f4f44f9d3b641e4dccb94584e4866daa076146799d8d2985dfbe5799966d8f3c8af43332d9f70a8daafeeface89c9909
7
- data.tar.gz: 03df43147ea54a77683c0775bfda4f94c0e492cfca4427c8e7a1ded0215d266f79fe845984fb7864ef70f783cf13fb93d80f848ed8656860ebb5787a4ed47c90
6
+ metadata.gz: c8f426765e61378bbbb5a8e7c7ea0860b93c9e73d0be38287b7d690731a095277ee62ed4d97570c4181f3086f6eae5ceacc1e706b15cbb470a1be779b524c71d
7
+ data.tar.gz: cec80f177749ff036fb921d011fa2f4b909647c1dfa29af808e49984a334cfe1ca4cb1ae0308eb20be6e0a0516212eb2bdb11f03d1f44b914e79ef94dcf34b9c
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,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require "cgi"
7
+ require "securerandom"
8
+
9
+ module Einvoicing
10
+ module Connect
11
+ module FR
12
+ module Pennylane
13
+ class Client
14
+ BASE_URL = "https://app.pennylane.com/api/external/v2"
15
+ SANDBOX_URL = "https://sandbox.pennylane.com/api/external/v2"
16
+
17
+ OAUTH_TOKEN_PATH = "/oauth/token"
18
+ OAUTH_BASE_URL = "https://app.pennylane.com"
19
+ OAUTH_SANDBOX_URL = "https://sandbox.pennylane.com"
20
+
21
+ def initialize(credentials:, sandbox: false)
22
+ @credentials = credentials
23
+ @base_url = sandbox ? SANDBOX_URL : BASE_URL
24
+ @oauth_base = sandbox ? OAUTH_SANDBOX_URL : OAUTH_BASE_URL
25
+ end
26
+
27
+ # Submit a Factur-X PDF binary to the e-invoice import endpoint.
28
+ # invoice_options: optional Hash pre-filling customer/line data.
29
+ def submit_einvoice(facturx_pdf, filename: "factur-x.pdf", invoice_options: nil)
30
+ post_multipart("/customer_invoices/e_invoices/imports",
31
+ file: facturx_pdf, filename: filename,
32
+ invoice_options: invoice_options)
33
+ end
34
+
35
+ # Get invoice status by Pennylane invoice ID.
36
+ def invoice_status(id)
37
+ get("/customer_invoices/#{id}")
38
+ end
39
+
40
+ # Find a customer by the caller's own identifier.
41
+ #
42
+ # Pennylane does not match a customer from the invoice itself: a
43
+ # document imported without +customer_id+ lands as "incomplete" with
44
+ # customer nil, whatever SIRET or VAT number the Factur-X carries.
45
+ # +external_reference+ is the caller's key into Pennylane's own
46
+ # numbering, which is what makes resolution repeatable.
47
+ #
48
+ # @return [Hash, nil] the customer, or nil when none carries that reference
49
+ def customer_by_reference(reference)
50
+ filter = JSON.generate([ { field: "external_reference", operator: "eq", value: reference } ])
51
+ get("/customers?filter=#{CGI.escape(filter)}")["items"]&.first
52
+ end
53
+
54
+ # Create a company customer (an organisation, invoiced under its own name).
55
+ #
56
+ # @param attributes [Hash] at least +name+ and +billing_address+;
57
+ # +reg_no+ carries the SIRET, +external_reference+ the caller's key.
58
+ def create_company_customer(attributes)
59
+ post("/company_customers", attributes)
60
+ end
61
+
62
+ # Create an individual customer (a private person).
63
+ def create_individual_customer(attributes)
64
+ post("/individual_customers", attributes)
65
+ end
66
+
67
+ # The customer for +reference+, created from +attributes+ if Pennylane
68
+ # does not know it yet. Idempotent as long as the caller keeps the
69
+ # reference stable, which is the point of it.
70
+ #
71
+ # @param company [Boolean] an organisation rather than a private person
72
+ def find_or_create_customer(reference:, attributes:, company: true)
73
+ existing = customer_by_reference(reference)
74
+ return existing if existing
75
+
76
+ payload = attributes.merge(external_reference: reference)
77
+ company ? create_company_customer(payload) : create_individual_customer(payload)
78
+ end
79
+
80
+ private
81
+
82
+ def current_token
83
+ case @credentials
84
+ when Credentials::OAuth
85
+ refresh_oauth_token! if @credentials.expired?
86
+ @credentials.access_token
87
+ when Credentials::ApiKey
88
+ @credentials.api_key
89
+ end
90
+ end
91
+
92
+ def refresh_oauth_token!
93
+ uri = URI("#{@oauth_base}#{OAUTH_TOKEN_PATH}")
94
+ req = Net::HTTP::Post.new(uri)
95
+ req["Content-Type"] = "application/x-www-form-urlencoded"
96
+ req.body = URI.encode_www_form(
97
+ grant_type: "refresh_token",
98
+ refresh_token: @credentials.refresh_token,
99
+ client_id: @credentials.client_id,
100
+ client_secret: @credentials.client_secret
101
+ )
102
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
103
+ unless res.is_a?(Net::HTTPSuccess)
104
+ raise OAuthError, ::I18n.t("einvoicing.connect.pennylane.oauth_failed",
105
+ code: res.code, body: res.body.to_s[0, 200])
106
+ end
107
+
108
+ data = JSON.parse(res.body)
109
+ @credentials.access_token = data["access_token"]
110
+ @credentials.refresh_token = data["refresh_token"] if data["refresh_token"]
111
+ @credentials.expires_at = Time.now + data.fetch("expires_in", 3600).to_i - 60
112
+ end
113
+
114
+ def post(path, body)
115
+ uri = URI("#{@base_url}#{path}")
116
+ http = Net::HTTP.new(uri.host, uri.port)
117
+ http.use_ssl = true
118
+ req = Net::HTTP::Post.new(uri)
119
+ req["Authorization"] = "Bearer #{current_token}"
120
+ req["Content-Type"] = "application/json"
121
+ req.body = JSON.generate(body)
122
+ handle_response(http.request(req))
123
+ end
124
+
125
+ def post_multipart(path, file:, filename:, invoice_options: nil)
126
+ boundary = "RubyBoundary#{SecureRandom.hex(16)}"
127
+ uri = URI("#{@base_url}#{path}")
128
+ http = Net::HTTP.new(uri.host, uri.port)
129
+ http.use_ssl = true
130
+ req = Net::HTTP::Post.new(uri)
131
+ req["Authorization"] = "Bearer #{current_token}"
132
+ req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
133
+ req.body = build_multipart_body(boundary, file: file, filename: filename,
134
+ invoice_options: invoice_options)
135
+ handle_response(http.request(req))
136
+ end
137
+
138
+ def build_multipart_body(boundary, file:, filename:, invoice_options:)
139
+ body = +""
140
+ body << "--#{boundary}\r\n"
141
+ body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\"\r\n"
142
+ body << "Content-Type: application/pdf\r\n\r\n"
143
+ body << file.b
144
+ body << "\r\n"
145
+ if invoice_options
146
+ body << "--#{boundary}\r\n"
147
+ body << "Content-Disposition: form-data; name=\"invoice_options\"\r\n"
148
+ body << "Content-Type: application/json\r\n\r\n"
149
+ body << JSON.generate(invoice_options)
150
+ body << "\r\n"
151
+ end
152
+ body << "--#{boundary}--\r\n"
153
+ body.force_encoding("BINARY")
154
+ end
155
+
156
+ def get(path)
157
+ uri = URI("#{@base_url}#{path}")
158
+ http = Net::HTTP.new(uri.host, uri.port)
159
+ http.use_ssl = true
160
+ req = Net::HTTP::Get.new(uri)
161
+ req["Authorization"] = "Bearer #{current_token}"
162
+ handle_response(http.request(req))
163
+ end
164
+
165
+ def handle_response(response)
166
+ body = JSON.parse(response.body) rescue {}
167
+ case response.code.to_i
168
+ when 200..299
169
+ body
170
+ when 401
171
+ raise AuthError, ::I18n.t("einvoicing.connect.pennylane.auth_failed")
172
+ else
173
+ raise SubmissionError, ::I18n.t("einvoicing.connect.pennylane.submission_failed",
174
+ code: response.code,
175
+ body: response.body.to_s[0, 200])
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end