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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -13
  3. data/Gemfile.lock +1 -1
  4. data/README.md +136 -138
  5. data/docs/ChorusProCredentials.md +8 -8
  6. data/docs/ChorusProDestination.md +1 -1
  7. data/docs/FactureElectroniqueModelsInvoiceTypeCode.md +15 -0
  8. data/docs/FactureElectroniqueRestApiSchemasCdarValidationErrorResponse.md +24 -0
  9. data/docs/FactureElectroniqueRestApiSchemasProcessingChorusProCredentials.md +26 -0
  10. data/docs/GetChorusProIdRequest.md +1 -1
  11. data/docs/GetInvoiceRequest.md +1 -1
  12. data/docs/GetStructureRequest.md +1 -1
  13. data/docs/InvoiceInput.md +1 -1
  14. data/docs/Recipient.md +1 -1
  15. data/docs/SearchStructureRequest.md +1 -1
  16. data/docs/SimplifiedInvoiceData.md +1 -1
  17. data/docs/SubmitCompleteInvoiceResponse.md +2 -2
  18. data/docs/SubmitInvoiceRequest.md +1 -1
  19. data/docs/Supplier.md +1 -1
  20. data/docs/ValidateCDARResponse.md +2 -2
  21. data/docs/ValidationErrorResponse.md +2 -8
  22. data/lib/factpulse/helpers/client.rb +152 -770
  23. data/lib/factpulse/helpers/exceptions.rb +38 -14
  24. data/lib/factpulse/helpers/helpers.rb +8 -7
  25. data/lib/factpulse/models/chorus_pro_credentials.rb +94 -26
  26. data/lib/factpulse/models/chorus_pro_destination.rb +1 -1
  27. data/lib/factpulse/models/{facture_electronique_rest_api_schemas_ereporting_invoice_type_code.rb → facture_electronique_models_invoice_type_code.rb} +20 -9
  28. 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
  29. 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
  30. data/lib/factpulse/models/get_chorus_pro_id_request.rb +1 -1
  31. data/lib/factpulse/models/get_invoice_request.rb +1 -1
  32. data/lib/factpulse/models/get_structure_request.rb +1 -1
  33. data/lib/factpulse/models/invoice_input.rb +1 -1
  34. data/lib/factpulse/models/invoice_type_code.rb +6 -17
  35. data/lib/factpulse/models/recipient.rb +0 -2
  36. data/lib/factpulse/models/scheme_id.rb +3 -3
  37. data/lib/factpulse/models/search_structure_request.rb +1 -1
  38. data/lib/factpulse/models/simplified_invoice_data.rb +1 -1
  39. data/lib/factpulse/models/submit_complete_invoice_response.rb +16 -16
  40. data/lib/factpulse/models/submit_invoice_request.rb +1 -1
  41. data/lib/factpulse/models/supplier.rb +0 -2
  42. data/lib/factpulse/models/validate_cdar_response.rb +2 -2
  43. data/lib/factpulse/models/validation_error_response.rb +20 -67
  44. data/lib/factpulse/version.rb +1 -1
  45. data/lib/factpulse.rb +3 -3
  46. metadata +8 -8
  47. data/docs/FactureElectroniqueRestApiSchemasChorusProChorusProCredentials.md +0 -26
  48. data/docs/FactureElectroniqueRestApiSchemasEreportingInvoiceTypeCode.md +0 -15
  49. data/docs/FactureElectroniqueRestApiSchemasValidationValidationErrorResponse.md +0 -18
@@ -1,826 +1,208 @@
1
1
  # frozen_string_literal: true
2
- require 'net/http'; require 'json'; require 'base64'; require 'uri'; require 'securerandom'; require 'digest'; require 'tempfile'
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
- module Helpers
6
- # Chorus Pro credentials for Zero-Trust mode.
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
- # AFNOR PDP credentials for Zero-Trust mode.
21
- # The FactPulse API uses these credentials to authenticate with the AFNOR PDP.
22
- class AFNORCredentials
23
- attr_reader :flow_service_url, :token_url, :client_id, :client_secret, :directory_service_url
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
- # Helpers for creating simplified total amounts.
37
- module AmountHelpers
38
- def self.amount(value)
39
- return '0.00' if value.nil?
40
- return format('%.2f', value) if value.is_a?(Numeric)
41
- value.is_a?(String) ? value : '0.00'
42
- end
43
-
44
- def self.invoice_totals(excl_tax, vat, incl_tax, amount_due, discount_incl_tax: nil, discount_reason: nil, prepayment: nil)
45
- result = {
46
- 'totalExclTax' => amount(excl_tax), 'vatAmount' => amount(vat),
47
- 'totalInclTax' => amount(incl_tax), 'amountDue' => amount(amount_due)
48
- }
49
- result['globalDiscountInclTax'] = amount(discount_incl_tax) if discount_incl_tax
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
- # Creates an invoice line (aligned with InvoiceLine in models.py).
56
- def self.invoice_line(number, description, quantity, unit_price_excl_tax, line_total_excl_tax,
57
- vat_rate: '20.00', vat_category: 'S', unit: 'LUMP_SUM', **options)
58
- result = {
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
- # Creates a VAT line (aligned with VatLine in models.py).
74
- def self.vat_line(rate_manual, base_amount_excl_tax, vat_amount, category: 'S')
75
- {
76
- 'rateManual' => amount(rate_manual), 'baseAmountExclTax' => amount(base_amount_excl_tax),
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
- # Creates a postal address for the FactPulse API.
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
- # Creates an electronic address. scheme_id: "0009"=SIREN, "0225"=SIRET
90
- def self.electronic_address(identifier, scheme_id: '0009')
91
- { 'identifier' => identifier, 'schemeId' => scheme_id }
92
- end
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
- # Computes the French intra-community VAT number from a SIREN.
95
- def self.compute_vat_intra(siren)
96
- return nil if siren.nil? || siren.length != 9 || !siren.match?(/^\d+$/)
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
- # Creates a supplier (issuer) with auto-computed SIREN, intra-EU VAT number and addresses.
102
- def self.supplier(name, siret, address_line1, postal_code, city, **options)
103
- siren = options[:siren] || (siret.length == 14 ? siret[0, 9] : nil)
104
- vat_intra = options[:vat_intra] || (siren ? compute_vat_intra(siren) : nil)
105
- result = {
106
- 'name' => name, 'supplierId' => options[:supplier_id] || 0, 'siret' => siret,
107
- 'electronicAddress' => electronic_address(siret, scheme_id: '0225'),
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
- # Creates a recipient (customer) with auto-computed SIREN and addresses.
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
- # Creates a beneficiary (factor) for factoring.
132
- #
133
- # The beneficiary (BG-10 / PayeeTradeParty) is used when payment
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
- class FactPulseClient
166
- attr_reader :chorus_credentials, :afnor_credentials
84
+ result = parse_response(response)
167
85
 
168
- def initialize(email:, password:, api_url: nil, client_uid: nil, chorus_credentials: nil, afnor_credentials: nil,
169
- polling_interval: nil, polling_timeout: nil, max_retries: nil)
170
- @email, @password = email, password; @api_url = (api_url || 'https://factpulse.fr').chomp('/')
171
- @client_uid, @polling_interval, @polling_timeout, @max_retries = client_uid, polling_interval || 2000, polling_timeout || 120000, max_retries || 1
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
- def chorus_credentials_for_api; @chorus_credentials&.to_h; end
177
- def afnor_credentials_for_api; @afnor_credentials&.to_h; end
178
- # Shorter aliases
179
- def get_chorus_pro_credentials; chorus_credentials_for_api; end
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
- def reset_auth; @access_token = @refresh_token = @token_expires_at = nil; end
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
- unless response.is_a?(Net::HTTPSuccess)
263
- # Extract error details from response body
264
- error_msg = "API Error (#{response.code})"
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
- warn "API Error #{response.code}: #{response.body}"
293
- raise FactPulseValidationError.new(error_msg, errors)
294
- end
105
+ return data if response.is_a?(Net::HTTPSuccess)
295
106
 
296
- data = JSON.parse(response.body)
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
- if result['content_b64']
317
- return Base64.decode64(result['content_b64'])
318
- elsif result['content_xml']
319
- return result['content_xml']
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
- raise FactPulseValidationError.new("Unexpected result: #{result.keys.join(', ')}")
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
- private def make_afnor_request(method, endpoint, json_data: nil, multipart: nil)
367
- token_info = get_afnor_token_and_url
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
- response = http.request(request)
392
- raise FactPulseValidationError.new("AFNOR error: #{response.code} - #{response.body}") unless response.is_a?(Net::HTTPSuccess)
128
+ def poll(task_id)
129
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
130
+ interval = 1.0
393
131
 
394
- content_type = response['Content-Type'] || ''
395
- if content_type.include?('application/json')
396
- JSON.parse(response.body) rescue {}
397
- else
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 = 60
465
-
466
- request = Net::HTTP::Get.new(uri)
467
- request['Authorization'] = "Bearer #{@access_token}"
142
+ http.read_timeout = @timeout
468
143
 
469
- response = http.request(request)
470
- raise FactPulseValidationError.new("Incoming flow error: #{response.code}") unless response.is_a?(Net::HTTPSuccess)
471
- JSON.parse(response.body) rescue {}
472
- end
144
+ req = Net::HTTP::Get.new(uri)
145
+ req['Authorization'] = "Bearer #{@token}"
146
+ response = http.request(req)
473
147
 
474
- # Checks the availability of the AFNOR Flow Service.
475
- def healthcheck_afnor
476
- make_afnor_request('GET', '/flow/v1/healthcheck')
477
- end
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
- boundary = "----RubyFormBoundary#{SecureRandom.hex(16)}"
646
- body = build_multipart_body(boundary, [
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
- response = http_multipart_post(uri, body, boundary)
651
- raise FactPulseValidationError.new("Validation error: #{response.code}") unless response.is_a?(Net::HTTPSuccess)
652
- JSON.parse(response.body) rescue {}
653
- end
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
- # Signature
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
- # Generates a test certificate (NOT FOR PRODUCTION).
686
- def generate_test_certificate(**options)
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
- # Workflow complet
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
- private
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
- def http_post(uri, payload)
775
- Net::HTTP.new(uri.host, uri.port).tap { |h| h.use_ssl = uri.scheme == 'https'; h.read_timeout = 30 }
776
- .request(Net::HTTP::Post.new(uri).tap { |r| r['Content-Type'] = 'application/json'; r.body = JSON.generate(payload) })
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
- def http_post_json(uri, payload)
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
- def http_get(uri)
791
- Net::HTTP.new(uri.host, uri.port).tap { |h| h.use_ssl = uri.scheme == 'https'; h.read_timeout = 30 }
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
- def http_multipart_post(uri, body, boundary)
796
- http = Net::HTTP.new(uri.host, uri.port)
797
- http.use_ssl = uri.scheme == 'https'
798
- http.read_timeout = 120
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
- def build_multipart_body(boundary, parts)
808
- body_parts = []
809
- parts.each do |part|
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