fintoc 0.1.0 → 1.1.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.github/pull_request_template.md +46 -0
  3. data/.github/workflows/ci.yml +46 -0
  4. data/.rubocop.yml +14 -7
  5. data/CHANGELOG.md +53 -1
  6. data/Gemfile +20 -1
  7. data/Gemfile.lock +237 -61
  8. data/README.md +404 -42
  9. data/Rakefile +3 -3
  10. data/fintoc.gemspec +3 -7
  11. data/lib/config/initializers/money.rb +5 -0
  12. data/lib/fintoc/base_client.rb +161 -0
  13. data/lib/fintoc/client.rb +14 -123
  14. data/lib/fintoc/constants.rb +4 -3
  15. data/lib/fintoc/errors.rb +139 -15
  16. data/lib/fintoc/jws.rb +83 -0
  17. data/lib/fintoc/utils.rb +2 -2
  18. data/lib/fintoc/v1/client/client.rb +12 -0
  19. data/lib/fintoc/v1/managers/links_manager.rb +46 -0
  20. data/lib/fintoc/v1/resources/account.rb +95 -0
  21. data/lib/fintoc/v1/resources/balance.rb +27 -0
  22. data/lib/fintoc/v1/resources/institution.rb +21 -0
  23. data/lib/fintoc/v1/resources/link.rb +85 -0
  24. data/lib/fintoc/v1/resources/movement.rb +62 -0
  25. data/lib/fintoc/v1/resources/transfer_account.rb +24 -0
  26. data/lib/fintoc/v2/client/client.rb +37 -0
  27. data/lib/fintoc/v2/managers/account_numbers_manager.rb +64 -0
  28. data/lib/fintoc/v2/managers/account_verifications_manager.rb +46 -0
  29. data/lib/fintoc/v2/managers/accounts_manager.rb +55 -0
  30. data/lib/fintoc/v2/managers/entities_manager.rb +36 -0
  31. data/lib/fintoc/v2/managers/simulate_manager.rb +32 -0
  32. data/lib/fintoc/v2/managers/transfers_manager.rb +61 -0
  33. data/lib/fintoc/v2/resources/account.rb +106 -0
  34. data/lib/fintoc/v2/resources/account_number.rb +106 -0
  35. data/lib/fintoc/v2/resources/account_verification.rb +73 -0
  36. data/lib/fintoc/v2/resources/entity.rb +51 -0
  37. data/lib/fintoc/v2/resources/transfer.rb +131 -0
  38. data/lib/fintoc/version.rb +1 -1
  39. data/lib/fintoc/webhook_signature.rb +73 -0
  40. data/lib/fintoc.rb +3 -0
  41. data/lib/tasks/simplecov_config.rb +19 -0
  42. metadata +35 -83
  43. data/lib/fintoc/resources/account.rb +0 -84
  44. data/lib/fintoc/resources/balance.rb +0 -24
  45. data/lib/fintoc/resources/institution.rb +0 -18
  46. data/lib/fintoc/resources/link.rb +0 -83
  47. data/lib/fintoc/resources/movement.rb +0 -55
  48. data/lib/fintoc/resources/transfer_account.rb +0 -22
@@ -0,0 +1,106 @@
1
+ require 'money'
2
+
3
+ module Fintoc
4
+ module V2
5
+ class Account
6
+ attr_reader :id, :object, :mode, :description, :available_balance, :currency,
7
+ :is_root, :root_account_number_id, :root_account_number, :status, :entity
8
+
9
+ def initialize(
10
+ id:,
11
+ object:,
12
+ mode:,
13
+ description:,
14
+ available_balance:,
15
+ currency:,
16
+ is_root:,
17
+ root_account_number_id:,
18
+ root_account_number:,
19
+ status:,
20
+ entity:,
21
+ client: nil,
22
+ **
23
+ )
24
+ @id = id
25
+ @object = object
26
+ @mode = mode
27
+ @description = description
28
+ @available_balance = available_balance
29
+ @currency = currency
30
+ @is_root = is_root
31
+ @root_account_number_id = root_account_number_id
32
+ @root_account_number = root_account_number
33
+ @status = status
34
+ @entity = entity
35
+ @client = client
36
+ end
37
+
38
+ def to_s
39
+ "💰 #{@description} (#{@id}) - #{Money.from_cents(@available_balance, @currency).format}"
40
+ end
41
+
42
+ def refresh
43
+ fresh_account = @client.accounts.get(@id)
44
+ refresh_from_account(fresh_account)
45
+ end
46
+
47
+ def update(description: nil, idempotency_key: nil)
48
+ params = {}
49
+ params[:description] = description if description
50
+
51
+ updated_account = @client.accounts.update(@id, idempotency_key:, **params)
52
+ refresh_from_account(updated_account)
53
+ end
54
+
55
+ def active?
56
+ @status == 'active'
57
+ end
58
+
59
+ def blocked?
60
+ @status == 'blocked'
61
+ end
62
+
63
+ def closed?
64
+ @status == 'closed'
65
+ end
66
+
67
+ def test_mode?
68
+ @mode == 'test'
69
+ end
70
+
71
+ def simulate_receive_transfer(amount:, idempotency_key: nil)
72
+ unless test_mode?
73
+ raise Fintoc::Errors::InvalidRequestError, 'Simulation is only available in test mode'
74
+ end
75
+
76
+ @client.simulate.receive_transfer(
77
+ account_number_id: @root_account_number_id,
78
+ amount:,
79
+ currency: @currency,
80
+ idempotency_key:
81
+ )
82
+ end
83
+
84
+ private
85
+
86
+ def refresh_from_account(account)
87
+ unless account.id == @id
88
+ raise ArgumentError, 'Account must be the same instance'
89
+ end
90
+
91
+ @object = account.object
92
+ @mode = account.mode
93
+ @description = account.description
94
+ @available_balance = account.available_balance
95
+ @currency = account.currency
96
+ @is_root = account.is_root
97
+ @root_account_number_id = account.root_account_number_id
98
+ @root_account_number = account.root_account_number
99
+ @status = account.status
100
+ @entity = account.entity
101
+
102
+ self
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,106 @@
1
+ module Fintoc
2
+ module V2
3
+ class AccountNumber
4
+ attr_reader :id, :object, :description, :number, :created_at, :updated_at,
5
+ :mode, :status, :is_root, :account_id, :metadata
6
+
7
+ def initialize(
8
+ id:,
9
+ object:,
10
+ description:,
11
+ number:,
12
+ created_at:,
13
+ updated_at:,
14
+ mode:,
15
+ status:,
16
+ is_root:,
17
+ account_id:,
18
+ metadata:,
19
+ client: nil,
20
+ **
21
+ )
22
+ @id = id
23
+ @object = object
24
+ @description = description
25
+ @number = number
26
+ @created_at = created_at
27
+ @updated_at = updated_at
28
+ @mode = mode
29
+ @status = status
30
+ @is_root = is_root
31
+ @account_id = account_id
32
+ @metadata = metadata
33
+ @client = client
34
+ end
35
+
36
+ def to_s
37
+ "🔢 #{@number} (#{@id}) - #{@description}"
38
+ end
39
+
40
+ def refresh
41
+ fresh_account_number = @client.account_numbers.get(@id)
42
+ refresh_from_account_number(fresh_account_number)
43
+ end
44
+
45
+ def update(description: nil, status: nil, metadata: nil, idempotency_key: nil)
46
+ params = {}
47
+ params[:description] = description if description
48
+ params[:status] = status if status
49
+ params[:metadata] = metadata if metadata
50
+
51
+ updated_account_number = @client.account_numbers.update(@id, idempotency_key:, **params)
52
+ refresh_from_account_number(updated_account_number)
53
+ end
54
+
55
+ def enabled?
56
+ @status == 'enabled'
57
+ end
58
+
59
+ def disabled?
60
+ @status == 'disabled'
61
+ end
62
+
63
+ def root?
64
+ @is_root
65
+ end
66
+
67
+ def test_mode?
68
+ @mode == 'test'
69
+ end
70
+
71
+ def simulate_receive_transfer(amount:, currency: 'MXN', idempotency_key: nil)
72
+ unless test_mode?
73
+ raise Fintoc::Errors::InvalidRequestError, 'Simulation is only available in test mode'
74
+ end
75
+
76
+ @client.simulate.receive_transfer(
77
+ account_number_id: @id,
78
+ amount:,
79
+ currency:,
80
+ idempotency_key:
81
+ )
82
+ end
83
+
84
+ private
85
+
86
+ def refresh_from_account_number(account_number)
87
+ unless account_number.id == @id
88
+ raise ArgumentError, 'AccountNumber must be the same instance'
89
+ end
90
+
91
+ @object = account_number.object
92
+ @description = account_number.description
93
+ @number = account_number.number
94
+ @created_at = account_number.created_at
95
+ @updated_at = account_number.updated_at
96
+ @mode = account_number.mode
97
+ @status = account_number.status
98
+ @is_root = account_number.is_root
99
+ @account_id = account_number.account_id
100
+ @metadata = account_number.metadata
101
+
102
+ self
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,73 @@
1
+ module Fintoc
2
+ module V2
3
+ class AccountVerification
4
+ attr_reader :id, :object, :status, :reason, :transfer_id, :counterparty, :mode, :receipt_url,
5
+ :transaction_date
6
+
7
+ def initialize(
8
+ id:,
9
+ object:,
10
+ status:,
11
+ reason:,
12
+ transfer_id:,
13
+ counterparty:,
14
+ mode:,
15
+ receipt_url:,
16
+ transaction_date:,
17
+ client: nil,
18
+ **
19
+ )
20
+ @id = id
21
+ @object = object
22
+ @status = status
23
+ @reason = reason
24
+ @transfer_id = transfer_id
25
+ @counterparty = counterparty
26
+ @mode = mode
27
+ @receipt_url = receipt_url
28
+ @transaction_date = transaction_date
29
+ @client = client
30
+ end
31
+
32
+ def to_s
33
+ "🔍 Account Verification (#{@id}) - #{@status}"
34
+ end
35
+
36
+ def refresh
37
+ fresh_verification = @client.account_verifications.get(@id)
38
+ refresh_from_verification(fresh_verification)
39
+ end
40
+
41
+ def pending?
42
+ @status == 'pending'
43
+ end
44
+
45
+ def succeeded?
46
+ @status == 'succeeded'
47
+ end
48
+
49
+ def failed?
50
+ @status == 'failed'
51
+ end
52
+
53
+ private
54
+
55
+ def refresh_from_verification(verification)
56
+ unless verification.id == @id
57
+ raise ArgumentError, 'Account verification must be the same instance'
58
+ end
59
+
60
+ @object = verification.object
61
+ @status = verification.status
62
+ @reason = verification.reason
63
+ @transfer_id = verification.transfer_id
64
+ @counterparty = verification.counterparty
65
+ @mode = verification.mode
66
+ @receipt_url = verification.receipt_url
67
+ @transaction_date = verification.transaction_date
68
+
69
+ self
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,51 @@
1
+ module Fintoc
2
+ module V2
3
+ class Entity
4
+ attr_reader :object, :mode, :id, :holder_name, :holder_id, :is_root
5
+
6
+ def initialize(
7
+ object:,
8
+ mode:,
9
+ id:,
10
+ holder_name:,
11
+ holder_id:,
12
+ is_root:,
13
+ client: nil,
14
+ **
15
+ )
16
+ @object = object
17
+ @mode = mode
18
+ @id = id
19
+ @holder_name = holder_name
20
+ @holder_id = holder_id
21
+ @is_root = is_root
22
+ @client = client
23
+ end
24
+
25
+ def to_s
26
+ "🏢 #{@holder_name} (#{@id})"
27
+ end
28
+
29
+ def refresh
30
+ fresh_entity = @client.entities.get(@id)
31
+ refresh_from_entity(fresh_entity)
32
+ end
33
+
34
+ private
35
+
36
+ def refresh_from_entity(entity)
37
+ unless entity.id == @id
38
+ raise ArgumentError, 'Entity must be the same instance'
39
+ end
40
+
41
+ @object = entity.object
42
+ @mode = entity.mode
43
+ @holder_name = entity.holder_name
44
+ @holder_id = entity.holder_id
45
+ @is_root = entity.is_root
46
+
47
+ self
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,131 @@
1
+ require 'money'
2
+
3
+ module Fintoc
4
+ module V2
5
+ class Transfer
6
+ attr_reader :id, :object, :amount, :currency, :direction, :status, :mode,
7
+ :post_date, :transaction_date, :comment, :reference_id, :receipt_url,
8
+ :tracking_key, :return_reason, :counterparty, :account_number,
9
+ :metadata, :created_at
10
+
11
+ def initialize( # rubocop:disable Metrics/MethodLength
12
+ id:,
13
+ object:,
14
+ amount:,
15
+ currency:,
16
+ status:,
17
+ mode:,
18
+ counterparty:,
19
+ direction: nil,
20
+ post_date: nil,
21
+ transaction_date: nil,
22
+ comment: nil,
23
+ reference_id: nil,
24
+ receipt_url: nil,
25
+ tracking_key: nil,
26
+ return_reason: nil,
27
+ account_number: nil,
28
+ metadata: {},
29
+ created_at: nil,
30
+ client: nil,
31
+ **
32
+ )
33
+ @id = id
34
+ @object = object
35
+ @amount = amount
36
+ @currency = currency
37
+ @direction = direction
38
+ @status = status
39
+ @mode = mode
40
+ @post_date = post_date
41
+ @transaction_date = transaction_date
42
+ @comment = comment
43
+ @reference_id = reference_id
44
+ @receipt_url = receipt_url
45
+ @tracking_key = tracking_key
46
+ @return_reason = return_reason
47
+ @counterparty = counterparty
48
+ @account_number = account_number
49
+ @metadata = metadata || {}
50
+ @created_at = created_at
51
+ @client = client
52
+ end
53
+
54
+ def to_s
55
+ amount_str = Money.from_cents(@amount, @currency).format
56
+ direction_icon = inbound? ? '⬇️' : '⬆️'
57
+ "#{direction_icon} #{amount_str} (#{@id}) - #{@status}"
58
+ end
59
+
60
+ def refresh
61
+ fresh_transfer = @client.transfers.get(@id)
62
+ refresh_from_transfer(fresh_transfer)
63
+ end
64
+
65
+ def return_transfer(idempotency_key: nil)
66
+ returned_transfer = @client.transfers.return(@id, idempotency_key:)
67
+ refresh_from_transfer(returned_transfer)
68
+ end
69
+
70
+ def pending?
71
+ @status == 'pending'
72
+ end
73
+
74
+ def succeeded?
75
+ @status == 'succeeded'
76
+ end
77
+
78
+ def failed?
79
+ @status == 'failed'
80
+ end
81
+
82
+ def returned?
83
+ @status == 'returned'
84
+ end
85
+
86
+ def return_pending?
87
+ @status == 'return_pending'
88
+ end
89
+
90
+ def rejected?
91
+ @status == 'rejected'
92
+ end
93
+
94
+ def inbound?
95
+ @direction == 'inbound'
96
+ end
97
+
98
+ def outbound?
99
+ @direction == 'outbound'
100
+ end
101
+
102
+ private
103
+
104
+ def refresh_from_transfer(transfer) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
105
+ unless transfer.id == @id
106
+ raise ArgumentError, 'Transfer must be the same instance'
107
+ end
108
+
109
+ @object = transfer.object
110
+ @amount = transfer.amount
111
+ @currency = transfer.currency
112
+ @direction = transfer.direction
113
+ @status = transfer.status
114
+ @mode = transfer.mode
115
+ @post_date = transfer.post_date
116
+ @transaction_date = transfer.transaction_date
117
+ @comment = transfer.comment
118
+ @reference_id = transfer.reference_id
119
+ @receipt_url = transfer.receipt_url
120
+ @tracking_key = transfer.tracking_key
121
+ @return_reason = transfer.return_reason
122
+ @counterparty = transfer.counterparty
123
+ @account_number = transfer.account_number
124
+ @metadata = transfer.metadata
125
+ @created_at = transfer.created_at
126
+
127
+ self
128
+ end
129
+ end
130
+ end
131
+ end
@@ -1,3 +1,3 @@
1
1
  module Fintoc
2
- VERSION = "0.1.0"
2
+ VERSION = '1.1.0'
3
3
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'time'
5
+ require 'fintoc/errors'
6
+
7
+ module Fintoc
8
+ class WebhookSignature
9
+ EXPECTED_SCHEME = 'v1'
10
+ DEFAULT_TOLERANCE = 300 # 5 minutes
11
+
12
+ class << self
13
+ def verify_header(payload, header, secret, tolerance = DEFAULT_TOLERANCE) # rubocop:disable Naming/PredicateMethod
14
+ timestamp, signatures = parse_header(header)
15
+
16
+ verify_timestamp(timestamp, tolerance) if tolerance
17
+
18
+ expected_signature = compute_signature(payload, timestamp, secret)
19
+ signature = signatures[EXPECTED_SCHEME]
20
+
21
+ if signature.nil? || signature.empty? # rubocop:disable Rails/Blank
22
+ raise Fintoc::Errors::WebhookSignatureError.new("No #{EXPECTED_SCHEME} signature found")
23
+ end
24
+
25
+ unless same_signatures?(signature, expected_signature)
26
+ raise Fintoc::Errors::WebhookSignatureError.new('Signature mismatch')
27
+ end
28
+
29
+ true
30
+ end
31
+
32
+ def compute_signature(payload, timestamp, secret)
33
+ signed_payload = "#{timestamp}.#{payload}"
34
+ OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), secret, signed_payload)
35
+ end
36
+
37
+ private
38
+
39
+ def parse_header(header)
40
+ elements = header.split(',').map(&:strip)
41
+ pairs = elements.map { |element| element.split('=', 2).map(&:strip) }
42
+ pairs = pairs.to_h
43
+
44
+ if pairs['t'].nil? || pairs['t'].empty? # rubocop:disable Rails/Blank
45
+ raise Fintoc::Errors::WebhookSignatureError.new('Missing timestamp in header')
46
+ end
47
+
48
+ timestamp = pairs['t'].to_i
49
+ signatures = pairs.except('t')
50
+
51
+ [timestamp, signatures]
52
+ rescue StandardError => e
53
+ raise Fintoc::Errors::WebhookSignatureError.new(
54
+ 'Unable to extract timestamp and signatures from header'
55
+ ), cause: e
56
+ end
57
+
58
+ def verify_timestamp(timestamp, tolerance)
59
+ now = Time.now.to_i
60
+
61
+ if timestamp < (now - tolerance)
62
+ raise Fintoc::Errors::WebhookSignatureError.new(
63
+ "Timestamp outside the tolerance zone (#{timestamp})"
64
+ )
65
+ end
66
+ end
67
+
68
+ def same_signatures?(signature, expected_signature)
69
+ OpenSSL.secure_compare(expected_signature, signature)
70
+ end
71
+ end
72
+ end
73
+ end
data/lib/fintoc.rb CHANGED
@@ -1,6 +1,9 @@
1
1
  require 'fintoc/version'
2
2
  require 'fintoc/errors'
3
3
  require 'fintoc/client'
4
+ require 'fintoc/webhook_signature'
5
+
6
+ require 'config/initializers/money'
4
7
 
5
8
  module Fintoc
6
9
  end
@@ -0,0 +1,19 @@
1
+ if ENV['COVERAGE'] == 'true'
2
+ require 'simplecov'
3
+ require 'simplecov_text_formatter'
4
+ require 'simplecov_linter_formatter'
5
+
6
+ SimpleCov.start do
7
+ formatter SimpleCov::Formatter::MultiFormatter.new([SimpleCov::Formatter::HTMLFormatter])
8
+
9
+ add_filter '/vendor/'
10
+ add_filter '/lib/fintoc.rb'
11
+ add_filter '/lib/fintoc/version.rb'
12
+ add_filter '/lib/tasks/simplecov_config.rb'
13
+
14
+ track_files 'lib/**/*.rb'
15
+
16
+ minimum_coverage 100
17
+ minimum_coverage_by_file 100
18
+ end
19
+ end