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 +4 -4
- data/LICENSE +21 -0
- data/README.md +220 -0
- data/config/locales/en.yml +6 -0
- data/config/locales/fr.yml +6 -0
- data/lib/einvoicing/connect/fr/directory.rb +104 -0
- data/lib/einvoicing/connect/fr/pennylane/adapter.rb +54 -0
- data/lib/einvoicing/connect/fr/pennylane/client.rb +141 -0
- data/lib/einvoicing/connect/fr/pennylane/credentials.rb +75 -0
- data/lib/einvoicing/connect/fr/pennylane/e_invoice_submitter.rb +46 -0
- data/lib/einvoicing/connect/fr/pennylane/errors.rb +14 -0
- data/lib/einvoicing/connect/fr/ppf/client.rb +132 -0
- data/lib/einvoicing/connect/fr/ppf/errors.rb +16 -0
- data/lib/einvoicing/connect/fr/ppf/invoice_adapter.rb +65 -0
- data/lib/einvoicing/connect/fr/ppf/submitter.rb +36 -0
- data/lib/einvoicing/connect/fr/siret_lookup.rb +56 -0
- data/lib/einvoicing/connect/version.rb +1 -1
- data/lib/einvoicing-connect.rb +11 -5
- metadata +48 -14
- data/lib/einvoicing/fr/siret_lookup.rb +0 -54
- data/lib/einvoicing/ppf/client.rb +0 -117
- data/lib/einvoicing/ppf/errors.rb +0 -12
- data/lib/einvoicing/ppf/invoice_adapter.rb +0 -61
- data/lib/einvoicing/ppf/submitter.rb +0 -32
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a3d4d4580d842a03b65af1d21a25c888685ad7fa8f69e3ee2b8b537c51bab015
|
|
4
|
+
data.tar.gz: bc35697fb4f88aa875b9460a106bffc2dc2578a57b0d8a19b32065a71bfb1c30
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/config/locales/en.yml
CHANGED
|
@@ -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}"
|
data/config/locales/fr.yml
CHANGED
|
@@ -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
|