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 decline limits.
46class DeclinedError < TransactionDeclinedError
47 # @return [Integer, nil] The number of declines the customer has.
48 attr_reader :declines
49 # @return [Integer, nil] The maximum number of declines allowed.
50 attr_reader :max_declines
51
52 # Initializes a new DeclinedError.
53 # @param declines [Integer, nil] The current number of declines.
54 # @param max_declines [Integer, nil] The maximum allowed declines.
55 def initialize(declines, max_declines)
56 @declines = declines
57 @max_declines = max_declines
58 super("Transaction declined")
59 end
60end
61
62class CreditCardSale
63 def self.create(*args, transaction_class: Transaction, **kwargs)
64 new(*args, **kwargs).sale.then do |response|
65 tx = BraintreeTransaction.build(
66 response,
67 transaction_class: transaction_class
68 )
69 tx.insert.then { tx }
70 end
71 end
72
73 class BraintreeTransaction < SimpleDelegator
74 def self.build(braintree_transaction, transaction_class: Transaction)
75 new(braintree_transaction).to_transaction(transaction_class)
76 end
77
78 def to_transaction(transaction_class)
79 transaction_class.new(
80 customer_id: customer_details.id,
81 transaction_id: id,
82 created_at: created_at,
83 settled_after: created_at + (90 * 24 * 60 * 60),
84 amount: amount,
85 note: "Credit card payment"
86 )
87 end
88 end
89
90 def initialize(
91 customer, amount:, payment_method: nil,
92 trust_repo: TrustLevelRepo.new
93 )
94 @customer = customer
95 @amount = amount
96 @payment_method = payment_method
97 @trust_repo = trust_repo
98 end
99
100 def sale
101 EMPromise.all([validate!, resolve_payment_method]).then { |_, selected|
102 BRAINTREE.transaction.sale(
103 amount: @amount,
104 merchant_account_id: @customer.merchant_account,
105 options: { submit_for_settlement: true },
106 payment_method_token: selected.token
107 )
108 }.then { |response| decline_guard(response) }
109 end
110
111protected
112
113 # Validates the transaction against customer locks, trust level, and decline history.
114 # @raise [TransactionDeclinedError] if the customer has made too many payments recently.
115 # @raise [AmountTooHighError] if the amount exceeds the trust level's maximum top-up amount.
116 # @raise [AmountTooLowError] if the amount is below any applicable minimum.
117 # @raise [DeclinedError] if the transaction is declined due to too many previous declines or other trust level restrictions.
118 # @return [EMPromise<void>] A promise that resolves if validation passes, or rejects with an error.
119 def validate!
120 EMPromise.all([
121 REDIS.exists("jmp_customer_credit_card_lock-#{@customer.customer_id}"),
122 @trust_repo.find(@customer), @customer.declines
123 ]).then do |(lock, tl, declines)|
124 raise TransactionDeclinedError, "Too many payments recently" if lock == 1
125
126 tl.validate_credit_card_transaction!(@amount.to_d, declines)
127 end
128 end
129
130 def resolve_payment_method
131 EMPromise.all([
132 @payment_method ||
133 @customer.payment_methods.then(&:default_payment_method)
134 ]).then do |(selected_method)|
135 raise "No valid payment method on file" unless selected_method
136
137 selected_method
138 end
139 end
140
141 def churnbuster_success(response)
142 Churnbuster.new.successful_payment(
143 @customer,
144 @amount,
145 response.transaction.id
146 )
147 end
148
149 def decline_guard(response)
150 if response.success?
151 churnbuster_success(response)
152 REDIS.setex(
153 "jmp_customer_credit_card_lock-#{@customer.customer_id}",
154 60 * 60 * 24, "1"
155 )
156 return response.transaction
157 end
158
159 @customer.mark_decline
160 raise BraintreeFailure, response
161 end
162end
163
164class BraintreeFailure < StandardError
165 attr_reader :response
166
167 def initialize(response)
168 super(response.message)
169 @response = response
170 end
171end