1# frozen_string_literal: true
2
3require "bigdecimal/util"
4require "delegate"
5
6require_relative "transaction"
7require_relative "trust_level_repo"
8
9class TransactionDeclinedError < StandardError; end
10
11# Error raised when a transaction amount exceeds the maximum allowed limit.
12class AmountTooHighError < TransactionDeclinedError
13 # @return [Numeric] The transaction amount that was too high.
14 attr_reader :amount
15 # @return [Numeric] The maximum amount allowed for the transaction.
16 attr_reader :max_amount
17
18 # Initializes a new AmountTooHighError.
19 # @param amount [Numeric] The transaction amount.
20 # @param max_amount [Numeric] The maximum allowed amount.
21 def initialize(amount, max_amount)
22 @amount = amount
23 @max_amount = max_amount
24 super("Amount $#{amount} exceeds maximum allowed amount of $#{max_amount}")
25 end
26end
27
28# Error raised when a transaction amount is below the minimum required limit.
29class AmountTooLowError < TransactionDeclinedError
30 # @return [Numeric] The transaction amount that was too low.
31 attr_reader :amount
32 # @return [Numeric] The minimum amount required for the transaction.
33 attr_reader :min_amount
34
35 # Initializes a new AmountTooLowError.
36 # @param amount [Numeric] The transaction amount.
37 # @param min_amount [Numeric] The minimum required amount.
38 def initialize(amount, min_amount)
39 @amount = amount
40 @min_amount = min_amount
41 super("Amount $#{amount} is below minimum amount of $#{min_amount}")
42 end
43end
44
45# Error raised when a transaction is declined, potentially due to exceeding
46# decline limits.
47class DeclinedError < TransactionDeclinedError
48 # @return [Integer, nil] The number of declines the customer has.
49 attr_reader :declines
50 # @return [Integer, nil] The maximum number of declines allowed.
51 attr_reader :max_declines
52
53 # Initializes a new DeclinedError.
54 # @param declines [Integer, nil] (nil) The current number of declines.
55 # @param max_declines [Integer, nil] (nil) The maximum allowed declines.
56 def initialize(declines=nil, max_declines=nil)
57 @declines = declines
58 @max_declines = max_declines
59 super("Transaction declined")
60 end
61end
62
63class CreditCardSale
64 def self.create(*args, transaction_class: Transaction, **kwargs)
65 new(*args, **kwargs).sale.then do |response|
66 tx = BraintreeTransaction.build(
67 response,
68 transaction_class: transaction_class
69 )
70 tx.insert.then { tx }
71 end
72 end
73
74 class BraintreeTransaction < SimpleDelegator
75 def self.build(braintree_transaction, transaction_class: Transaction)
76 new(braintree_transaction).to_transaction(transaction_class)
77 end
78
79 def to_transaction(transaction_class)
80 transaction_class.new(
81 customer_id: customer_details.id,
82 transaction_id: id,
83 created_at: created_at,
84 settled_after: created_at + (90 * 24 * 60 * 60),
85 amount: amount,
86 note: "Credit card payment"
87 )
88 end
89 end
90
91 def initialize(
92 customer, amount:, payment_method: nil,
93 trust_repo: TrustLevelRepo.new
94 )
95 @customer = customer
96 @amount = amount
97 @payment_method = payment_method
98 @trust_repo = trust_repo
99 end
100
101 def sale
102 EMPromise.all([validate!, resolve_payment_method]).then { |_, selected|
103 BRAINTREE.transaction.sale(
104 amount: @amount,
105 merchant_account_id: @customer.merchant_account,
106 options: { submit_for_settlement: true },
107 payment_method_token: selected.token
108 )
109 }.then { |response| decline_guard(response) }
110 end
111
112protected
113
114 # Validates the transaction against customer locks, trust level, and decline
115 # history.
116 # @raise [TransactionDeclinedError] if the customer has made too many payments
117 # recently.
118 # @raise [AmountTooHighError] if the amount exceeds the trust level's maximum
119 # top-up amount.
120 # @raise [AmountTooLowError] if the amount is below any applicable minimum.
121 # @raise [DeclinedError] if the transaction is declined due to too many
122 # previous declines or other trust level restrictions.
123 # @return [EMPromise<void>] A promise that resolves if validation passes, or
124 # rejects with an error.
125 def validate!
126 EMPromise.all([
127 REDIS.exists("jmp_customer_credit_card_lock-#{@customer.customer_id}"),
128 @trust_repo.find(@customer), @customer.declines
129 ]).then do |(lock, tl, declines)|
130 raise TransactionDeclinedError, "Too many payments recently" if lock == 1
131
132 tl.validate_credit_card_transaction!(@amount.to_d, declines)
133 end
134 end
135
136 def resolve_payment_method
137 EMPromise.all([
138 @payment_method ||
139 @customer.payment_methods.then(&:default_payment_method)
140 ]).then do |(selected_method)|
141 raise "No valid payment method on file" unless selected_method
142
143 selected_method
144 end
145 end
146
147 def churnbuster_success(response)
148 Churnbuster.new.successful_payment(
149 @customer,
150 @amount,
151 response.transaction.id
152 )
153 end
154
155 def decline_guard(response)
156 if response.success?
157 churnbuster_success(response)
158 REDIS.setex(
159 "jmp_customer_credit_card_lock-#{@customer.customer_id}",
160 60 * 60 * 24, "1"
161 )
162 return response.transaction
163 end
164
165 @customer.mark_decline
166 raise BraintreeFailure, response
167 end
168end
169
170class BraintreeFailure < StandardError
171 attr_reader :response
172
173 def initialize(response)
174 super(response.message)
175 @response = response
176 end
177end