c6_bank 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.
@@ -0,0 +1,17 @@
1
+ # config/initializers/c6_bank.rb
2
+ #
3
+ # Store secrets in Rails credentials or environment variables.
4
+ # Never commit real certificates or client secrets.
5
+
6
+ C6Bank.configure do |c|
7
+ c.client_id = Rails.application.credentials.dig(:c6_bank, :client_id) || ENV.fetch("C6_CLIENT_ID")
8
+ c.client_secret = Rails.application.credentials.dig(:c6_bank, :client_secret) || ENV.fetch("C6_CLIENT_SECRET")
9
+ c.pix_key = Rails.application.credentials.dig(:c6_bank, :pix_key) || ENV["C6_PIX_KEY"]
10
+
11
+ c.certificate_path = Rails.application.credentials.dig(:c6_bank, :cert_path) || ENV.fetch("C6_CERT_PATH")
12
+ c.private_key_path = Rails.application.credentials.dig(:c6_bank, :key_path) || ENV.fetch("C6_KEY_PATH")
13
+
14
+ c.environment = Rails.env.production? ? :production : :sandbox
15
+ c.partner_software_name = "MyApp"
16
+ c.partner_software_version = "1.0.0"
17
+ end
@@ -0,0 +1,31 @@
1
+ # Roteiro de Conformidade → Gem
2
+
3
+ Paths below match the official C6 OpenAPI (`docs/openapi/bolepix.yaml`, `docs/openapi/pix-api.yaml`).
4
+
5
+ ## Bolepix (`client.boletos`) — `/v2/bank_slips`
6
+
7
+ | Roteiro | Method |
8
+ |---|---|
9
+ | 1–3 Emitir (simples, juros/multa, desconto) | `create` / `C6Bank::Boleto::Request` (`with_fees`, `with_juros`, `with_multa`, `add_desconto`) |
10
+ | 4 Baixa | `cancel` / `baixa` → `PUT /v2/bank_slips/{id}/cancel` |
11
+ | 5 Alterar | `update` → `PATCH /v2/bank_slips/{id}` |
12
+ | 6 Consultar | `find` → `GET /v2/bank_slips/{id}` |
13
+ | Listar | `list` → `GET /v2/bank_slips/list` |
14
+ | PDF | `pdf` → `GET /v2/bank_slips/{id}/pdf` |
15
+
16
+ Pix QR on a boleto: set `C6_PIX_KEY` (EVP). Opt out with `req.without_pix`.
17
+
18
+ ## Pix (`client.pix`) — `/v2/pix`
19
+
20
+ | Roteiro | Method | HTTP |
21
+ |---|---|---|
22
+ | 7.1 / 7.2 Cobrança imediata | `create_immediate_charge` | `POST /cob` or `PUT /cob/{txid}` |
23
+ | 7.3 Consultar cob | `find_immediate` | `GET /cob/{txid}` |
24
+ | 7.4 Listar cob | `list_immediate` | `GET /cob` |
25
+ | 7.5 Cobrança com vencimento | `create_due_charge` | `PUT /cobv/{txid}` |
26
+ | 7.6 / 7.7 Consultar cobv | `find_due` / `list_due` | `GET /cobv/{txid}`, `GET /cobv` |
27
+ | 7.8 Webhook | `configure_webhook` | `PUT /webhook/{chave}` |
28
+
29
+ ## Payments / DDA (`client.dda`)
30
+
31
+ Still a thin wrapper. Confirm paths in the Agendamento de Pagamentos spec before homologating section 8.
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require_relative "env"
6
+ require "c6_bank"
7
+
8
+ C6BankExamples.load_env!
9
+
10
+ client = C6Bank::Client.from_env
11
+ client.authenticate!
12
+ puts "Authenticated. scope=#{client.scope.inspect}"
13
+ rescue C6Bank::AuthenticationError => e
14
+ abort e.message
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "date"
6
+ require "json"
7
+ require_relative "env"
8
+ require "c6_bank"
9
+
10
+ C6BankExamples.load_env!
11
+
12
+ client = C6Bank::Client.from_env
13
+ client.authenticate!
14
+
15
+ due_date = (Date.today + 10).iso8601
16
+
17
+ puts "=== Bolepix with Pix (default when C6_PIX_KEY is set) ==="
18
+ req = C6Bank.boleto_request(
19
+ amount: 150.75,
20
+ due_date: due_date,
21
+ description: "Pedido 42"
22
+ )
23
+ req.payer(
24
+ name: "Fulano de Tal",
25
+ tax_id: "12345678909",
26
+ address: {
27
+ address: "Av. Nove de Julho, 3186",
28
+ neighborhood: "Jardins",
29
+ city: "Sao Paulo",
30
+ state: "SP",
31
+ zip_code: "01406000"
32
+ }
33
+ )
34
+ req.with_fees(
35
+ fine_value: 10,
36
+ fine_deadline: 5,
37
+ fine_type: "FIXED_VALUE",
38
+ interest_value: 0.5,
39
+ interest_deadline: 1,
40
+ interest_type: "VALUE_PER_DAY"
41
+ )
42
+ req.bank_slip(instructions: ["Nao receber apos o vencimento"])
43
+
44
+ puts JSON.pretty_generate(req.to_h.merge("payment_method" => req.to_h[:payment_method]))
45
+ puts
46
+ puts client.boletos.create(req)
47
+
48
+ puts "\n=== Boleto only ==="
49
+ req2 = C6Bank.boleto_request(amount: 99.90, due_date: due_date, description: "Somente boleto")
50
+ req2.payer(
51
+ name: "Empresa Exemplo Ltda",
52
+ tax_id: "12345678000195",
53
+ address: { address: "Rua Teste 100", city: "Curitiba", state: "PR", zip_code: "80010000" }
54
+ )
55
+ req2.without_pix
56
+ puts client.boletos.create(req2)
data/examples/env.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module C6BankExamples
4
+ module_function
5
+
6
+ def load_env!(path = File.expand_path("../.env", __dir__))
7
+ return unless File.file?(path)
8
+
9
+ File.readlines(path, chomp: true).each do |line|
10
+ next if line.strip.empty? || line.lstrip.start_with?("#")
11
+
12
+ key, value = line.split("=", 2)
13
+ next unless key && value
14
+
15
+ ENV[key] = unquote(value) if ENV[key].nil? || ENV[key].empty?
16
+ end
17
+ end
18
+
19
+ def unquote(value)
20
+ value.strip.sub(/\A(['"])(.*)\1\z/, '\2')
21
+ end
22
+ end
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Hits the C6 sandbox with the credentials in `.env`.
5
+ #
6
+ # bundle exec ruby examples/live_sandbox.rb
7
+ #
8
+ # Sandbox hours: Monday–Friday 07:00–22:00 BRT.
9
+
10
+ require "bundler/setup"
11
+ require "date"
12
+ require "json"
13
+ require_relative "env"
14
+ require "c6_bank"
15
+
16
+ $stdout.sync = true
17
+
18
+ C6BankExamples.load_env!
19
+
20
+ client = C6Bank::Client.from_env
21
+
22
+ puts "Environment : #{client.config.environment}"
23
+ puts "Base URL : #{client.config.effective_base_url}"
24
+ puts "Client ID : #{client.config.client_id[0, 8]}…"
25
+ puts "Pix key : #{client.config.pix_key}"
26
+ puts "Wallet : #{client.config.effective_billing_scheme}"
27
+ puts
28
+
29
+ begin
30
+ client.authenticate!
31
+ puts "Auth OK"
32
+ puts "Scope : #{client.scope.inspect}"
33
+ rescue C6Bank::AuthenticationError => e
34
+ abort <<~MSG
35
+ Auth failed: #{e.message}
36
+
37
+ Typical causes:
38
+ - mTLS certificate expired or not issued for this client_id
39
+ - sandbox closed (Mon-Fri 07:00-22:00 BRT)
40
+ - client_id / client_secret mismatch
41
+ MSG
42
+ end
43
+
44
+ due_date = (Date.today + 10).iso8601
45
+
46
+ puts "\n=== Bolepix (boleto + Pix QR) ==="
47
+ req = C6Bank.boleto_request(
48
+ amount: 12.34,
49
+ due_date: due_date,
50
+ description: "c6_bank gem live sandbox"
51
+ )
52
+ req.payer(
53
+ name: "Fulano de Tal",
54
+ tax_id: "12345678909",
55
+ address: {
56
+ address: "Av. Nove de Julho, 3186",
57
+ neighborhood: "Jardins",
58
+ city: "Sao Paulo",
59
+ state: "SP",
60
+ zip_code: "01406000"
61
+ }
62
+ )
63
+ req.bank_slip(instructions: ["Pagavel via boleto ou Pix"])
64
+
65
+ begin
66
+ bolepix = client.boletos.create(req)
67
+ puts JSON.pretty_generate(bolepix)
68
+ pix = bolepix.dig("payment_method", "pix") || bolepix.dig(:payment_method, :pix)
69
+ if pix
70
+ puts "Pix QR present: #{pix['qr_code'] || pix[:qr_code] ? 'yes' : 'no'}"
71
+ puts "Pix reference : #{pix['reference'] || pix[:reference]}"
72
+ else
73
+ puts "WARN: charge created without Pix. Check that C6_PIX_KEY is a registered EVP key."
74
+ end
75
+ rescue C6Bank::ApiError => e
76
+ puts "Bolepix failed (#{e.status}): #{e.body}"
77
+ end
78
+
79
+ puts "\n=== Pix cob (immediate charge) ==="
80
+ begin
81
+ cob = client.pix.create_immediate_charge(
82
+ amount: "5.00",
83
+ expiration: 3600,
84
+ devedor: { cpf: "12345678909", nome: "Fulano de Tal" },
85
+ solicitacaoPagador: "c6_bank gem live sandbox"
86
+ )
87
+ puts JSON.pretty_generate(cob)
88
+ rescue C6Bank::ApiError => e
89
+ puts "Pix cob failed (#{e.status}): #{e.body}"
90
+ end
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "date"
6
+ require "json"
7
+ require_relative "env"
8
+ require "c6_bank"
9
+
10
+ C6BankExamples.load_env!
11
+
12
+ client = C6Bank::Client.from_env
13
+ client.authenticate!
14
+
15
+ puts "=== Immediate Pix charge (cob) ==="
16
+ cob = client.pix.create_immediate_charge(
17
+ amount: "89.90",
18
+ expiration: 3600,
19
+ devedor: { cpf: "12345678909", nome: "Fulano" },
20
+ solicitacaoPagador: "Servico prestado"
21
+ )
22
+ puts JSON.pretty_generate(cob)
23
+
24
+ puts "\n=== Due Pix charge (cobv) ==="
25
+ cobv = client.pix.create_due_charge(
26
+ amount: "250.00",
27
+ due_date: (Date.today + 15).iso8601,
28
+ days_after_due: 30,
29
+ devedor: {
30
+ cpf: "12345678909",
31
+ nome: "Fulano de Tal",
32
+ logradouro: "Alameda Souza, 80",
33
+ cidade: "Recife",
34
+ uf: "PE",
35
+ cep: "70011750"
36
+ },
37
+ solicitacaoPagador: "Cobranca com vencimento"
38
+ )
39
+ puts JSON.pretty_generate(cobv)
40
+
41
+ puts "\nConsult by txid:"
42
+ puts JSON.pretty_generate(client.pix.find_immediate(cob.fetch("txid")))
data/exe/c6_bank ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "c6_bank"
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module C6Bank
4
+ module Boleto
5
+ # Builder for C6 Bank Bolepix (bank slip + optional Pix QR) requests.
6
+ #
7
+ # Matches the official OpenAPI spec (docs/openapi/bolepix.yaml).
8
+ class Request
9
+ attr_reader :data
10
+
11
+ def self.generate_external_reference_id
12
+ require "securerandom"
13
+ SecureRandom.alphanumeric(26).upcase
14
+ end
15
+
16
+ def self.apply_defaults(body, config, omit_pix: false)
17
+ payload = C6Bank::Pix::Payload.stringify(body)
18
+ payment_method = payload["payment_method"] ||= {}
19
+ bank_slip = payment_method["bank_slip"] ||= {}
20
+ bank_slip["billing_scheme"] ||= config.effective_billing_scheme
21
+
22
+ if omit_pix
23
+ payment_method.delete("pix")
24
+ elsif payment_method["pix"].nil? && config.pix_key
25
+ payment_method["pix"] = { "key" => config.pix_key, "type" => "EVP" }
26
+ end
27
+
28
+ payload["external_reference_id"] ||= generate_external_reference_id
29
+ payload
30
+ end
31
+
32
+ def initialize(params = {})
33
+ @omit_pix = false
34
+ @data = {
35
+ external_reference_id: params[:external_reference_id] || params["external_reference_id"],
36
+ amount: params[:amount] || params[:valor] || params["amount"] || params["valor"],
37
+ due_date: params[:due_date] || params[:data_vencimento] || params["due_date"] || params["data_vencimento"],
38
+ description: params[:description] || params["description"],
39
+ days_after_due_date: params[:days_after_due_date] || params["days_after_due_date"],
40
+ origin: params[:origin] || params["origin"],
41
+ payer: normalize_payer(params[:payer] || params["payer"]),
42
+ fees: normalize_fees(params[:fees] || params["fees"]),
43
+ payment_method: {
44
+ bank_slip: {}
45
+ }
46
+ }.compact
47
+
48
+ return unless params[:payment_method] || params["payment_method"]
49
+
50
+ @data[:payment_method] = (params[:payment_method] || params["payment_method"]).dup
51
+ end
52
+
53
+ def omit_pix?
54
+ @omit_pix
55
+ end
56
+
57
+ def payer(name: nil, tax_id: nil, email: nil, address: nil, **rest)
58
+ payer_data = {
59
+ name: name,
60
+ tax_id: tax_id,
61
+ email: email,
62
+ address: address
63
+ }.merge(rest).compact
64
+ @data[:payer] = normalize_payer(payer_data)
65
+ self
66
+ end
67
+
68
+ def with_fees(**fee_params)
69
+ @data[:fees] = normalize_fees(fee_params)
70
+ self
71
+ end
72
+
73
+ def bank_slip(billing_scheme: nil, our_number: nil, your_number: nil, instructions: nil, **rest)
74
+ @data[:payment_method] ||= {}
75
+ bs = @data[:payment_method][:bank_slip] ||= {}
76
+ bs[:billing_scheme] = billing_scheme.to_s if billing_scheme
77
+ bs[:our_number] = our_number.to_s if our_number
78
+ bs[:your_number] = your_number.to_s if your_number
79
+ bs[:instructions] = Array(instructions) if instructions
80
+ bs.merge!(rest.transform_keys(&:to_sym))
81
+ self
82
+ end
83
+
84
+ def pix(key:, type: "EVP")
85
+ @omit_pix = false
86
+ @data[:payment_method] ||= {}
87
+ @data[:payment_method][:pix] = { key: key, type: type }
88
+ self
89
+ end
90
+
91
+ def without_pix
92
+ @omit_pix = true
93
+ @data[:payment_method]&.delete(:pix)
94
+ self
95
+ end
96
+
97
+ def with_juros(valor:, tipo: :diario)
98
+ tipo_str = case tipo.to_s.downcase
99
+ when "diario", "value_per_day" then "VALUE_PER_DAY"
100
+ when "monthly_percentage", "mensal" then "MONTHLY_PERCENTAGE"
101
+ else tipo.to_s.upcase
102
+ end
103
+ fees = @data[:fees] || {}
104
+ fees[:interest_value] = valor
105
+ fees[:interest_type] = tipo_str
106
+ @data[:fees] = fees
107
+ self
108
+ end
109
+
110
+ def with_multa(tipo:, valor:)
111
+ tipo_str = case tipo.to_s.downcase
112
+ when "fixo", "fixed" then "FIXED_VALUE"
113
+ when "percentual", "percentage", "%" then "PERCENTAGE"
114
+ else tipo.to_s.upcase
115
+ end
116
+ fees = @data[:fees] || {}
117
+ fees[:fine_value] = valor
118
+ fees[:fine_type] = tipo_str
119
+ @data[:fees] = fees
120
+ self
121
+ end
122
+
123
+ def add_desconto(**opts)
124
+ fees = @data[:fees] ||= {}
125
+ fees[:first_discount_value] = opts[:valor] || opts[:value] if opts[:valor] || opts[:value]
126
+ fees[:first_discount_deadline] = opts[:dias_antes] || opts[:deadline] if opts[:dias_antes] || opts[:deadline]
127
+ if opts[:tipo] || opts[:type]
128
+ t = (opts[:tipo] || opts[:type]).to_s.upcase
129
+ fees[:discount_type] = t
130
+ end
131
+ self
132
+ end
133
+
134
+ def to_h
135
+ result = @data.dup
136
+ if result[:payment_method].is_a?(Hash)
137
+ result[:payment_method] = result[:payment_method].dup
138
+ result[:payment_method].delete(:pix) if result[:payment_method][:pix].nil? || omit_pix?
139
+ end
140
+ result.compact
141
+ end
142
+
143
+ private
144
+
145
+ def normalize_payer(payer)
146
+ return nil unless payer
147
+
148
+ p = payer.is_a?(Hash) ? payer.dup : {}
149
+ p[:tax_id] ||= p.delete(:cpf_cnpj) || p.delete("cpf_cnpj")
150
+ addr = p[:address] || p["address"]
151
+ p[:address] = addr.transform_keys(&:to_sym) if addr.is_a?(Hash)
152
+ p.transform_keys(&:to_sym).compact
153
+ end
154
+
155
+ def normalize_fees(fees)
156
+ return nil unless fees
157
+
158
+ f = fees.is_a?(Hash) ? fees.dup : {}
159
+ f.transform_keys(&:to_sym).compact
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+ require "openssl"
6
+ require "json"
7
+ require "time"
8
+ require "uri"
9
+
10
+ module C6Bank
11
+ class Client
12
+ TOKEN_PATHS = ["/v1/auth", "/v1/auth/"].freeze
13
+
14
+ attr_reader :config, :scope
15
+
16
+ def self.from_env(**overrides)
17
+ new(
18
+ {
19
+ client_id: ENV.fetch("C6_CLIENT_ID"),
20
+ client_secret: ENV.fetch("C6_CLIENT_SECRET"),
21
+ certificate_path: ENV.fetch("C6_CERT_PATH"),
22
+ private_key_path: ENV.fetch("C6_KEY_PATH"),
23
+ pix_key: ENV["C6_PIX_KEY"],
24
+ environment: (ENV["C6_ENV"] || "sandbox").to_sym,
25
+ partner_software_name: ENV["C6_PARTNER_SOFTWARE_NAME"],
26
+ partner_software_version: ENV["C6_PARTNER_SOFTWARE_VERSION"],
27
+ base_url: ENV["C6_BASE_URL"],
28
+ auth_base_url: ENV["C6_AUTH_BASE_URL"],
29
+ token_path: ENV["C6_TOKEN_PATH"],
30
+ boleto_path: ENV["C6_BOLETO_PATH"],
31
+ pix_path: ENV["C6_PIX_PATH"]
32
+ }.compact.merge(overrides)
33
+ )
34
+ end
35
+
36
+ def initialize(options = {})
37
+ @config = Configuration.new
38
+
39
+ options.each do |key, value|
40
+ setter = "#{key}="
41
+ @config.public_send(setter, value) if @config.respond_to?(setter)
42
+ end
43
+
44
+ @config.validate!
45
+
46
+ @token = nil
47
+ @token_expires_at = nil
48
+ @scope = nil
49
+ @preferred_token_path = options[:token_path] || @config.token_path
50
+ end
51
+
52
+ def connection
53
+ @connection ||= build_connection
54
+ end
55
+
56
+ def boletos
57
+ Resources::Boleto.new(self)
58
+ end
59
+
60
+ def pix
61
+ Resources::Pix.new(self)
62
+ end
63
+
64
+ def dda
65
+ Resources::Dda.new(self)
66
+ end
67
+
68
+ def request(method, path, body: nil, params: nil, headers: {}, raw: false)
69
+ ensure_authenticated!
70
+
71
+ response = perform_request(method, path, body: body, params: params, headers: headers)
72
+ begin
73
+ raw ? raw_or_error(response) : handle_response(response)
74
+ rescue AuthenticationError
75
+ raise unless response.status == 401
76
+
77
+ @token = nil
78
+ @token_expires_at = nil
79
+ ensure_authenticated!
80
+ response = perform_request(method, path, body: body, params: params, headers: headers)
81
+ raw ? raw_or_error(response) : handle_response(response)
82
+ end
83
+ end
84
+
85
+ def authenticate!(token_path: nil)
86
+ @config.validate!
87
+
88
+ auth_base = @config.effective_auth_base_url
89
+ token_conn = Faraday.new(url: auth_base) do |f|
90
+ f.ssl.merge!(ssl_options)
91
+ f.request :retry, max: @config.max_retries, interval: 0.5, interval_randomness: 0.5, backoff_factor: 2,
92
+ methods: %i[post], retry_statuses: [429, 500, 502, 503, 504]
93
+ f.options.open_timeout = @config.open_timeout
94
+ f.options.timeout = @config.read_timeout
95
+ f.adapter Faraday.default_adapter
96
+ end
97
+
98
+ paths = if token_path
99
+ [token_path]
100
+ else
101
+ [@preferred_token_path, *TOKEN_PATHS].compact.uniq
102
+ end
103
+
104
+ last_error = nil
105
+ paths.each do |path|
106
+ resp = token_conn.post(path) do |req|
107
+ req.headers.merge!(
108
+ "Content-Type" => "application/x-www-form-urlencoded",
109
+ "Accept" => "application/json"
110
+ )
111
+ req.body = URI.encode_www_form(
112
+ grant_type: "client_credentials",
113
+ client_id: @config.client_id,
114
+ client_secret: @config.client_secret
115
+ )
116
+ end
117
+
118
+ if resp.status.between?(200, 299)
119
+ store_token(resp)
120
+ @preferred_token_path = path
121
+ return true
122
+ end
123
+
124
+ last_error = AuthenticationError.new("Could not obtain token (#{resp.status}): #{utf8(resp.body)}")
125
+ end
126
+
127
+ raise last_error if last_error
128
+
129
+ raise AuthenticationError, "Could not obtain token"
130
+ end
131
+
132
+ def authenticated?
133
+ @token && @token_expires_at && Time.now < @token_expires_at
134
+ end
135
+
136
+ def set_token(access_token, expires_in: 3600)
137
+ @token = access_token
138
+ @token_expires_at = Time.now + expires_in.to_i - 30
139
+ end
140
+
141
+ def auth_headers(extra = {})
142
+ h = {}
143
+ h["Authorization"] = "Bearer #{@token}" if @token
144
+ h["partner-software-name"] = @config.partner_software_name if @config.partner_software_name
145
+ h["partner-software-version"] = @config.partner_software_version if @config.partner_software_version
146
+ h.merge(extra)
147
+ end
148
+
149
+ private
150
+
151
+ def ensure_authenticated!
152
+ authenticate! unless authenticated?
153
+ end
154
+
155
+ def store_token(resp)
156
+ parsed = begin
157
+ JSON.parse(resp.body.to_s)
158
+ rescue JSON::ParserError
159
+ {}
160
+ end
161
+ token = parsed["access_token"] || parsed["token"] || parsed["accessToken"]
162
+
163
+ raise AuthenticationError, "Response did not include access_token. Body: #{resp.body}" unless token
164
+
165
+ @token = token
166
+ @scope = parsed["scope"]
167
+ expires = parsed["expires_in"] || parsed["expiresIn"] || 3600
168
+ @token_expires_at = Time.now + expires.to_i - 30
169
+ true
170
+ end
171
+
172
+ def build_connection
173
+ Faraday.new(url: @config.effective_base_url) do |f|
174
+ f.ssl.merge!(ssl_options)
175
+ f.request :retry, max: @config.max_retries,
176
+ interval: 0.5,
177
+ interval_randomness: 0.5,
178
+ backoff_factor: 2,
179
+ methods: %i[get head options],
180
+ retry_statuses: [429, 500, 502, 503, 504]
181
+ f.options.open_timeout = @config.open_timeout
182
+ f.options.timeout = @config.read_timeout
183
+ f.adapter Faraday.default_adapter
184
+ end
185
+ end
186
+
187
+ def ssl_options
188
+ {
189
+ client_cert: @config.ssl_certificate,
190
+ client_key: @config.ssl_private_key,
191
+ verify: true
192
+ }
193
+ end
194
+
195
+ def perform_request(method, path, body:, params:, headers:)
196
+ request_headers = {
197
+ "Accept" => "application/json"
198
+ }.merge(headers)
199
+
200
+ request_headers["Content-Type"] ||= "application/json" if body
201
+ request_headers["Authorization"] = "Bearer #{@token}" if @token
202
+ request_headers["partner-software-name"] = @config.partner_software_name if @config.partner_software_name
203
+ request_headers["partner-software-version"] = @config.partner_software_version if @config.partner_software_version
204
+
205
+ connection.public_send(method, path) do |req|
206
+ req.params.update(params) if params
207
+ req.body = json_body(body) unless body.nil?
208
+ req.headers.merge!(request_headers)
209
+ end
210
+ end
211
+
212
+ def json_body(body)
213
+ return body if body.is_a?(String)
214
+
215
+ JSON.generate(body)
216
+ end
217
+
218
+ def raw_or_error(response)
219
+ return response if response.status.between?(200, 299)
220
+
221
+ handle_response(response)
222
+ end
223
+
224
+ def utf8(value)
225
+ str = value.to_s.dup
226
+ str.force_encoding("UTF-8")
227
+ str.encode("UTF-8", invalid: :replace, undef: :replace)
228
+ end
229
+
230
+ def handle_response(response)
231
+ case response.status
232
+ when 200..299
233
+ return nil if response.body.to_s.strip.empty?
234
+
235
+ begin
236
+ JSON.parse(response.body)
237
+ rescue JSON::ParserError
238
+ utf8(response.body)
239
+ end
240
+ when 401
241
+ @token = nil
242
+ @token_expires_at = nil
243
+ raise AuthenticationError, "Unauthorized (401): #{utf8(response.body)}"
244
+ when 404
245
+ raise NotFoundError.new("Not found", status: 404, body: utf8(response.body), response: response)
246
+ when 429
247
+ raise RateLimitError.new("Rate limited", status: 429, body: utf8(response.body), response: response)
248
+ when 500..599
249
+ raise ServerError.new("Server error", status: response.status, body: utf8(response.body), response: response)
250
+ else
251
+ raise ApiError.new("API error", status: response.status, body: utf8(response.body), response: response)
252
+ end
253
+ end
254
+ end
255
+ end