# frozen_string_literal: true require "braintree" require "date" require "delegate" require "dhall" require "pg" require "redis" require "roda" require "uri" if ENV["RACK_ENV"] == "development" require "pry-rescue" use PryRescue::Rack end require_relative "lib/electrum" require_relative "lib/transaction" require "sentry-ruby" Sentry.init do |config| config.traces_sample_rate = 1 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 Plan def self.for(plan_name) new(PLANS.find { |p| p[:name].to_s == plan_name }) end def initialize(plan) @plan = plan end def price(months=1) (BigDecimal.new(@plan[:monthly_price].to_i) * months) / 10000 end def currency @plan[:currency].to_s.to_sym end def merchant_account BRAINTREE_CONFIG[:merchant_accounts][currency] end def self.active?(customer_id) DB.exec_params(<<~SQL, [customer_id]).first&.[]("count").to_i > 0 SELECT count(1) AS count FROM customer_plans WHERE customer_id=$1 AND expires_at > NOW() SQL end def bill_plan(customer_id) DB.transaction do charge_for_plan(customer_id) unless activate_plan_starting_now(customer_id) add_one_month_to_current_plan(customer_id) end end end def activate_plan_starting_now(customer_id) DB.exec(<<~SQL, [customer_id, @plan[:name]]).cmd_tuples.positive? INSERT INTO plan_log (customer_id, plan_name, date_range) VALUES ($1, $2, tsrange(LOCALTIMESTAMP, LOCALTIMESTAMP + '1 month')) ON CONFLICT DO NOTHING SQL end protected def charge_for_plan(customer_id) params = [ customer_id, "#{customer_id}-bill-#{@plan[:name]}-at-#{Time.now.to_i}", -price ] DB.exec(<<~SQL, params) INSERT INTO transactions (customer_id, transaction_id, created_at, amount) VALUES ($1, $2, LOCALTIMESTAMP, $3) SQL end def add_one_month_to_current_plan(customer_id) DB.exec(<<~SQL, [customer_id]) UPDATE plan_log SET date_range=range_merge( date_range, tsrange( LOCALTIMESTAMP, GREATEST(upper(date_range), LOCALTIMESTAMP) + '1 month' ) ) WHERE customer_id=$1 AND date_range && tsrange(LOCALTIMESTAMP, LOCALTIMESTAMP + '1 month') SQL end end class CreditCardGateway def initialize(jid, customer_id=nil) @jid = jid @customer_id = customer_id @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 = REDIS.get(redis_key_jid) 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 save_customer_id! end def save_customer_id! unless REDIS.set(redis_key_jid, @customer_id) == "OK" raise "Saving new jid,customer to redis failed" end unless REDIS.set(redis_key_customer_id, @jid) == "OK" raise "Saving new customer,jid to redis failed" end @customer_id end def client_token @gateway.client_token.generate(customer_id: customer_id) end def payment_methods? !@gateway.customer.find(customer_id).payment_methods.empty? end def default_payment_method=(nonce) @gateway.payment_method.create( customer_id: customer_id, payment_method_nonce: nonce, options: { make_default: true } ) end def decline_guard(ip) customer_declines, ip_declines = REDIS.mget( "jmp_pay_decline-#{@customer_id}", "jmp_pay_decline-#{ip}" ) customer_declines.to_i < 2 && ip_declines.to_i < 4 end def sale(ip:, **kwargs) return nil unless decline_guard(ip) tx = Transaction.sale(**kwargs) return tx if tx REDIS.incr("jmp_pay_decline-#{@customer_id}") REDIS.expire("jmp_pay_decline-#{@customer_id}", 60 * 60 * 24) REDIS.incr("jmp_pay_decline-#{ip}") REDIS.expire("jmp_pay_decline-#{ip}", 60 * 60 * 24) nil end def buy_plan(plan_name, nonce, ip) plan = Plan.for(plan_name) sale( ip: ip, amount: plan.price(5), payment_method_nonce: nonce, merchant_account_id: plan.merchant_account, options: {submit_for_settlement: true} )&.insert && plan.bill_plan(@customer_id) end protected def redis_key_jid "jmp_customer_id-#{@jid}" end def redis_key_customer_id "jmp_customer_jid-#{@customer_id}" 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 do |tx_hash| "('#{DB.escape_string(tx_hash)}/#{DB.escape_string(address)}')" end 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 # This class must contain all of the routes because of how the DSL works # rubocop:disable Metrics/ClassLength class JmpPay < Roda SENTRY_DSN = ENV["SENTRY_DSN"] && URI(ENV["SENTRY_DSN"]) plugin :render, engine: "slim" plugin :common_logger, $stdout def redis_key_btc_addresses "jmp_customer_btc_addresses-#{request.params['customer_id']}" end def verify_address_customer_id(r) return if REDIS.sismember(redis_key_btc_addresses, request.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( request.params["customer_id"], request.params["address"], ELECTRUM .getaddresshistory(request.params["address"]) .map { |item| item["tx_hash"] } ).enqueue! "OK" end r.on :jid do |jid| Sentry.set_user(id: request.params["customer_id"], jid: jid) gateway = CreditCardGateway.new( jid, request.params["customer_id"] ) r.on "activate" do Sentry.configure_scope do |scope| scope.set_transaction_name("activate") scope.set_context( "activate", plan_name: request.params["plan_name"] ) end render = lambda do |l={}| view( "activate", locals: { token: gateway.client_token, customer_id: gateway.customer_id, error: false }.merge(l) ) end r.get do if Plan.active?(gateway.customer_id) r.redirect request.params["return_to"], 303 else render.call end end r.post do result = DB.transaction do Plan.active?(gateway.customer_id) || gateway.buy_plan( request.params["plan_name"], request.params["braintree_nonce"], request.ip ) end if request.params["auto_top_up_amount"].to_i >= 15 REDIS.set( "jmp_customer_auto_top_up_amount-#{gateway.customer_id}", request.params["auto_top_up_amount"].to_i ) end if result r.redirect request.params["return_to"], 303 else render.call(error: true) end end end r.on "credit_cards" do r.get do view( "credit_cards", locals: { token: gateway.client_token, customer_id: gateway.customer_id, auto_top_up: REDIS.get( "jmp_customer_auto_top_up_amount-#{gateway.customer_id}" ) || (gateway.payment_methods? ? "" : "15") } ) end r.post do gateway.default_payment_method = request.params["braintree_nonce"] if request.params["auto_top_up_amount"].to_i >= 15 REDIS.set( "jmp_customer_auto_top_up_amount-#{gateway.customer_id}", request.params["auto_top_up_amount"].to_i ) elsif request.params["auto_top_up_amount"].to_i == 0 REDIS.del( "jmp_customer_auto_top_up_amount-#{gateway.customer_id}" ) end "OK" end end end end end # rubocop:enable Metrics/ClassLength run JmpPay.freeze.app