factpulse 3.0.37 → 4.0.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/CHANGELOG.md +10 -13
- data/Gemfile.lock +1 -1
- data/README.md +136 -138
- data/docs/ChorusProCredentials.md +8 -8
- data/docs/ChorusProDestination.md +1 -1
- data/docs/FactureElectroniqueModelsInvoiceTypeCode.md +15 -0
- data/docs/FactureElectroniqueRestApiSchemasCdarValidationErrorResponse.md +24 -0
- data/docs/FactureElectroniqueRestApiSchemasProcessingChorusProCredentials.md +26 -0
- data/docs/GetChorusProIdRequest.md +1 -1
- data/docs/GetInvoiceRequest.md +1 -1
- data/docs/GetStructureRequest.md +1 -1
- data/docs/InvoiceInput.md +1 -1
- data/docs/Recipient.md +1 -1
- data/docs/SearchStructureRequest.md +1 -1
- data/docs/SimplifiedInvoiceData.md +1 -1
- data/docs/SubmitCompleteInvoiceResponse.md +2 -2
- data/docs/SubmitInvoiceRequest.md +1 -1
- data/docs/Supplier.md +1 -1
- data/docs/ValidateCDARResponse.md +2 -2
- data/docs/ValidationErrorResponse.md +2 -8
- data/lib/factpulse/helpers/client.rb +152 -770
- data/lib/factpulse/helpers/exceptions.rb +38 -14
- data/lib/factpulse/helpers/helpers.rb +8 -7
- data/lib/factpulse/models/chorus_pro_credentials.rb +94 -26
- data/lib/factpulse/models/chorus_pro_destination.rb +1 -1
- data/lib/factpulse/models/{facture_electronique_rest_api_schemas_ereporting_invoice_type_code.rb → facture_electronique_models_invoice_type_code.rb} +20 -9
- data/lib/factpulse/models/{facture_electronique_rest_api_schemas_validation_validation_error_response.rb → facture_electronique_rest_api_schemas_cdar_validation_error_response.rb} +70 -23
- data/lib/factpulse/models/{facture_electronique_rest_api_schemas_chorus_pro_chorus_pro_credentials.rb → facture_electronique_rest_api_schemas_processing_chorus_pro_credentials.rb} +29 -97
- data/lib/factpulse/models/get_chorus_pro_id_request.rb +1 -1
- data/lib/factpulse/models/get_invoice_request.rb +1 -1
- data/lib/factpulse/models/get_structure_request.rb +1 -1
- data/lib/factpulse/models/invoice_input.rb +1 -1
- data/lib/factpulse/models/invoice_type_code.rb +6 -17
- data/lib/factpulse/models/recipient.rb +0 -2
- data/lib/factpulse/models/scheme_id.rb +3 -3
- data/lib/factpulse/models/search_structure_request.rb +1 -1
- data/lib/factpulse/models/simplified_invoice_data.rb +1 -1
- data/lib/factpulse/models/submit_complete_invoice_response.rb +16 -16
- data/lib/factpulse/models/submit_invoice_request.rb +1 -1
- data/lib/factpulse/models/supplier.rb +0 -2
- data/lib/factpulse/models/validate_cdar_response.rb +2 -2
- data/lib/factpulse/models/validation_error_response.rb +20 -67
- data/lib/factpulse/version.rb +1 -1
- data/lib/factpulse.rb +3 -3
- metadata +8 -8
- data/docs/FactureElectroniqueRestApiSchemasChorusProChorusProCredentials.md +0 -26
- data/docs/FactureElectroniqueRestApiSchemasEreportingInvoiceTypeCode.md +0 -15
- data/docs/FactureElectroniqueRestApiSchemasValidationValidationErrorResponse.md +0 -18
|
@@ -1,826 +1,208 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
-
|
|
2
|
+
#
|
|
3
|
+
# FactPulse SDK - Thin HTTP wrapper with auto-polling.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# client = FactPulseClient.new('email', 'password', 'client_uid')
|
|
7
|
+
#
|
|
8
|
+
# # POST /api/v1/processing/invoices/submit-complete-async
|
|
9
|
+
# result = client.post('processing/invoices/submit-complete-async',
|
|
10
|
+
# invoiceData: {...},
|
|
11
|
+
# destination: { type: 'afnor' }
|
|
12
|
+
# )
|
|
13
|
+
# pdf_bytes = result['content'] # auto-decoded, auto-polled
|
|
14
|
+
|
|
15
|
+
require 'net/http'
|
|
16
|
+
require 'json'
|
|
17
|
+
require 'base64'
|
|
18
|
+
require 'uri'
|
|
3
19
|
|
|
4
20
|
module FactPulse
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
# These credentials are passed in each request and are never stored server-side.
|
|
8
|
-
class ChorusProCredentials
|
|
9
|
-
attr_reader :piste_client_id, :piste_client_secret, :chorus_pro_login, :chorus_pro_password, :sandbox
|
|
10
|
-
def initialize(piste_client_id:, piste_client_secret:, chorus_pro_login:, chorus_pro_password:, sandbox: true)
|
|
11
|
-
@piste_client_id, @piste_client_secret = piste_client_id, piste_client_secret
|
|
12
|
-
@chorus_pro_login, @chorus_pro_password, @sandbox = chorus_pro_login, chorus_pro_password, sandbox
|
|
13
|
-
end
|
|
14
|
-
def to_h
|
|
15
|
-
{ 'piste_client_id' => @piste_client_id, 'piste_client_secret' => @piste_client_secret,
|
|
16
|
-
'chorus_pro_login' => @chorus_pro_login, 'chorus_pro_password' => @chorus_pro_password, 'sandbox' => @sandbox }
|
|
17
|
-
end
|
|
18
|
-
end
|
|
21
|
+
class Error < StandardError
|
|
22
|
+
attr_reader :status_code, :details
|
|
19
23
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def initialize(flow_service_url:, token_url:, client_id:, client_secret:, directory_service_url: nil)
|
|
25
|
-
@flow_service_url, @token_url = flow_service_url, token_url
|
|
26
|
-
@client_id, @client_secret, @directory_service_url = client_id, client_secret, directory_service_url
|
|
27
|
-
end
|
|
28
|
-
def to_h
|
|
29
|
-
result = { 'flow_service_url' => @flow_service_url, 'token_url' => @token_url,
|
|
30
|
-
'client_id' => @client_id, 'client_secret' => @client_secret }
|
|
31
|
-
result['directory_service_url'] = @directory_service_url if @directory_service_url
|
|
32
|
-
result
|
|
33
|
-
end
|
|
24
|
+
def initialize(message, status_code: nil, details: [])
|
|
25
|
+
super(message)
|
|
26
|
+
@status_code = status_code
|
|
27
|
+
@details = details
|
|
34
28
|
end
|
|
29
|
+
end
|
|
35
30
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
result['globalDiscountReason'] = discount_reason if discount_reason
|
|
51
|
-
result['prepayment'] = amount(prepayment) if prepayment
|
|
52
|
-
result
|
|
53
|
-
end
|
|
31
|
+
class Client
|
|
32
|
+
DEFAULT_API_URL = 'https://factpulse.fr'
|
|
33
|
+
|
|
34
|
+
def initialize(email, password, client_uid, api_url: DEFAULT_API_URL, timeout: 60, polling_timeout: 120)
|
|
35
|
+
@email = email
|
|
36
|
+
@password = password
|
|
37
|
+
@client_uid = client_uid
|
|
38
|
+
@api_url = api_url.chomp('/')
|
|
39
|
+
@timeout = timeout
|
|
40
|
+
@polling_timeout = polling_timeout
|
|
41
|
+
@token = nil
|
|
42
|
+
@token_expires_at = 0
|
|
43
|
+
@token_mutex = Mutex.new
|
|
44
|
+
end
|
|
54
45
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
'number' => number, 'description' => description,
|
|
60
|
-
'quantity' => amount(quantity), 'unitPriceExclTax' => amount(unit_price_excl_tax),
|
|
61
|
-
'lineTotalExclTax' => amount(line_total_excl_tax), 'vatRateManual' => amount(vat_rate),
|
|
62
|
-
'vatCategory' => vat_category, 'unit' => unit
|
|
63
|
-
}
|
|
64
|
-
result['reference'] = options[:reference] if options[:reference]
|
|
65
|
-
result['discountExclTax'] = amount(options[:discount_excl_tax]) if options[:discount_excl_tax]
|
|
66
|
-
result['discountReasonCode'] = options[:discount_reason_code] if options[:discount_reason_code]
|
|
67
|
-
result['discountReason'] = options[:discount_reason] if options[:discount_reason]
|
|
68
|
-
result['periodStartDate'] = options[:period_start_date] if options[:period_start_date]
|
|
69
|
-
result['periodEndDate'] = options[:period_end_date] if options[:period_end_date]
|
|
70
|
-
result
|
|
71
|
-
end
|
|
46
|
+
# POST request to /api/v1/{path}
|
|
47
|
+
def post(path, **data)
|
|
48
|
+
request('POST', path, data, retry_auth: true)
|
|
49
|
+
end
|
|
72
50
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
'vatAmount' => amount(vat_amount), 'category' => category
|
|
78
|
-
}
|
|
79
|
-
end
|
|
51
|
+
# GET request to /api/v1/{path}
|
|
52
|
+
def get(path, **params)
|
|
53
|
+
request('GET', path, params, retry_auth: true)
|
|
54
|
+
end
|
|
80
55
|
|
|
81
|
-
|
|
82
|
-
def self.postal_address(line1, postal_code, city, country: 'FR', line2: nil, line3: nil)
|
|
83
|
-
result = { 'line1' => line1, 'postalCode' => postal_code, 'city' => city, 'countryCode' => country }
|
|
84
|
-
result['line2'] = line2 if line2
|
|
85
|
-
result['line3'] = line3 if line3
|
|
86
|
-
result
|
|
87
|
-
end
|
|
56
|
+
private
|
|
88
57
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
58
|
+
def request(method, path, data, retry_auth:)
|
|
59
|
+
ensure_auth
|
|
60
|
+
url = "#{@api_url}/api/v1/#{path}"
|
|
61
|
+
uri = URI(url)
|
|
93
62
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
cle = (12 + 3 * (siren.to_i % 97)) % 97
|
|
98
|
-
format('FR%02d%s', cle, siren)
|
|
99
|
-
end
|
|
63
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
64
|
+
http.use_ssl = uri.scheme == 'https'
|
|
65
|
+
http.read_timeout = @timeout
|
|
100
66
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
'postalAddress' => postal_address(address_line1, postal_code, city, country: options[:country] || 'FR', line2: options[:address_line2])
|
|
109
|
-
}
|
|
110
|
-
result['siren'] = siren if siren
|
|
111
|
-
result['vatIntra'] = vat_intra if vat_intra
|
|
112
|
-
result['iban'] = options[:iban] if options[:iban]
|
|
113
|
-
result['supplierServiceId'] = options[:service_code] if options[:service_code]
|
|
114
|
-
result['supplierBankCoordinatesCode'] = options[:bank_coordinates_code] if options[:bank_coordinates_code]
|
|
115
|
-
result
|
|
67
|
+
if method == 'POST'
|
|
68
|
+
req = Net::HTTP::Post.new(uri)
|
|
69
|
+
req['Content-Type'] = 'application/json'
|
|
70
|
+
req.body = JSON.generate(data)
|
|
71
|
+
else
|
|
72
|
+
uri.query = URI.encode_www_form(data) unless data.empty?
|
|
73
|
+
req = Net::HTTP::Get.new(uri)
|
|
116
74
|
end
|
|
75
|
+
req['Authorization'] = "Bearer #{@token}"
|
|
117
76
|
|
|
118
|
-
|
|
119
|
-
def self.recipient(name, siret, address_line1, postal_code, city, **options)
|
|
120
|
-
siren = options[:siren] || (siret.length == 14 ? siret[0, 9] : nil)
|
|
121
|
-
result = {
|
|
122
|
-
'name' => name, 'siret' => siret,
|
|
123
|
-
'electronicAddress' => electronic_address(siret, scheme_id: '0225'),
|
|
124
|
-
'postalAddress' => postal_address(address_line1, postal_code, city, country: options[:country] || 'FR', line2: options[:address_line2])
|
|
125
|
-
}
|
|
126
|
-
result['siren'] = siren if siren
|
|
127
|
-
result['executingServiceCode'] = options[:executing_service_code] if options[:executing_service_code]
|
|
128
|
-
result
|
|
129
|
-
end
|
|
77
|
+
response = http.request(req)
|
|
130
78
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
# must be made to a third party different from the supplier, typically
|
|
135
|
-
# a factor (factoring company).
|
|
136
|
-
#
|
|
137
|
-
# For factored invoices, you also need to:
|
|
138
|
-
# - Use a factored document type (393, 396, 501, 502, 472, 473)
|
|
139
|
-
# - Add an ACC note with the subrogation mention
|
|
140
|
-
# - The beneficiary's IBAN will be used for payment
|
|
141
|
-
#
|
|
142
|
-
# @param name [String] Factor's business name (BT-59)
|
|
143
|
-
# @param options [Hash] Options: :siret (BT-60), :siren (BT-61), :iban, :bic
|
|
144
|
-
# @return [Hash] Dict ready to be used in a factored invoice
|
|
145
|
-
#
|
|
146
|
-
# @example
|
|
147
|
-
# factor = beneficiary('FACTOR SAS',
|
|
148
|
-
# siret: '30000000700033',
|
|
149
|
-
# iban: 'FR76 3000 4000 0500 0012 3456 789'
|
|
150
|
-
# )
|
|
151
|
-
def self.beneficiary(name, **options)
|
|
152
|
-
# Auto-compute SIREN from SIRET
|
|
153
|
-
siret = options[:siret]
|
|
154
|
-
siren = options[:siren] || (siret && siret.length == 14 ? siret[0, 9] : nil)
|
|
155
|
-
|
|
156
|
-
result = { 'name' => name }
|
|
157
|
-
result['siret'] = siret if siret
|
|
158
|
-
result['siren'] = siren if siren
|
|
159
|
-
result['iban'] = options[:iban] if options[:iban]
|
|
160
|
-
result['bic'] = options[:bic] if options[:bic]
|
|
161
|
-
result
|
|
79
|
+
if response.code == '401' && retry_auth
|
|
80
|
+
invalidate_token
|
|
81
|
+
return request(method, path, data, retry_auth: false)
|
|
162
82
|
end
|
|
163
|
-
end
|
|
164
83
|
|
|
165
|
-
|
|
166
|
-
attr_reader :chorus_credentials, :afnor_credentials
|
|
84
|
+
result = parse_response(response)
|
|
167
85
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
@chorus_credentials, @afnor_credentials = chorus_credentials, afnor_credentials
|
|
173
|
-
@access_token = @refresh_token = @token_expires_at = nil
|
|
86
|
+
# Auto-poll: support both taskId (camelCase) and task_id (snake_case)
|
|
87
|
+
if result.is_a?(Hash)
|
|
88
|
+
task_id = result['taskId'] || result['task_id']
|
|
89
|
+
result = poll(task_id) if task_id
|
|
174
90
|
end
|
|
175
91
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
def get_afnor_credentials; afnor_credentials_for_api; end
|
|
181
|
-
|
|
182
|
-
def ensure_authenticated(force_refresh: false)
|
|
183
|
-
now = (Time.now.to_f * 1000).to_i
|
|
184
|
-
if force_refresh || @access_token.nil? || (@token_expires_at && now >= @token_expires_at)
|
|
185
|
-
payload = { 'username' => @email, 'password' => @password }; payload['client_uid'] = @client_uid if @client_uid
|
|
186
|
-
response = http_post(URI("#{@api_url}/api/token/"), payload)
|
|
187
|
-
raise FactPulseAuthError, "Auth failed" unless response.is_a?(Net::HTTPSuccess)
|
|
188
|
-
tokens = JSON.parse(response.body); @access_token, @refresh_token = tokens['access'], tokens['refresh']
|
|
189
|
-
@token_expires_at = now + (28 * 60 * 1000)
|
|
190
|
-
end
|
|
92
|
+
# Auto-decode: support both content_b64 and contentB64
|
|
93
|
+
if result.is_a?(Hash)
|
|
94
|
+
b64_content = result.delete('content_b64') || result.delete('contentB64')
|
|
95
|
+
result['content'] = Base64.decode64(b64_content) if b64_content
|
|
191
96
|
end
|
|
192
97
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def poll_task(task_id, timeout: nil, interval: nil)
|
|
196
|
-
timeout_ms, interval_ms = timeout || @polling_timeout, interval || @polling_interval
|
|
197
|
-
start_time, current_interval = (Time.now.to_f * 1000).to_i, interval_ms.to_f
|
|
198
|
-
loop do
|
|
199
|
-
raise FactPulsePollingTimeout.new(task_id, timeout_ms) if (Time.now.to_f * 1000).to_i - start_time > timeout_ms
|
|
200
|
-
ensure_authenticated; response = http_get(URI("#{@api_url}/api/v1/processing/tasks/#{task_id}/status"))
|
|
201
|
-
reset_auth and next if response.code == '401'
|
|
202
|
-
data = JSON.parse(response.body)
|
|
203
|
-
return data['result'] || {} if data['status'] == 'SUCCESS'
|
|
204
|
-
if data['status'] == 'FAILURE'
|
|
205
|
-
# Format AFNOR: errorMessage, details
|
|
206
|
-
r = data['result'] || {}
|
|
207
|
-
raise FactPulseValidationError.new("Task #{task_id} failed: #{r['errorMessage'] || '?'}", (r['details'] || []).map { |e| ValidationErrorDetail.from_hash(e) })
|
|
208
|
-
end
|
|
209
|
-
sleep(current_interval / 1000.0); current_interval = [current_interval * 1.5, 10000].min
|
|
210
|
-
end
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
def self.format_amount(m); AmountHelpers.amount(m); end
|
|
214
|
-
|
|
215
|
-
# Generates a Factur-X invoice from a dict/hash and a source PDF.
|
|
216
|
-
def generate_facturx(invoice_data, pdf_source, profile: 'EN16931', output_format: 'pdf', sync: true, timeout: nil)
|
|
217
|
-
# Convert data to JSON string
|
|
218
|
-
json_data = case invoice_data
|
|
219
|
-
when String then invoice_data
|
|
220
|
-
when Hash then JSON.generate(invoice_data)
|
|
221
|
-
else
|
|
222
|
-
if invoice_data.respond_to?(:to_h)
|
|
223
|
-
JSON.generate(invoice_data.to_h)
|
|
224
|
-
elsif invoice_data.respond_to?(:to_hash)
|
|
225
|
-
JSON.generate(invoice_data.to_hash)
|
|
226
|
-
else
|
|
227
|
-
raise FactPulseValidationError.new("Unsupported data type: #{invoice_data.class}")
|
|
228
|
-
end
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
# Read source PDF
|
|
232
|
-
pdf_content = case pdf_source
|
|
233
|
-
when String then File.binread(pdf_source)
|
|
234
|
-
when File then pdf_source.read
|
|
235
|
-
else
|
|
236
|
-
if pdf_source.respond_to?(:read)
|
|
237
|
-
pdf_source.read
|
|
238
|
-
else
|
|
239
|
-
raise FactPulseValidationError.new("Unsupported PDF type: #{pdf_source.class}")
|
|
240
|
-
end
|
|
241
|
-
end
|
|
242
|
-
pdf_filename = pdf_source.is_a?(String) ? File.basename(pdf_source) : 'invoice.pdf'
|
|
243
|
-
|
|
244
|
-
ensure_authenticated
|
|
245
|
-
uri = URI("#{@api_url}/api/v1/processing/generate-invoice")
|
|
246
|
-
|
|
247
|
-
# Build multipart request
|
|
248
|
-
boundary = "----RubyFormBoundary#{SecureRandom.hex(16)}"
|
|
249
|
-
body = build_multipart_body(boundary, [
|
|
250
|
-
{ name: 'invoice_data', content: json_data, content_type: 'application/json' },
|
|
251
|
-
{ name: 'profile', content: profile },
|
|
252
|
-
{ name: 'output_format', content: output_format },
|
|
253
|
-
{ name: 'source_pdf', content: pdf_content, filename: pdf_filename, content_type: 'application/pdf' }
|
|
254
|
-
])
|
|
255
|
-
|
|
256
|
-
response = http_multipart_post(uri, body, boundary)
|
|
257
|
-
|
|
258
|
-
if response.code == '401'
|
|
259
|
-
reset_auth; ensure_authenticated; response = http_multipart_post(uri, body, boundary)
|
|
260
|
-
end
|
|
98
|
+
result
|
|
99
|
+
end
|
|
261
100
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
errors = []
|
|
266
|
-
|
|
267
|
-
begin
|
|
268
|
-
error_data = JSON.parse(response.body)
|
|
269
|
-
# Format FastAPI/Pydantic: {"detail": [{"loc": [...], "msg": "...", "type": "..."}]}
|
|
270
|
-
if error_data['detail'].is_a?(Array)
|
|
271
|
-
error_msg = 'Validation error'
|
|
272
|
-
error_data['detail'].each do |err|
|
|
273
|
-
next unless err.is_a?(Hash)
|
|
274
|
-
loc = (err['loc'] || []).map(&:to_s).join(' -> ')
|
|
275
|
-
errors << ValidationErrorDetail.new(
|
|
276
|
-
level: 'ERROR',
|
|
277
|
-
item: loc,
|
|
278
|
-
reason: err['msg'] || err.to_s,
|
|
279
|
-
source: 'validation',
|
|
280
|
-
code: err['type']
|
|
281
|
-
)
|
|
282
|
-
end
|
|
283
|
-
elsif error_data['detail'].is_a?(String)
|
|
284
|
-
error_msg = error_data['detail']
|
|
285
|
-
elsif error_data['errorMessage']
|
|
286
|
-
error_msg = error_data['errorMessage']
|
|
287
|
-
end
|
|
288
|
-
rescue JSON::ParserError
|
|
289
|
-
error_msg = "API Error (#{response.code}): #{response.body}"
|
|
290
|
-
end
|
|
101
|
+
def parse_response(response)
|
|
102
|
+
body = response.body
|
|
103
|
+
data = body && !body.empty? ? JSON.parse(body) : {}
|
|
291
104
|
|
|
292
|
-
|
|
293
|
-
raise FactPulseValidationError.new(error_msg, errors)
|
|
294
|
-
end
|
|
105
|
+
return data if response.is_a?(Net::HTTPSuccess)
|
|
295
106
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if sync && data['taskId']
|
|
299
|
-
result = poll_task(data['taskId'], timeout: timeout)
|
|
300
|
-
|
|
301
|
-
# Check for business error (task succeeded but business result is ERROR)
|
|
302
|
-
if result['status'] == 'ERROR'
|
|
303
|
-
error_msg = result['errorMessage'] || 'Business error'
|
|
304
|
-
errors = (result['details'] || []).map do |d|
|
|
305
|
-
ValidationErrorDetail.new(
|
|
306
|
-
d['level'] || 'ERROR',
|
|
307
|
-
d['item'] || '',
|
|
308
|
-
d['reason'] || '',
|
|
309
|
-
d['source'],
|
|
310
|
-
d['code']
|
|
311
|
-
)
|
|
312
|
-
end
|
|
313
|
-
raise FactPulseValidationError.new(error_msg, errors)
|
|
314
|
-
end
|
|
107
|
+
msg = "HTTP #{response.code}"
|
|
108
|
+
details = []
|
|
315
109
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
110
|
+
if data.is_a?(Hash)
|
|
111
|
+
if data['detail'].is_a?(Array)
|
|
112
|
+
details = data['detail']
|
|
113
|
+
msgs = data['detail'].map do |e|
|
|
114
|
+
loc = e['loc'] || []
|
|
115
|
+
"#{loc.last || '?'}: #{e['msg'] || '?'}"
|
|
320
116
|
end
|
|
321
|
-
|
|
117
|
+
msg = "Validation error: #{msgs.join('; ')}"
|
|
118
|
+
elsif data['detail'].is_a?(String)
|
|
119
|
+
msg = data['detail']
|
|
120
|
+
elsif data['errorMessage'].is_a?(String)
|
|
121
|
+
msg = data['errorMessage']
|
|
322
122
|
end
|
|
323
|
-
|
|
324
|
-
data
|
|
325
|
-
end
|
|
326
|
-
|
|
327
|
-
# =========================================================================
|
|
328
|
-
# AFNOR PDP - Authentication and internal helpers
|
|
329
|
-
# =========================================================================
|
|
330
|
-
|
|
331
|
-
private def get_afnor_credentials_internal
|
|
332
|
-
return @afnor_credentials if @afnor_credentials
|
|
333
|
-
|
|
334
|
-
ensure_authenticated
|
|
335
|
-
response = http_get(URI("#{@api_url}/api/v1/afnor/credentials"))
|
|
336
|
-
raise FactPulseAuthError, "Failed to get AFNOR credentials" unless response.is_a?(Net::HTTPSuccess)
|
|
337
|
-
creds = JSON.parse(response.body)
|
|
338
|
-
AFNORCredentials.new(
|
|
339
|
-
flow_service_url: creds['flow_service_url'],
|
|
340
|
-
token_url: creds['token_url'],
|
|
341
|
-
client_id: creds['client_id'],
|
|
342
|
-
client_secret: creds['client_secret'],
|
|
343
|
-
directory_service_url: creds['directory_service_url']
|
|
344
|
-
)
|
|
345
|
-
end
|
|
346
|
-
|
|
347
|
-
private def get_afnor_token_and_url
|
|
348
|
-
credentials = get_afnor_credentials_internal
|
|
349
|
-
uri = URI("#{@api_url}/api/v1/afnor/oauth/token")
|
|
350
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
351
|
-
http.use_ssl = uri.scheme == 'https'
|
|
352
|
-
request = Net::HTTP::Post.new(uri)
|
|
353
|
-
request['X-PDP-Token-URL'] = credentials.token_url
|
|
354
|
-
request.set_form_data(
|
|
355
|
-
'grant_type' => 'client_credentials',
|
|
356
|
-
'client_id' => credentials.client_id,
|
|
357
|
-
'client_secret' => credentials.client_secret
|
|
358
|
-
)
|
|
359
|
-
response = http.request(request)
|
|
360
|
-
raise FactPulseAuthError, "AFNOR OAuth2 failed" unless response.is_a?(Net::HTTPSuccess)
|
|
361
|
-
token_data = JSON.parse(response.body)
|
|
362
|
-
raise FactPulseAuthError, "Invalid AFNOR OAuth2 response" unless token_data['access_token']
|
|
363
|
-
{ token: token_data['access_token'], pdp_base_url: credentials.flow_service_url }
|
|
364
123
|
end
|
|
365
124
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
uri = URI("#{@api_url}/api/v1/afnor#{endpoint}")
|
|
369
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
370
|
-
http.use_ssl = uri.scheme == 'https'
|
|
371
|
-
http.read_timeout = 60
|
|
372
|
-
|
|
373
|
-
request = case method.upcase
|
|
374
|
-
when 'GET' then Net::HTTP::Get.new(uri)
|
|
375
|
-
when 'POST' then Net::HTTP::Post.new(uri)
|
|
376
|
-
else raise "Unsupported method: #{method}"
|
|
377
|
-
end
|
|
378
|
-
|
|
379
|
-
request['Authorization'] = "Bearer #{token_info[:token]}"
|
|
380
|
-
request['X-PDP-Base-URL'] = token_info[:pdp_base_url]
|
|
381
|
-
|
|
382
|
-
if multipart
|
|
383
|
-
boundary = "----RubyFormBoundary#{SecureRandom.hex(16)}"
|
|
384
|
-
request['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
|
|
385
|
-
request.body = build_multipart_body(boundary, multipart)
|
|
386
|
-
elsif json_data
|
|
387
|
-
request['Content-Type'] = 'application/json'
|
|
388
|
-
request.body = JSON.generate(json_data)
|
|
389
|
-
end
|
|
125
|
+
raise Error.new(msg, status_code: response.code.to_i, details: details)
|
|
126
|
+
end
|
|
390
127
|
|
|
391
|
-
|
|
392
|
-
|
|
128
|
+
def poll(task_id)
|
|
129
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
130
|
+
interval = 1.0
|
|
393
131
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
{ '_raw' => response.body }
|
|
132
|
+
loop do
|
|
133
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
134
|
+
if elapsed >= @polling_timeout
|
|
135
|
+
raise Error.new("Polling timeout after #{@polling_timeout}s for task #{task_id}")
|
|
399
136
|
end
|
|
400
|
-
end
|
|
401
|
-
|
|
402
|
-
# ==================== AFNOR Flow Service ====================
|
|
403
|
-
|
|
404
|
-
# Submits an invoice to a PDP via the AFNOR API.
|
|
405
|
-
def submit_invoice_afnor(pdf_path, flow_name, **options)
|
|
406
|
-
pdf_content = File.binread(pdf_path)
|
|
407
|
-
sha256 = Digest::SHA256.hexdigest(pdf_content)
|
|
408
|
-
|
|
409
|
-
flow_info = {
|
|
410
|
-
'name' => flow_name,
|
|
411
|
-
'flowSyntax' => options[:flow_syntax] || 'CII',
|
|
412
|
-
'flowProfile' => options[:flow_profile] || 'EN16931',
|
|
413
|
-
'sha256' => sha256
|
|
414
|
-
}
|
|
415
|
-
flow_info['trackingId'] = options[:tracking_id] if options[:tracking_id]
|
|
416
|
-
|
|
417
|
-
make_afnor_request('POST', '/flow/v1/flows', multipart: [
|
|
418
|
-
{ name: 'file', content: pdf_content, filename: File.basename(pdf_path), content_type: 'application/pdf' },
|
|
419
|
-
{ name: 'flowInfo', content: JSON.generate(flow_info), content_type: 'application/json' }
|
|
420
|
-
])
|
|
421
|
-
end
|
|
422
|
-
|
|
423
|
-
# Searches for AFNOR invoicing flows.
|
|
424
|
-
def search_flows_afnor(**criteria)
|
|
425
|
-
search_body = {
|
|
426
|
-
'offset' => criteria[:offset] || 0,
|
|
427
|
-
'limit' => criteria[:limit] || 25,
|
|
428
|
-
'where' => {}
|
|
429
|
-
}
|
|
430
|
-
search_body['where']['trackingId'] = criteria[:tracking_id] if criteria[:tracking_id]
|
|
431
|
-
search_body['where']['status'] = criteria[:status] if criteria[:status]
|
|
432
|
-
|
|
433
|
-
make_afnor_request('POST', '/flow/v1/flows/search', json_data: search_body)
|
|
434
|
-
end
|
|
435
|
-
|
|
436
|
-
# Downloads the PDF file of an AFNOR flow.
|
|
437
|
-
def download_flow_afnor(flow_id)
|
|
438
|
-
result = make_afnor_request('GET', "/flow/v1/flows/#{flow_id}")
|
|
439
|
-
result['_raw'] || ''
|
|
440
|
-
end
|
|
441
|
-
|
|
442
|
-
# Retrieves JSON metadata of an incoming flow (supplier invoice).
|
|
443
|
-
# Downloads an incoming flow from the AFNOR PDP and extracts invoice
|
|
444
|
-
# metadata into a unified JSON format. Supports Factur-X, CII and UBL.
|
|
445
|
-
#
|
|
446
|
-
# Note: This endpoint uses FactPulse JWT authentication (not AFNOR OAuth).
|
|
447
|
-
# The FactPulse server handles calling the PDP with stored credentials.
|
|
448
|
-
#
|
|
449
|
-
# @param flow_id [String] Flow identifier (UUID)
|
|
450
|
-
# @param include_document [Boolean] If true, includes the document in base64
|
|
451
|
-
# @return [Hash] Invoice metadata (supplier, amounts, dates, etc.)
|
|
452
|
-
#
|
|
453
|
-
# @example
|
|
454
|
-
# invoice = client.get_incoming_invoice_afnor("550e8400-...")
|
|
455
|
-
# puts "Supplier: #{invoice['supplier']['name']}"
|
|
456
|
-
# puts "Total incl. tax: #{invoice['total_incl_tax']} #{invoice['currency']}"
|
|
457
|
-
def get_incoming_invoice_afnor(flow_id, include_document: false)
|
|
458
|
-
ensure_authenticated
|
|
459
|
-
uri = URI("#{@api_url}/api/v1/afnor/incoming-flows/#{flow_id}")
|
|
460
|
-
uri.query = "include_document=true" if include_document
|
|
461
137
|
|
|
138
|
+
ensure_auth
|
|
139
|
+
uri = URI("#{@api_url}/api/v1/processing/tasks/#{task_id}/status")
|
|
462
140
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
463
141
|
http.use_ssl = uri.scheme == 'https'
|
|
464
|
-
http.read_timeout =
|
|
465
|
-
|
|
466
|
-
request = Net::HTTP::Get.new(uri)
|
|
467
|
-
request['Authorization'] = "Bearer #{@access_token}"
|
|
142
|
+
http.read_timeout = @timeout
|
|
468
143
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
end
|
|
144
|
+
req = Net::HTTP::Get.new(uri)
|
|
145
|
+
req['Authorization'] = "Bearer #{@token}"
|
|
146
|
+
response = http.request(req)
|
|
473
147
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
# ==================== AFNOR Directory ====================
|
|
480
|
-
|
|
481
|
-
# Gets a company (legal unit) by SIRET in the AFNOR directory.
|
|
482
|
-
# @param siret [String] 14-digit SIRET number
|
|
483
|
-
# @return [Hash] Company information
|
|
484
|
-
def get_siret_afnor(siret)
|
|
485
|
-
make_afnor_request('GET', "/directory/v1/siret/code-insee:#{siret}")
|
|
486
|
-
end
|
|
487
|
-
|
|
488
|
-
# Gets a company (legal unit) by SIREN in the AFNOR directory.
|
|
489
|
-
# @param siren [String] 9-digit SIREN number
|
|
490
|
-
# @return [Hash] Company information
|
|
491
|
-
def get_siren_afnor(siren)
|
|
492
|
-
make_afnor_request('GET', "/directory/v1/siren/code-insee:#{siren}")
|
|
493
|
-
end
|
|
494
|
-
|
|
495
|
-
# Searches for SIRENs (legal units) in the AFNOR directory.
|
|
496
|
-
# @param criteria [Hash] Search criteria (filters, sorting, fields, limit)
|
|
497
|
-
# @return [Hash] Search results
|
|
498
|
-
def search_siren_afnor(**criteria)
|
|
499
|
-
search_body = {
|
|
500
|
-
'limit' => criteria[:limit] || 25,
|
|
501
|
-
'filters' => criteria[:filters] || {}
|
|
502
|
-
}
|
|
503
|
-
make_afnor_request('POST', '/directory/v1/siren/search', json_data: search_body)
|
|
504
|
-
end
|
|
505
|
-
|
|
506
|
-
# Searches for routing codes in the AFNOR directory.
|
|
507
|
-
# @param criteria [Hash] Search criteria (filters, sorting, fields, limit)
|
|
508
|
-
# @return [Hash] Search results with routing codes
|
|
509
|
-
def search_routing_codes_afnor(**criteria)
|
|
510
|
-
search_body = {
|
|
511
|
-
'limit' => criteria[:limit] || 25,
|
|
512
|
-
'filters' => criteria[:filters] || {}
|
|
513
|
-
}
|
|
514
|
-
make_afnor_request('POST', '/directory/v1/routing-code/search', json_data: search_body)
|
|
515
|
-
end
|
|
516
|
-
|
|
517
|
-
# Gets a routing code by SIRET and routing identifier.
|
|
518
|
-
# @param siret [String] 14-digit SIRET number
|
|
519
|
-
# @param routing_identifier [String] Routing code identifier
|
|
520
|
-
# @return [Hash] Routing code information
|
|
521
|
-
def get_routing_code_afnor(siret, routing_identifier)
|
|
522
|
-
make_afnor_request('GET', "/directory/v1/routing-code/siret:#{siret}/code:#{routing_identifier}")
|
|
523
|
-
end
|
|
524
|
-
|
|
525
|
-
# =========================================================================
|
|
526
|
-
# Chorus Pro
|
|
527
|
-
# =========================================================================
|
|
528
|
-
|
|
529
|
-
private def make_chorus_request(method, endpoint, json_data = nil)
|
|
530
|
-
ensure_authenticated
|
|
531
|
-
uri = URI("#{@api_url}/api/v1/chorus-pro#{endpoint}")
|
|
532
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
533
|
-
http.use_ssl = uri.scheme == 'https'
|
|
534
|
-
http.read_timeout = 60
|
|
535
|
-
|
|
536
|
-
body = json_data || {}
|
|
537
|
-
body['credentials'] = @chorus_credentials.to_h if @chorus_credentials
|
|
538
|
-
|
|
539
|
-
request = case method.upcase
|
|
540
|
-
when 'GET' then Net::HTTP::Get.new(uri)
|
|
541
|
-
when 'POST' then Net::HTTP::Post.new(uri)
|
|
542
|
-
else raise "Unsupported method: #{method}"
|
|
543
|
-
end
|
|
544
|
-
|
|
545
|
-
request['Authorization'] = "Bearer #{@access_token}"
|
|
546
|
-
request['Content-Type'] = 'application/json'
|
|
547
|
-
request.body = JSON.generate(body) if body.any?
|
|
548
|
-
|
|
549
|
-
response = http.request(request)
|
|
550
|
-
raise FactPulseValidationError.new("Chorus Pro error: #{response.code}") unless response.is_a?(Net::HTTPSuccess)
|
|
551
|
-
JSON.parse(response.body) rescue {}
|
|
552
|
-
end
|
|
553
|
-
|
|
554
|
-
# Searches for structures on Chorus Pro.
|
|
555
|
-
def rechercher_structure_chorus(identifiant_structure: nil, raison_sociale: nil, type_identifiant: 'SIRET', restreindre_privees: true)
|
|
556
|
-
body = { 'restreindre_structures_privees' => restreindre_privees }
|
|
557
|
-
body['identifiant_structure'] = identifiant_structure if identifiant_structure
|
|
558
|
-
body['raison_sociale_structure'] = raison_sociale if raison_sociale
|
|
559
|
-
body['type_identifiant_structure'] = type_identifiant if type_identifiant
|
|
560
|
-
|
|
561
|
-
make_chorus_request('POST', '/structures/rechercher', body)
|
|
562
|
-
end
|
|
563
|
-
|
|
564
|
-
# Gets the details of a Chorus Pro structure.
|
|
565
|
-
def consulter_structure_chorus(id_structure_cpp)
|
|
566
|
-
make_chorus_request('POST', '/structures/consulter', { 'id_structure_cpp' => id_structure_cpp })
|
|
567
|
-
end
|
|
568
|
-
|
|
569
|
-
# Gets the Chorus Pro ID of a structure from its SIRET.
|
|
570
|
-
def obtenir_id_chorus_depuis_siret(siret, type_identifiant: 'SIRET')
|
|
571
|
-
make_chorus_request('POST', '/structures/obtenir-id-depuis-siret', { 'siret' => siret, 'type_identifiant' => type_identifiant })
|
|
572
|
-
end
|
|
573
|
-
|
|
574
|
-
# Lists the services of a Chorus Pro structure.
|
|
575
|
-
def lister_services_structure_chorus(id_structure_cpp)
|
|
576
|
-
make_chorus_request('GET', "/structures/#{id_structure_cpp}/services")
|
|
577
|
-
end
|
|
578
|
-
|
|
579
|
-
# Submits an invoice to Chorus Pro.
|
|
580
|
-
# @param invoice_data [Hash] Invoice data with keys: numero_facture, date_facture, date_echeance_paiement,
|
|
581
|
-
# id_structure_cpp, montant_ht_total, montant_tva, montant_ttc_total, etc.
|
|
582
|
-
# @return [Hash] Response with identifiant_facture_cpp, numero_flux_depot, code_retour, libelle
|
|
583
|
-
def submit_invoice_chorus(invoice_data)
|
|
584
|
-
make_chorus_request('POST', '/factures/soumettre', invoice_data)
|
|
585
|
-
end
|
|
586
|
-
alias soumettre_facture_chorus submit_invoice_chorus
|
|
587
|
-
|
|
588
|
-
# Gets the status of a Chorus Pro invoice.
|
|
589
|
-
# @param invoice_cpp_id [Integer] Chorus Pro invoice ID
|
|
590
|
-
# @return [Hash] Invoice status with statut_courant, numero_facture, date_facture, etc.
|
|
591
|
-
def get_invoice_status_chorus(invoice_cpp_id)
|
|
592
|
-
make_chorus_request('POST', '/factures/consulter', { 'identifiant_facture_cpp' => invoice_cpp_id })
|
|
593
|
-
end
|
|
594
|
-
alias consulter_facture_chorus get_invoice_status_chorus
|
|
595
|
-
|
|
596
|
-
# =========================================================================
|
|
597
|
-
# Validation
|
|
598
|
-
# =========================================================================
|
|
599
|
-
|
|
600
|
-
# Validates a Factur-X PDF.
|
|
601
|
-
# @param pdf_path [String] Path to the PDF file
|
|
602
|
-
# @param profile [String, nil] Factur-X profile (MINIMUM, BASIC, EN16931, EXTENDED). If nil, auto-detected.
|
|
603
|
-
# @param use_verapdf [Boolean] Enable strict PDF/A validation with VeraPDF (default: false)
|
|
604
|
-
def validate_facturx_pdf(pdf_path, profile: nil, use_verapdf: false)
|
|
605
|
-
ensure_authenticated
|
|
606
|
-
uri = URI("#{@api_url}/api/v1/processing/validate-facturx-pdf")
|
|
607
|
-
pdf_content = File.binread(pdf_path)
|
|
608
|
-
|
|
609
|
-
parts = [
|
|
610
|
-
{ name: 'pdf_file', content: pdf_content, filename: File.basename(pdf_path), content_type: 'application/pdf' },
|
|
611
|
-
{ name: 'use_verapdf', content: use_verapdf.to_s }
|
|
612
|
-
]
|
|
613
|
-
parts << { name: 'profile', content: profile } if profile
|
|
614
|
-
|
|
615
|
-
boundary = "----RubyFormBoundary#{SecureRandom.hex(16)}"
|
|
616
|
-
body = build_multipart_body(boundary, parts)
|
|
617
|
-
|
|
618
|
-
response = http_multipart_post(uri, body, boundary)
|
|
619
|
-
raise FactPulseValidationError.new("Validation error: #{response.code}") unless response.is_a?(Net::HTTPSuccess)
|
|
620
|
-
JSON.parse(response.body) rescue {}
|
|
621
|
-
end
|
|
622
|
-
|
|
623
|
-
# Validates a Factur-X XML.
|
|
624
|
-
def validate_facturx_xml(xml_content, profile: 'EN16931')
|
|
625
|
-
ensure_authenticated
|
|
626
|
-
uri = URI("#{@api_url}/api/v1/processing/validate-xml")
|
|
627
|
-
|
|
628
|
-
boundary = "----RubyFormBoundary#{SecureRandom.hex(16)}"
|
|
629
|
-
body = build_multipart_body(boundary, [
|
|
630
|
-
{ name: 'xml_file', content: xml_content, filename: 'invoice.xml', content_type: 'application/xml' },
|
|
631
|
-
{ name: 'profile', content: profile }
|
|
632
|
-
])
|
|
633
|
-
|
|
634
|
-
response = http_multipart_post(uri, body, boundary)
|
|
635
|
-
raise FactPulseValidationError.new("Validation error: #{response.code}") unless response.is_a?(Net::HTTPSuccess)
|
|
636
|
-
JSON.parse(response.body) rescue {}
|
|
637
|
-
end
|
|
638
|
-
|
|
639
|
-
# Validates the signature of a signed PDF.
|
|
640
|
-
def validate_pdf_signature(pdf_path)
|
|
641
|
-
ensure_authenticated
|
|
642
|
-
uri = URI("#{@api_url}/api/v1/processing/validate-pdf-signature")
|
|
643
|
-
pdf_content = File.binread(pdf_path)
|
|
148
|
+
if response.code == '401'
|
|
149
|
+
invalidate_token
|
|
150
|
+
next
|
|
151
|
+
end
|
|
644
152
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
{ name: 'pdf_file', content: pdf_content, filename: File.basename(pdf_path), content_type: 'application/pdf' }
|
|
648
|
-
])
|
|
153
|
+
data = parse_response(response)
|
|
154
|
+
status = data['status']
|
|
649
155
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
156
|
+
if status == 'SUCCESS'
|
|
157
|
+
result = data['result'] || {}
|
|
158
|
+
# Support both content_b64 and contentB64
|
|
159
|
+
b64_content = result.delete('content_b64') || result.delete('contentB64')
|
|
160
|
+
result['content'] = Base64.decode64(b64_content) if b64_content
|
|
161
|
+
return result
|
|
162
|
+
end
|
|
654
163
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
# Signs a PDF with the server-configured certificate.
|
|
660
|
-
def sign_pdf(pdf_path, **options)
|
|
661
|
-
ensure_authenticated
|
|
662
|
-
uri = URI("#{@api_url}/api/v1/processing/sign-pdf")
|
|
663
|
-
pdf_content = File.binread(pdf_path)
|
|
664
|
-
|
|
665
|
-
parts = [
|
|
666
|
-
{ name: 'pdf_file', content: pdf_content, filename: File.basename(pdf_path), content_type: 'application/pdf' },
|
|
667
|
-
{ name: 'use_pades_lt', content: (options[:use_pades_lt] ? 'true' : 'false') },
|
|
668
|
-
{ name: 'use_timestamp', content: (options.key?(:use_timestamp) ? (options[:use_timestamp] ? 'true' : 'false') : 'true') }
|
|
669
|
-
]
|
|
670
|
-
parts << { name: 'reason', content: options[:reason] } if options[:reason]
|
|
671
|
-
parts << { name: 'location', content: options[:location] } if options[:location]
|
|
672
|
-
parts << { name: 'contact', content: options[:contact] } if options[:contact]
|
|
673
|
-
|
|
674
|
-
boundary = "----RubyFormBoundary#{SecureRandom.hex(16)}"
|
|
675
|
-
body = build_multipart_body(boundary, parts)
|
|
676
|
-
|
|
677
|
-
response = http_multipart_post(uri, body, boundary)
|
|
678
|
-
raise FactPulseValidationError.new("Signature error: #{response.code}") unless response.is_a?(Net::HTTPSuccess)
|
|
679
|
-
|
|
680
|
-
result = JSON.parse(response.body) rescue {}
|
|
681
|
-
raise FactPulseValidationError.new("Invalid signature response") unless result['pdf_signe_base64']
|
|
682
|
-
Base64.decode64(result['pdf_signe_base64'])
|
|
683
|
-
end
|
|
164
|
+
if status == 'FAILURE'
|
|
165
|
+
res = data['result'] || {}
|
|
166
|
+
raise Error.new(res['errorMessage'] || 'Task failed', details: res['details'] || [])
|
|
167
|
+
end
|
|
684
168
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
ensure_authenticated
|
|
688
|
-
uri = URI("#{@api_url}/api/v1/processing/generate-test-certificate")
|
|
689
|
-
body = {
|
|
690
|
-
'cn' => options[:cn] || 'Test Organisation',
|
|
691
|
-
'organisation' => options[:organisation] || 'Test Organisation',
|
|
692
|
-
'email' => options[:email] || 'test@example.com',
|
|
693
|
-
'validity_days' => options[:validity_days] || 365,
|
|
694
|
-
'key_size' => options[:key_size] || 2048
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
response = http_post_json(uri, body)
|
|
698
|
-
raise FactPulseValidationError.new("Error: #{response.code}") unless response.is_a?(Net::HTTPSuccess)
|
|
699
|
-
JSON.parse(response.body) rescue {}
|
|
169
|
+
sleep([interval, @polling_timeout - elapsed].min)
|
|
170
|
+
interval = [interval * 1.5, 10].min
|
|
700
171
|
end
|
|
172
|
+
end
|
|
701
173
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
# Generates a complete Factur-X PDF with optional validation, signature and submission.
|
|
707
|
-
def generate_complete_facturx(invoice, pdf_source_path, **options)
|
|
708
|
-
profile = options[:profile] || 'EN16931'
|
|
709
|
-
validate = options.fetch(:validate, true)
|
|
710
|
-
sign = options.fetch(:sign, false)
|
|
711
|
-
submit_afnor = options.fetch(:submit_afnor, false)
|
|
712
|
-
timeout = options[:timeout] || 120000
|
|
713
|
-
|
|
714
|
-
result = {}
|
|
715
|
-
|
|
716
|
-
# 1. Generation
|
|
717
|
-
pdf_bytes = generate_facturx(invoice, pdf_source_path, profile: profile, output_format: 'pdf', sync: true, timeout: timeout)
|
|
718
|
-
result[:pdf_bytes] = pdf_bytes
|
|
719
|
-
|
|
720
|
-
# Create a temporary file for subsequent operations
|
|
721
|
-
temp_file = Tempfile.new(['facturx_', '.pdf'])
|
|
722
|
-
begin
|
|
723
|
-
temp_file.binmode
|
|
724
|
-
temp_file.write(pdf_bytes)
|
|
725
|
-
temp_file.flush
|
|
726
|
-
|
|
727
|
-
# 2. Validation
|
|
728
|
-
if validate
|
|
729
|
-
validation = validate_facturx_pdf(temp_file.path, profile: profile)
|
|
730
|
-
result[:validation] = validation
|
|
731
|
-
unless validation['isCompliant']
|
|
732
|
-
if options[:output_path]
|
|
733
|
-
File.binwrite(options[:output_path], pdf_bytes)
|
|
734
|
-
result[:pdf_path] = options[:output_path]
|
|
735
|
-
end
|
|
736
|
-
return result
|
|
737
|
-
end
|
|
738
|
-
end
|
|
739
|
-
|
|
740
|
-
# 3. Signature
|
|
741
|
-
if sign
|
|
742
|
-
pdf_bytes = sign_pdf(temp_file.path, **options)
|
|
743
|
-
result[:pdf_bytes] = pdf_bytes
|
|
744
|
-
result[:signature] = { 'signed' => true }
|
|
745
|
-
temp_file.rewind
|
|
746
|
-
temp_file.write(pdf_bytes)
|
|
747
|
-
temp_file.flush
|
|
748
|
-
end
|
|
749
|
-
|
|
750
|
-
# 4. AFNOR submission
|
|
751
|
-
if submit_afnor
|
|
752
|
-
invoice_number = invoice['invoiceNumber'] || invoice['invoice_number'] || 'INVOICE'
|
|
753
|
-
flow_name = options[:afnor_flow_name] || "Invoice #{invoice_number}"
|
|
754
|
-
tracking_id = options[:afnor_tracking_id] || invoice_number
|
|
755
|
-
afnor_result = submit_invoice_afnor(temp_file.path, flow_name, tracking_id: tracking_id)
|
|
756
|
-
result[:afnor] = afnor_result
|
|
757
|
-
end
|
|
758
|
-
|
|
759
|
-
# Final save
|
|
760
|
-
if options[:output_path]
|
|
761
|
-
File.binwrite(options[:output_path], pdf_bytes)
|
|
762
|
-
result[:pdf_path] = options[:output_path]
|
|
763
|
-
end
|
|
764
|
-
ensure
|
|
765
|
-
temp_file.close
|
|
766
|
-
temp_file.unlink
|
|
174
|
+
def ensure_auth
|
|
175
|
+
@token_mutex.synchronize do
|
|
176
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @token_expires_at
|
|
177
|
+
refresh_token
|
|
767
178
|
end
|
|
768
|
-
|
|
769
|
-
result
|
|
770
179
|
end
|
|
180
|
+
end
|
|
771
181
|
|
|
772
|
-
|
|
182
|
+
def refresh_token
|
|
183
|
+
uri = URI("#{@api_url}/api/token/")
|
|
184
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
185
|
+
http.use_ssl = uri.scheme == 'https'
|
|
186
|
+
http.read_timeout = @timeout
|
|
773
187
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
end
|
|
188
|
+
req = Net::HTTP::Post.new(uri)
|
|
189
|
+
req['Content-Type'] = 'application/json'
|
|
190
|
+
req.body = JSON.generate(username: @email, password: @password, client_uid: @client_uid)
|
|
778
191
|
|
|
779
|
-
|
|
780
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
781
|
-
http.use_ssl = uri.scheme == 'https'
|
|
782
|
-
http.read_timeout = 30
|
|
783
|
-
request = Net::HTTP::Post.new(uri)
|
|
784
|
-
request['Authorization'] = "Bearer #{@access_token}"
|
|
785
|
-
request['Content-Type'] = 'application/json'
|
|
786
|
-
request.body = JSON.generate(payload)
|
|
787
|
-
http.request(request)
|
|
788
|
-
end
|
|
192
|
+
response = http.request(req)
|
|
789
193
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
.request(Net::HTTP::Get.new(uri).tap { |r| r['Authorization'] = "Bearer #{@access_token}" })
|
|
194
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
195
|
+
raise Error.new("Authentication failed: HTTP #{response.code}", status_code: response.code.to_i)
|
|
793
196
|
end
|
|
794
197
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
request = Net::HTTP::Post.new(uri)
|
|
801
|
-
request['Authorization'] = "Bearer #{@access_token}"
|
|
802
|
-
request['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
|
|
803
|
-
request.body = body
|
|
804
|
-
http.request(request)
|
|
805
|
-
end
|
|
198
|
+
data = JSON.parse(response.body)
|
|
199
|
+
@token = data['access'] || raise(Error.new('Invalid auth response'))
|
|
200
|
+
@token_expires_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 28 * 60
|
|
201
|
+
end
|
|
806
202
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
body_parts << "--#{boundary}\r\n"
|
|
811
|
-
if part[:filename]
|
|
812
|
-
body_parts << "Content-Disposition: form-data; name=\"#{part[:name]}\"; filename=\"#{part[:filename]}\"\r\n"
|
|
813
|
-
body_parts << "Content-Type: #{part[:content_type] || 'application/octet-stream'}\r\n\r\n"
|
|
814
|
-
else
|
|
815
|
-
body_parts << "Content-Disposition: form-data; name=\"#{part[:name]}\"\r\n"
|
|
816
|
-
body_parts << "Content-Type: #{part[:content_type]}\r\n" if part[:content_type]
|
|
817
|
-
body_parts << "\r\n"
|
|
818
|
-
end
|
|
819
|
-
body_parts << part[:content]
|
|
820
|
-
body_parts << "\r\n"
|
|
821
|
-
end
|
|
822
|
-
body_parts << "--#{boundary}--\r\n"
|
|
823
|
-
body_parts.join
|
|
203
|
+
def invalidate_token
|
|
204
|
+
@token_mutex.synchronize do
|
|
205
|
+
@token_expires_at = 0
|
|
824
206
|
end
|
|
825
207
|
end
|
|
826
208
|
end
|