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,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module C6Bank
6
+ class Configuration
7
+ SANDBOX_BASE_URL = "https://baas-api-sandbox.c6bank.info"
8
+ PRODUCTION_BASE_URL = "https://baas-api.c6bank.info"
9
+ SANDBOX_BILLING_SCHEME = "21"
10
+ PRODUCTION_BILLING_SCHEME = "15"
11
+
12
+ attr_accessor :client_id,
13
+ :client_secret,
14
+ :certificate_path,
15
+ :private_key_path,
16
+ :certificate,
17
+ :private_key,
18
+ :base_url,
19
+ :auth_base_url,
20
+ :open_timeout,
21
+ :read_timeout,
22
+ :write_timeout,
23
+ :max_retries,
24
+ :token_path,
25
+ :boleto_path,
26
+ :pix_path,
27
+ :dda_path,
28
+ :partner_software_name,
29
+ :partner_software_version,
30
+ :pix_key,
31
+ :billing_scheme
32
+ attr_reader :environment
33
+
34
+ def initialize
35
+ @environment = :sandbox
36
+ @open_timeout = 10
37
+ @read_timeout = 30
38
+ @write_timeout = 30
39
+ @max_retries = 2
40
+
41
+ @token_path = "/v1/auth"
42
+ @boleto_path = "/v2/bank_slips"
43
+ @pix_path = "/v2/pix"
44
+ @dda_path = "/api/v1/pagamentos"
45
+
46
+ @auth_base_url = nil
47
+ @partner_software_name = "c6_bank-gem"
48
+ @partner_software_version = C6Bank::VERSION
49
+ @pix_key = nil
50
+ @billing_scheme = nil
51
+ end
52
+
53
+ def environment=(env)
54
+ @environment = env.to_sym
55
+ end
56
+
57
+ def sandbox?
58
+ environment == :sandbox
59
+ end
60
+
61
+ def production?
62
+ environment == :production
63
+ end
64
+
65
+ def effective_base_url
66
+ return base_url if base_url
67
+
68
+ sandbox? ? SANDBOX_BASE_URL : PRODUCTION_BASE_URL
69
+ end
70
+
71
+ def effective_auth_base_url
72
+ return auth_base_url if auth_base_url
73
+
74
+ effective_base_url
75
+ end
76
+
77
+ def effective_billing_scheme
78
+ billing_scheme.to_s.empty? ? default_billing_scheme : billing_scheme.to_s
79
+ end
80
+
81
+ def default_billing_scheme
82
+ production? ? PRODUCTION_BILLING_SCHEME : SANDBOX_BILLING_SCHEME
83
+ end
84
+
85
+ def ssl_certificate
86
+ return certificate if certificate
87
+
88
+ OpenSSL::X509::Certificate.new(File.read(certificate_path)) if certificate_path
89
+ end
90
+
91
+ def ssl_private_key
92
+ return private_key if private_key
93
+
94
+ OpenSSL::PKey::RSA.new(File.read(private_key_path)) if private_key_path
95
+ end
96
+
97
+ def validate!
98
+ raise C6Bank::ConfigurationError, "client_id and client_secret are required" unless client_id && client_secret
99
+
100
+ unless ssl_certificate && ssl_private_key
101
+ raise C6Bank::ConfigurationError,
102
+ "Either certificate_path + private_key_path or certificate + private_key must be provided for mTLS"
103
+ end
104
+
105
+ warn_if_certificate_expired!
106
+ end
107
+
108
+ private
109
+
110
+ def warn_if_certificate_expired!
111
+ cert = ssl_certificate
112
+ expiry = cert.respond_to?(:not_after) ? cert.not_after : nil
113
+ return unless expiry.is_a?(Time) && expiry < Time.now.utc && expiry.year > 1980
114
+
115
+ warn "[c6_bank] mTLS certificate expired on #{expiry}. Replace C6_CERT_PATH / C6_KEY_PATH."
116
+ rescue OpenSSL::X509::CertificateError, ArgumentError, TypeError
117
+ nil
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module C6Bank
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+
8
+ class AuthenticationError < Error; end
9
+
10
+ class ApiError < Error
11
+ attr_reader :status, :body, :response
12
+
13
+ def initialize(message = nil, status: nil, body: nil, response: nil)
14
+ @status = status
15
+ @body = body
16
+ @response = response
17
+ super(message || default_message)
18
+ end
19
+
20
+ private
21
+
22
+ def default_message
23
+ msg = "C6 Bank API error"
24
+ msg += " (status: #{status})" if status
25
+ msg += ": #{body}" if body
26
+ msg
27
+ end
28
+ end
29
+
30
+ class NotFoundError < ApiError; end
31
+ class RateLimitError < ApiError; end
32
+ class ServerError < ApiError; end
33
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module C6Bank
4
+ module Pix
5
+ # Normalizes Ruby-friendly hashes into the BACEN Pix JSON contract.
6
+ module Payload
7
+ module_function
8
+
9
+ TOP_LEVEL_ALIASES = {
10
+ "pix_key" => "chave",
11
+ "key" => "chave",
12
+ "amount" => "valor",
13
+ "payer" => "devedor",
14
+ "message" => "solicitacaoPagador",
15
+ "solicitacao_pagador" => "solicitacaoPagador",
16
+ "additional_info" => "infoAdicionais",
17
+ "info_adicionais" => "infoAdicionais"
18
+ }.freeze
19
+
20
+ def immediate(params, default_key:)
21
+ data = stringify(params)
22
+ txid = data.delete("txid")
23
+ apply_aliases!(data)
24
+ data["chave"] = present(data["chave"]) || present(default_key)
25
+ require_chave!(data["chave"])
26
+
27
+ expiracao = data.delete("expiracao") || data.delete("expiration")
28
+ calendario = stringify(data["calendario"] || {})
29
+ calendario["expiracao"] ||= expiracao || 3600
30
+ data["calendario"] = calendario
31
+ data["valor"] = normalize_valor(data["valor"])
32
+ data["devedor"] = normalize_devedor(data["devedor"]) if data["devedor"]
33
+ [txid, compact(data)]
34
+ end
35
+
36
+ def due(params, default_key:)
37
+ data = stringify(params)
38
+ txid = data.delete("txid") || Pix.generate_txid
39
+ apply_aliases!(data)
40
+ data["chave"] = present(data["chave"]) || present(default_key)
41
+ require_chave!(data["chave"])
42
+
43
+ due_date = data.delete("dataDeVencimento") || data.delete("due_date") || data.delete("data_vencimento")
44
+ days_after = data.delete("validadeAposVencimento") ||
45
+ data.delete("days_after_due") ||
46
+ data.delete("validade_apos_vencimento")
47
+ calendario = stringify(data["calendario"] || {})
48
+ calendario["dataDeVencimento"] ||= due_date
49
+ calendario["validadeAposVencimento"] ||= days_after unless days_after.nil?
50
+ data["calendario"] = calendario
51
+ data["valor"] = normalize_valor(data["valor"])
52
+ data["devedor"] = normalize_devedor(data["devedor"]) if data["devedor"]
53
+ [txid, compact(data)]
54
+ end
55
+
56
+ def stringify(value)
57
+ case value
58
+ when Hash
59
+ value.each_with_object({}) { |(key, nested), memo| memo[key.to_s] = stringify(nested) }
60
+ when Array
61
+ value.map { |item| stringify(item) }
62
+ else
63
+ value
64
+ end
65
+ end
66
+
67
+ def apply_aliases!(data)
68
+ TOP_LEVEL_ALIASES.each do |from, to|
69
+ data[to] = data.delete(from) if data.key?(from) && !present(data[to])
70
+ end
71
+ data
72
+ end
73
+
74
+ def normalize_valor(value)
75
+ case value
76
+ when Hash
77
+ money = stringify(value)
78
+ money["original"] = format_money(money["original"]) if money.key?("original")
79
+ money
80
+ else
81
+ { "original" => format_money(value) }
82
+ end
83
+ end
84
+
85
+ def normalize_devedor(value)
86
+ return unless value
87
+
88
+ person = stringify(value)
89
+ tax_id = person.delete("tax_id") || person.delete("cpf_cnpj") || person.delete("document")
90
+ if present(tax_id)
91
+ digits = tax_id.to_s.gsub(/\D/, "")
92
+ if digits.length == 14
93
+ person["cnpj"] ||= digits
94
+ else
95
+ person["cpf"] ||= digits
96
+ end
97
+ end
98
+ person
99
+ end
100
+
101
+ def format_money(value)
102
+ format("%.2f", Float(value))
103
+ end
104
+
105
+ def require_chave!(chave)
106
+ return if present(chave)
107
+
108
+ raise ConfigurationError,
109
+ "A Pix key is required. Set C6Bank pix_key (C6_PIX_KEY) or pass chave: in the request."
110
+ end
111
+
112
+ def present(value)
113
+ str = value.to_s.strip
114
+ str.empty? ? nil : value
115
+ end
116
+
117
+ def compact(value)
118
+ case value
119
+ when Hash
120
+ value.each_with_object({}) do |(key, nested), memo|
121
+ compacted = compact(nested)
122
+ memo[key] = compacted unless compacted.nil?
123
+ end
124
+ when Array
125
+ value.map { |item| compact(item) }
126
+ else
127
+ value
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "pix/payload"
5
+
6
+ module C6Bank
7
+ # Helpers for the BACEN Pix API (cob / cobv) exposed by C6 Bank at `/v2/pix`.
8
+ module Pix
9
+ module_function
10
+
11
+ def generate_txid(length = 32)
12
+ length = length.clamp(26, 35)
13
+ SecureRandom.alphanumeric(length)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module C6Bank
4
+ module Resources
5
+ class Base
6
+ attr_reader :client
7
+
8
+ def initialize(client)
9
+ @client = client
10
+ end
11
+
12
+ protected
13
+
14
+ def request(*args, **kwargs)
15
+ client.request(*args, **kwargs)
16
+ end
17
+
18
+ # Returns the configured path for this resource.
19
+ # Resources override #default_path
20
+ def path
21
+ config_path = begin
22
+ client.config.public_send("#{self.class.name.split("::").last.downcase}_path")
23
+ rescue StandardError
24
+ nil
25
+ end
26
+ config_path || default_path
27
+ end
28
+
29
+ def default_path
30
+ raise NotImplementedError
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module C6Bank
4
+ module Resources
5
+ class Boleto < Base
6
+ def create(params_or_request)
7
+ body = payload_from(params_or_request)
8
+ request(:post, boleto_path, body: body)
9
+ end
10
+
11
+ alias emit create
12
+ alias issue create
13
+
14
+ def find(external_reference_id)
15
+ request(:get, "#{boleto_path}/#{external_reference_id}")
16
+ end
17
+
18
+ def list(params = {})
19
+ request(:get, "#{boleto_path}/list", params: stringify(params))
20
+ end
21
+
22
+ def update(external_reference_id, params = {})
23
+ request(:patch, "#{boleto_path}/#{external_reference_id}", body: stringify(params))
24
+ end
25
+
26
+ def pdf(external_reference_id)
27
+ # C6 sandbox returns 406 for `Accept: application/pdf` alone and
28
+ # serves `Content-Type: application/pdf` when Accept is `*/*` or JSON.
29
+ response = client.request(
30
+ :get,
31
+ "#{boleto_path}/#{external_reference_id}/pdf",
32
+ headers: { "Accept" => "*/*" },
33
+ raw: true
34
+ )
35
+ response.body
36
+ end
37
+
38
+ def cancel(external_reference_id)
39
+ request(:put, "#{boleto_path}/#{external_reference_id}/cancel")
40
+ end
41
+
42
+ alias baixa cancel
43
+
44
+ private
45
+
46
+ def boleto_path
47
+ client.config.boleto_path || "/v2/bank_slips"
48
+ end
49
+
50
+ def payload_from(params_or_request)
51
+ omit_pix = false
52
+ body =
53
+ if params_or_request.is_a?(C6Bank::Boleto::Request)
54
+ omit_pix = params_or_request.omit_pix?
55
+ params_or_request.to_h
56
+ else
57
+ hash = stringify(params_or_request)
58
+ omit_pix = hash["pix"] == false || hash["omit_pix"] == true
59
+ hash.delete("pix") if hash["pix"] == false
60
+ hash.delete("omit_pix")
61
+ hash
62
+ end
63
+
64
+ C6Bank::Boleto::Request.apply_defaults(body, client.config, omit_pix: omit_pix)
65
+ end
66
+
67
+ def stringify(value)
68
+ C6Bank::Pix::Payload.stringify(value)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module C6Bank
4
+ module Resources
5
+ # DDA + scheduled payment groups (agendamento de pagamentos).
6
+ # Covers roteiro section 8.
7
+ class Dda < Base
8
+ # 8.1 - Query open DDA boletos for a customer
9
+ def query_dda(params)
10
+ request(:get, "#{dda_path}/dda", params: params)
11
+ end
12
+
13
+ # 8.2 - Submit a payment group for consultation
14
+ def create_payment_group(params)
15
+ request(:post, "#{dda_path}/grupos", body: params)
16
+ end
17
+
18
+ # Get items of a group
19
+ def get_group_items(group_id)
20
+ request(:get, "#{dda_path}/grupos/#{group_id}/itens")
21
+ end
22
+
23
+ # Remove items (list or single)
24
+ def remove_from_group(group_id, params)
25
+ request(:delete, "#{dda_path}/grupos/#{group_id}/itens", body: params)
26
+ end
27
+
28
+ # Approve/submit group
29
+ def approve_group(group_id)
30
+ request(:post, "#{dda_path}/grupos/#{group_id}/aprovar")
31
+ end
32
+
33
+ private
34
+
35
+ def dda_path
36
+ client.config.dda_path || "/api/v1/pagamentos"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module C6Bank
6
+ module Resources
7
+ # BACEN Pix API (C6 Bank) — `/v2/pix`.
8
+ #
9
+ # Immediate charges (cob), due-date charges (cobv), received Pix, refunds
10
+ # and webhook configuration.
11
+ class Pix < Base
12
+ def create_immediate_charge(params = {})
13
+ txid, body = C6Bank::Pix::Payload.immediate(params, default_key: default_pix_key)
14
+ if txid
15
+ request(:put, "#{pix_path}/cob/#{txid}", body: body)
16
+ else
17
+ request(:post, "#{pix_path}/cob", body: body)
18
+ end
19
+ end
20
+
21
+ def revise_immediate(txid, params = {})
22
+ request(:patch, "#{pix_path}/cob/#{txid}", body: stringify_params(params))
23
+ end
24
+
25
+ def find_immediate(txid)
26
+ request(:get, "#{pix_path}/cob/#{txid}")
27
+ end
28
+
29
+ def list_immediate(params = {})
30
+ request(:get, "#{pix_path}/cob", params: stringify_params(params))
31
+ end
32
+
33
+ def create_due_charge(params = {})
34
+ txid, body = C6Bank::Pix::Payload.due(params, default_key: default_pix_key)
35
+ request(:put, "#{pix_path}/cobv/#{txid}", body: body)
36
+ end
37
+
38
+ def revise_due(txid, params = {})
39
+ request(:patch, "#{pix_path}/cobv/#{txid}", body: stringify_params(params))
40
+ end
41
+
42
+ def find_due(txid)
43
+ request(:get, "#{pix_path}/cobv/#{txid}")
44
+ end
45
+
46
+ def list_due(params = {})
47
+ request(:get, "#{pix_path}/cobv", params: stringify_params(params))
48
+ end
49
+
50
+ def received(e2eid)
51
+ request(:get, "#{pix_path}/pix/#{e2eid}")
52
+ end
53
+
54
+ def list_received(params = {})
55
+ request(:get, "#{pix_path}/pix", params: stringify_params(params))
56
+ end
57
+
58
+ def refund(e2eid, id, params = {})
59
+ request(:put, "#{pix_path}/pix/#{e2eid}/devolucao/#{id}", body: stringify_params(params))
60
+ end
61
+
62
+ def find_refund(e2eid, id)
63
+ request(:get, "#{pix_path}/pix/#{e2eid}/devolucao/#{id}")
64
+ end
65
+
66
+ def configure_webhook(params = {})
67
+ chave = webhook_chave(params)
68
+ url = params[:webhookUrl] || params["webhookUrl"] || params[:webhook_url] ||
69
+ params["webhook_url"] || params[:url] || params["url"]
70
+ request(:put, "#{pix_path}/webhook/#{encode_chave(chave)}", body: { "webhookUrl" => url })
71
+ end
72
+
73
+ def webhook(chave = nil)
74
+ request(:get, "#{pix_path}/webhook/#{encode_chave(webhook_chave(chave: chave))}")
75
+ end
76
+
77
+ def delete_webhook(chave = nil)
78
+ request(:delete, "#{pix_path}/webhook/#{encode_chave(webhook_chave(chave: chave))}")
79
+ end
80
+
81
+ def list_webhooks(params = {})
82
+ request(:get, "#{pix_path}/webhook", params: stringify_params(params))
83
+ end
84
+
85
+ private
86
+
87
+ def pix_path
88
+ client.config.pix_path
89
+ end
90
+
91
+ def default_pix_key
92
+ client.config.pix_key
93
+ end
94
+
95
+ def stringify_params(params)
96
+ C6Bank::Pix::Payload.stringify(params)
97
+ end
98
+
99
+ def webhook_chave(params = {})
100
+ params = { chave: params } unless params.is_a?(Hash)
101
+ chave = params[:chave] || params["chave"] || params[:key] || params["key"] || default_pix_key
102
+ C6Bank::Pix::Payload.require_chave!(chave)
103
+ chave
104
+ end
105
+
106
+ def encode_chave(chave)
107
+ CGI.escapeURIComponent(chave.to_s)
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resources/base"
4
+ require_relative "resources/boleto"
5
+ require_relative "resources/pix"
6
+ require_relative "resources/dda"
7
+
8
+ module C6Bank
9
+ module Resources
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module C6Bank
4
+ VERSION = "0.2.0"
5
+ end
data/lib/c6_bank.rb ADDED
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "c6_bank/version"
4
+ require_relative "c6_bank/errors"
5
+ require_relative "c6_bank/configuration"
6
+ require_relative "c6_bank/client"
7
+ require_relative "c6_bank/pix"
8
+ require_relative "c6_bank/resources"
9
+ require_relative "c6_bank/boleto/request"
10
+
11
+ module C6Bank
12
+ class << self
13
+ attr_writer :client
14
+
15
+ def client
16
+ @client ||= begin
17
+ unless @config_block_ran
18
+ raise ConfigurationError, "C6Bank not configured. Call C6Bank.configure or C6Bank.client= first."
19
+ end
20
+
21
+ build_default_client
22
+ end
23
+ end
24
+
25
+ def configure
26
+ config = Configuration.new
27
+ yield config if block_given?
28
+ config.validate!
29
+ @config_block_ran = true
30
+ @client = Client.new(
31
+ client_id: config.client_id,
32
+ client_secret: config.client_secret,
33
+ certificate: config.ssl_certificate,
34
+ private_key: config.ssl_private_key,
35
+ environment: config.environment,
36
+ base_url: config.base_url,
37
+ auth_base_url: config.auth_base_url,
38
+ token_path: config.token_path,
39
+ boleto_path: config.boleto_path,
40
+ pix_path: config.pix_path,
41
+ dda_path: config.dda_path,
42
+ partner_software_name: config.partner_software_name,
43
+ partner_software_version: config.partner_software_version,
44
+ pix_key: config.pix_key,
45
+ billing_scheme: config.billing_scheme
46
+ )
47
+ end
48
+
49
+ def from_env(**overrides)
50
+ @config_block_ran = true
51
+ @client = Client.from_env(**overrides)
52
+ end
53
+
54
+ def boletos = client.boletos
55
+ def pix = client.pix
56
+ def dda = client.dda
57
+
58
+ def boleto_request(params = {})
59
+ Boleto::Request.new(params)
60
+ end
61
+
62
+ private
63
+
64
+ def build_default_client
65
+ raise ConfigurationError, "No global client configured"
66
+ end
67
+ end
68
+ end
data/sig/c6_bank.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module C6Bank
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end