# frozen_string_literal: true require "braintree" require "bigdecimal/util" require "date" require "delegate" require "dhall" require "forwardable" require "pg" require "redis" require "roda" require "uri" if ENV["RACK_ENV"] == "development" require "pry-rescue" use PryRescue::Rack end require_relative "lib/auto_top_up_repo" require_relative "lib/customer" require_relative "lib/three_d_secure_repo" require_relative "lib/electrum" require_relative "lib/transaction" require "sentry-ruby" Sentry.init do |config| config.traces_sample_rate = 0.01 end use Sentry::Rack::CaptureExceptions REDIS = Redis.new PLANS = Dhall.load("env:PLANS").sync BRAINTREE_CONFIG = Dhall.load("env:BRAINTREE_CONFIG").sync ELECTRUM = Electrum.new( **Dhall::Coder.load("env:ELECTRUM_CONFIG", transform_keys: :to_sym) ) DB = PG.connect(dbname: "jmp") DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB) DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB) class CreditCardGateway class ErrorResult < StandardError def self.for(result) if result.verification&.status == "gateway_rejected" && result.verification&.gateway_rejection_reason == "cvv" new("fieldInvalidForCvv") else new(result.message) end end end def initialize(jid, customer_id, antifraud) @jid = jid @customer_id = customer_id @antifraud = antifraud @gateway = Braintree::Gateway.new( environment: BRAINTREE_CONFIG[:environment].to_s, merchant_id: BRAINTREE_CONFIG[:merchant_id].to_s, public_key: BRAINTREE_CONFIG[:public_key].to_s, private_key: BRAINTREE_CONFIG[:private_key].to_s ) end def check_customer_id(cid) return cid unless ENV["RACK_ENV"] == "production" raise "customer_id does not match" unless @customer_id == cid cid end def customer_id customer_id = Customer.new(nil, @jid).customer_id return customer_id if check_customer_id(customer_id) result = @gateway.customer.create raise "Braintree customer create failed" unless result.success? @customer_id = result.customer.id Customer.new(@customer_id, @jid).save! @customer_id end def customer_plan name = DB.exec_params(<<~SQL, [customer_id]).first&.[]("plan_name") SELECT plan_name FROM customer_plans WHERE customer_id=$1 LIMIT 1 SQL PLANS.find { |plan| plan[:name].to_s == name } end def merchant_account plan = customer_plan return unless plan BRAINTREE_CONFIG[:merchant_accounts][plan[:currency]] end def client_token kwargs = {} kwargs[:merchant_account_id] = merchant_account.to_s if merchant_account @gateway.client_token.generate(customer_id: customer_id, **kwargs) end def payment_methods? !@gateway.customer.find(customer_id).payment_methods.empty? end def antifraud return if REDIS.exists?("jmp_antifraud_bypass-#{customer_id}") REDIS.mget(@antifraud.map { |k| "jmp_antifraud-#{k}" }).find do |anti| anti.to_i > 2 end && Braintree::ErrorResult.new( @gateway, errors: {}, message: "Please contact support" ) end def with_antifraud result = antifraud || yield return result if result.success? @antifraud.each do |k| REDIS.incr("jmp_antifraud-#{k}") REDIS.expire("jmp_antifraud-#{k}", 60 * 60 * 24) end raise ErrorResult.for(result) end def sale(nonce, amount) with_antifraud do @gateway.transaction.sale( customer_id: customer_id, payment_method_nonce: nonce, amount: amount, merchant_account_id: merchant_account.to_s, options: { store_in_vault_on_success: true, submit_for_settlement: true } ) end end def default_method(nonce) with_antifraud do @gateway.payment_method.create( customer_id: customer_id, payment_method_nonce: nonce, options: { verify_card: true, make_default: true, verification_merchant_account_id: merchant_account.to_s } ) end end def remove_method(token) @gateway.payment_method.delete(token) end end class UnknownTransactions def self.from(customer_id, address, tx_hashes) self.for( customer_id, fetch_rows_for(address, tx_hashes).map { |row| row["transaction_id"] } ) end def self.fetch_rows_for(address, tx_hashes) values = tx_hashes.map { |tx_hash| "('#{DB.escape_string(tx_hash)}/#{DB.escape_string(address)}')" } return [] if values.empty? DB.exec_params(<<-SQL) SELECT transaction_id FROM (VALUES #{values.join(',')}) AS t(transaction_id) LEFT JOIN transactions USING (transaction_id) WHERE transactions.transaction_id IS NULL SQL end def self.for(customer_id, transaction_ids) transaction_ids.empty? ? None.new : new(customer_id, transaction_ids) end def initialize(customer_id, transaction_ids) @customer_id = customer_id @transaction_ids = transaction_ids end def enqueue! REDIS.hset( "pending_btc_transactions", *@transaction_ids.flat_map { |txid| [txid, @customer_id] } ) end class None def enqueue!; end end end class CardVault def self.for(gateway, nonce, amount=nil) if amount&.positive? CardDeposit.new(gateway, nonce, amount) else new(gateway, nonce) end end def initialize(gateway, nonce) @gateway = gateway @nonce = nonce end def call(auto_top_up_amount) result = vault! ThreeDSecureRepo.new.put_from_result(result) AutoTopUpRepo.new.put( @gateway.customer_id, auto_top_up_amount ) result end def vault! @gateway.default_method(@nonce) end class CardDeposit < self def initialize(gateway, nonce, amount) super(gateway, nonce) @amount = amount return unless @amount < 15 || @amount > 35 raise CreditCardGateway::ErrorResult, "amount too low or too high" end def call(*) result = super Transaction.new( @gateway.customer_id, result.transaction.id, @amount, "Credit card payment" ).save result end def vault! @gateway.sale(@nonce, @amount) end end end class JmpPay < Roda SENTRY_DSN = ENV["SENTRY_DSN"] && URI(ENV["SENTRY_DSN"]) plugin :render, engine: "slim" plugin :cookies, path: "/" plugin :common_logger, $stdout extend Forwardable def_delegators :request, :params def redis_key_btc_addresses "jmp_customer_btc_addresses-#{params['customer_id']}" end def verify_address_customer_id(r) return if REDIS.sismember(redis_key_btc_addresses, params["address"]) warn "Address and customer_id do not match" r.halt([ 403, { "Content-Type" => "text/plain" }, "Address and customer_id do not match" ]) end route do |r| r.on "electrum_notify" do verify_address_customer_id(r) UnknownTransactions.from( params["customer_id"], params["address"], ELECTRUM .getaddresshistory(params["address"]) .map { |item| item["tx_hash"] } ).enqueue! "OK" end r.on :jid do |jid| Sentry.set_user(id: params["customer_id"], jid: jid) atfd = r.cookies["atfd"] || SecureRandom.uuid one_year = 60 * 60 * 24 * 365 response.set_cookie( "atfd", value: atfd, expires: Time.now + one_year ) params.delete("atfd") if params["atfd"].to_s == "" antifrauds = [atfd, r.ip, params["atfd"]].compact.uniq customer_id = params["customer_id"] gateway = CreditCardGateway.new(jid, customer_id, antifrauds) topup = AutoTopUpRepo.new r.on "credit_cards" do r.get do if gateway.antifraud return view( :message, locals: { message: "Please contact support" } ) end view( "credit_cards", locals: { token: gateway.client_token, customer_id: gateway.customer_id, antifraud: atfd, auto_top_up: topup.find(gateway.customer_id) || (gateway.payment_methods? ? "" : "15") } ) end r.post do CardVault .for( gateway, params["braintree_nonce"], params["amount"].to_d ).call(params["auto_top_up_amount"].to_i) "OK" rescue ThreeDSecureRepo::Failed gateway.remove_method($!.message) response.status = 400 "hostedFieldsFieldsInvalidError" rescue CreditCardGateway::ErrorResult response.status = 400 $!.message end end end end end run JmpPay.freeze.app