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