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 descriptor: { name: "JMPchat" },
107 options: { submit_for_settlement: true },
108 payment_method_token: selected.token
109 )
110 }.then { |response| decline_guard(response) }
111 end
112
113protected
114
115 # Validates the transaction against customer locks, trust level, and decline
116 # history.
117 # @raise [TransactionDeclinedError] if the customer has made too many payments
118 # recently.
119 # @raise [AmountTooHighError] if the amount exceeds the trust level's maximum
120 # top-up amount.
121 # @raise [AmountTooLowError] if the amount is below any applicable minimum.
122 # @raise [DeclinedError] if the transaction is declined due to too many
123 # previous declines or other trust level restrictions.
124 # @return [EMPromise<void>] A promise that resolves if validation passes, or
125 # rejects with an error.
126 def validate!
127 EMPromise.all([
128 REDIS.exists("jmp_customer_credit_card_lock-#{@customer.customer_id}"),
129 @trust_repo.find(@customer), @customer.declines
130 ]).then do |(lock, tl, declines)|
131 raise TransactionDeclinedError, "Too many payments recently" if lock == 1
132
133 tl.validate_credit_card_transaction!(@amount.to_d, declines)
134 end
135 end
136
137 def resolve_payment_method
138 EMPromise.all([
139 @payment_method ||
140 @customer.payment_methods.then(&:default_payment_method)
141 ]).then do |(selected_method)|
142 raise "No valid payment method on file" unless selected_method
143
144 selected_method
145 end
146 end
147
148 def churnbuster_success(response)
149 Churnbuster.new.successful_payment(
150 @customer,
151 @amount,
152 response.transaction.id
153 )
154 end
155
156 def decline_guard(response)
157 if response.success?
158 churnbuster_success(response)
159 REDIS.setex(
160 "jmp_customer_credit_card_lock-#{@customer.customer_id}",
161 60 * 60 * 24, "1"
162 )
163 return response.transaction
164 end
165
166 @customer.mark_decline
167 raise BraintreeFailure, response
168 end
169end
170
171class BraintreeFailure < StandardError
172 attr_reader :response
173
174 def initialize(response)
175 super(response.message)
176 @response = response
177 end
178end