Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 31 additions & 13 deletions lib/fintoc/base_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,20 @@
require 'fintoc/errors'
require 'fintoc/constants'
require 'fintoc/version'
require 'fintoc/jws'

module Fintoc
class BaseClient
include Utils

def initialize(api_key)
def initialize(api_key, jws_private_key: nil)
@api_key = api_key
@user_agent = "fintoc-ruby/#{Fintoc::VERSION}"
@headers = { Authorization: "Bearer #{@api_key}", 'User-Agent': @user_agent }
@link_headers = nil
@link_header_pattern = '<(?<url>.*)>;\s*rel="(?<rel>.*)"'
@default_params = {}
@jws = jws_private_key ? Fintoc::JWS.new(jws_private_key) : nil
end

def get(version: :v1)
Expand All @@ -26,18 +28,18 @@ def delete(version: :v1)
request('delete', version: version)
end

def post(version: :v1)
request('post', version: version)
def post(version: :v1, use_jws: false)
request('post', version: version, use_jws: use_jws)
end

def patch(version: :v1)
request('patch', version: version)
def patch(version: :v1, use_jws: false)
request('patch', version: version, use_jws: use_jws)
end

def request(method, version: :v1)
def request(method, version: :v1, use_jws: false)
proc do |resource, **kwargs|
parameters = params(method, **kwargs)
response = make_request(method, resource, parameters, version: version)
response = make_request(method, resource, parameters, version: version, use_jws: use_jws)
content = JSON.parse(response.body, symbolize_names: true)

if response.status.client_error? || response.status.server_error?
Expand Down Expand Up @@ -78,16 +80,32 @@ def parse_headers(dict, link)
dict
end

def make_request(method, resource, parameters, version: :v1)
def build_url(resource, version: :v1)
base_url = version == :v2 ? Fintoc::Constants::BASE_URL_V2 : Fintoc::Constants::BASE_URL
"#{Fintoc::Constants::SCHEME}#{base_url}#{resource}"
end

def should_use_jws?(method, use_jws)
use_jws && @jws && %w[post patch put].include?(method.downcase)
end

def make_request(method, resource, parameters, version: :v1, use_jws: false)
# this is to handle url returned in the link headers
# I'm sure there is a better and more clever way to solve this
if resource.start_with? 'https'
client.send(method, resource)
else
base_url = version == :v2 ? Fintoc::Constants::BASE_URL_V2 : Fintoc::Constants::BASE_URL
url = "#{Fintoc::Constants::SCHEME}#{base_url}#{resource}"
client.send(method, url, parameters)
return client.send(method, resource)
end

url = build_url(resource, version:)

if should_use_jws?(method, use_jws)
request_body = parameters[:json]&.to_json || ''
jws_signature = @jws.generate_signature(request_body)

return client.headers('Fintoc-JWS-Signature' => jws_signature).send(method, url, parameters)
end

client.send(method, url, parameters)
end

def params(method, **kwargs)
Expand Down
4 changes: 2 additions & 2 deletions lib/fintoc/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ class Client

attr_reader :movements, :transfers

def initialize(api_key)
def initialize(api_key, jws_private_key: nil)
@movements = Fintoc::Movements::Client.new(api_key)
@transfers = Fintoc::Transfers::Client.new(api_key)
@transfers = Fintoc::Transfers::Client.new(api_key, jws_private_key: jws_private_key)
end

# Delegate common methods to maintain backward compatibility
Expand Down
83 changes: 83 additions & 0 deletions lib/fintoc/jws.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
require 'openssl'
require 'json'
require 'base64'
require 'securerandom'

module Fintoc
class JWS
def initialize(private_key)
unless private_key.is_a?(OpenSSL::PKey::RSA)
raise ArgumentError, 'private_key must be an OpenSSL::PKey::RSA instance'
end

@private_key = private_key
end

def generate_signature(raw_body)
body_string = raw_body.is_a?(Hash) ? raw_body.to_json : raw_body.to_s

headers = {
alg: 'RS256',
nonce: SecureRandom.hex(16),
ts: Time.now.to_i,
crit: %w[ts nonce]
}

protected_base64 = base64url_encode(headers.to_json)
payload_base64 = base64url_encode(body_string)
signing_input = "#{protected_base64}.#{payload_base64}"

signature = @private_key.sign(OpenSSL::Digest.new('SHA256'), signing_input)
signature_base64 = base64url_encode(signature)

"#{protected_base64}.#{signature_base64}"
end

def parse_signature(signature)
protected_b64, signature_b64 = signature.split('.')

{
protected_headers: decode_protected_headers(protected_b64),
signature_bytes: decode_signature(signature_b64),
protected_b64: protected_b64,
signature_b64: signature_b64
}
end

def verify_signature(signature, payload)
parsed = parse_signature(signature)

# Reconstruct the signing input
payload_json = payload.is_a?(Hash) ? payload.to_json : payload.to_s
payload_b64 = base64url_encode(payload_json)
signing_input = "#{parsed[:protected_b64]}.#{payload_b64}"

# Verify with public key
public_key = @private_key.public_key
public_key.verify(OpenSSL::Digest.new('SHA256'), parsed[:signature_bytes], signing_input)
end

private

def decode_protected_headers(protected_b64)
padded = add_padding(protected_b64)

protected_json = Base64.urlsafe_decode64(padded)
JSON.parse(protected_json, symbolize_names: true)
end

def decode_signature(signature_b64)
padded = add_padding(signature_b64)

Base64.urlsafe_decode64(padded)
end

def add_padding(b64)
(b64.length % 4).zero? ? b64 : (b64 + ('=' * (4 - (b64.length % 4))))
end

def base64url_encode(data)
Base64.urlsafe_encode64(data).tr('=', '')
end
end
end
2 changes: 2 additions & 0 deletions lib/fintoc/transfers/client/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
require 'fintoc/transfers/client/entities_methods'
require 'fintoc/transfers/client/accounts_methods'
require 'fintoc/transfers/client/account_numbers_methods'
require 'fintoc/transfers/client/transfers_methods'

module Fintoc
module Transfers
class Client < BaseClient
include EntitiesMethods
include AccountsMethods
include AccountNumbersMethods
include TransfersMethods
end
end
end
49 changes: 49 additions & 0 deletions lib/fintoc/transfers/client/transfers_methods.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
require 'fintoc/transfers/resources/transfer'

module Fintoc
module Transfers
module TransfersMethods
def create_transfer(amount:, currency:, account_id:, counterparty:, **params)
data = _create_transfer(amount:, currency:, account_id:, counterparty:, **params)
build_transfer(data)
end

def get_transfer(transfer_id)
data = _get_transfer(transfer_id)
build_transfer(data)
end

def list_transfers(**params)
_list_transfers(**params).map { |data| build_transfer(data) }
end

def return_transfer(transfer_id)
data = _return_transfer(transfer_id)
build_transfer(data)
end

private

def _create_transfer(amount:, currency:, account_id:, counterparty:, **params)
post(version: :v2, use_jws: true)
.call('transfers', amount:, currency:, account_id:, counterparty:, **params)
end

def _get_transfer(transfer_id)
get(version: :v2).call("transfers/#{transfer_id}")
end

def _list_transfers(**params)
get(version: :v2).call('transfers', **params)
end

def _return_transfer(transfer_id)
post(version: :v2, use_jws: true).call('transfers/return', transfer_id:)
end

def build_transfer(data)
Fintoc::Transfers::Transfer.new(**data, client: self)
end
end
end
end
132 changes: 132 additions & 0 deletions lib/fintoc/transfers/resources/transfer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
require 'money'
require 'fintoc/utils'

module Fintoc
module Transfers
class Transfer
include Utils

attr_reader :id, :object, :amount, :currency, :direction, :status, :mode,
:post_date, :transaction_date, :comment, :reference_id, :receipt_url,
:tracking_key, :return_reason, :counterparty, :account_number,
:metadata, :created_at

def initialize( # rubocop:disable Metrics/MethodLength
id:,
object:,
amount:,
currency:,
status:,
mode:,
counterparty:,
direction: nil,
post_date: nil,
transaction_date: nil,
comment: nil,
reference_id: nil,
receipt_url: nil,
tracking_key: nil,
return_reason: nil,
account_number: nil,
metadata: {},
created_at: nil,
client: nil,
**
)
@id = id
@object = object
@amount = amount
@currency = currency
@direction = direction
@status = status
@mode = mode
@post_date = post_date
@transaction_date = transaction_date
@comment = comment
@reference_id = reference_id
@receipt_url = receipt_url
@tracking_key = tracking_key
@return_reason = return_reason
@counterparty = counterparty
@account_number = account_number
@metadata = metadata || {}
@created_at = created_at
@client = client
end

def to_s
amount_str = Money.from_cents(@amount, @currency).format
direction_icon = inbound? ? '⬇️' : '⬆️'
"#{direction_icon} #{amount_str} (#{@id}) - #{@status}"
end

def refresh
fresh_transfer = @client.get_transfer(@id)
refresh_from_transfer(fresh_transfer)
end

def return_transfer
returned_transfer = @client.return_transfer(@id)
refresh_from_transfer(returned_transfer)
end

def pending?
@status == 'pending'
end

def succeeded?
@status == 'succeeded'
end

def failed?
@status == 'failed'
end

def returned?
@status == 'returned'
end

def return_pending?
@status == 'return_pending'
end

def rejected?
@status == 'rejected'
end

def inbound?
@direction == 'inbound'
end

def outbound?
@direction == 'outbound'
end

private

def refresh_from_transfer(transfer) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
raise 'Transfer must be the same instance' unless transfer.id == @id

@object = transfer.object
@amount = transfer.amount
@currency = transfer.currency
@direction = transfer.direction
@status = transfer.status
@mode = transfer.mode
@post_date = transfer.post_date
@transaction_date = transfer.transaction_date
@comment = transfer.comment
@reference_id = transfer.reference_id
@receipt_url = transfer.receipt_url
@tracking_key = transfer.tracking_key
@return_reason = transfer.return_reason
@counterparty = transfer.counterparty
@account_number = transfer.account_number
@metadata = transfer.metadata
@created_at = transfer.created_at

self
end
end
end
end
Loading