#!/usr/bin/ruby # frozen_string_literal: true # Usage: bin/process_pending-btc_transactions '{ # healthchecks_url = "https://hc-ping.com/...", # oxr_app_id = "", # required_confirmations = 3, # notify_using = { # jid = "", # password = "", # target = \(jid: Text) -> "+12266669977@cheogram.com", # body = \(jid: Text) -> \(body: Text) -> "/msg ${jid} ${body}", # }, # electrum = env:ELECTRUM_CONFIG, # plans = ./plans.dhall, # activation_amount = 10000 # }' require "bigdecimal" require "dhall" require "money/bank/open_exchange_rates_bank" require "net/http" require "nokogiri" require "pg" require "redis" require_relative "../lib/blather_notify" require_relative "../lib/electrum" CONFIG = Dhall::Coder .new(safe: Dhall::Coder::JSON_LIKE + [Symbol, Proc]) .load(ARGV[0], transform_keys: :to_sym) Net::HTTP.post_form(URI("#{CONFIG[:healthchecks_url]}/start"), {}) REDIS = Redis.new ELECTRUM = Electrum.new(**CONFIG[:electrum]) DB = PG.connect(dbname: "jmp") DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB) DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB) BlatherNotify.start( CONFIG[:notify_using][:jid], CONFIG[:notify_using][:password] ) unless (cad_to_usd = REDIS.get("cad_to_usd")&.to_f) oxr = Money::Bank::OpenExchangeRatesBank.new(Money::RatesStore::Memory.new) oxr.app_id = CONFIG.fetch(:oxr_app_id) oxr.update_rates cad_to_usd = oxr.get_rate("CAD", "USD") REDIS.set("cad_to_usd", cad_to_usd, ex: 60*60) end canadianbitcoins = Nokogiri::HTML.parse( Net::HTTP.get(URI("https://www.canadianbitcoins.com")) ) bitcoin_row = canadianbitcoins.at("#ticker > table > tbody > tr") raise "Bitcoin row has moved" unless bitcoin_row.at("td").text == "Bitcoin" btc_sell_price = {} btc_sell_price[:CAD] = BigDecimal.new( bitcoin_row.at("td:nth-of-type(3)").text.match(/^\$(\d+\.\d+)/)[1] ) btc_sell_price[:USD] = btc_sell_price[:CAD] * cad_to_usd class Plan def self.for_customer(customer) row = DB.exec_params(<<-SQL, [customer.id]).first SELECT plan_name FROM customer_plans WHERE customer_id=$1 LIMIT 1 SQL from_name(customer, row&.[]("plan_name")) end def self.pending_for_customer(customer) from_name( customer, REDIS.get("pending_plan_for-#{customer.id}"), klass: Pending ) end def self.from_name(customer, plan_name, klass: Plan) return unless plan_name plan = CONFIG[:plans].find { |p| p[:name] == plan_name } klass.new(customer, plan) if plan end def initialize(customer, plan) @customer = customer @plan = plan end def name @plan[:name] end def currency @plan[:currency] end def bonus_for(fiat_amount, cad_to_usd) bonus = (0.050167 * fiat_amount) - (currency == :CAD ? 1 : cad_to_usd) return bonus.round(4, :floor) if bonus > 0 end def price BigDecimal.new(@plan[:monthly_price].to_i) * 0.0001 end def insert(start:, expire:) params = [@customer.id, name, start, expire] DB.exec_params(<<-SQL, params) INSERT INTO plan_log (customer_id, plan_name, starts_at, expires_at) VALUES ($1, $2, $3, $4) SQL end def activate_any_pending_plan!; end class Pending < Plan def initialize(customer, plan) super @go_until = Date.today >> 1 end def activation_amount camnt = BigDecimal.new(CONFIG[:activation_amount].to_i) * 0.0001 [camnt, price].max end def activate_any_pending_plan! if @customer.balance < activation_amount @customer.notify( "Your account could not be activated because your " \ "balance of $#{@customer.balance} is less that the " \ "required activation amount of $#{activation_amount}. " \ "Please buy more credit to have your account activated." ) else charge_insert_notify end end protected def charge_insert_notify @customer.add_transaction( "activate_#{name}_until_#{@go_until}", -price, "Activate pending plan" ) insert(start: Date.today, expire: @go_until) REDIS.del("pending_plan_for-#{@customer.id}") @customer.notify("Your account has been activated") end end end class Customer def initialize(customer_id) @customer_id = customer_id end def id @customer_id end def notify(body) jid = REDIS.get("jmp_customer_jid-#{@customer_id}") raise "No JID for #{customer_id}" unless jid BlatherNotify.say( CONFIG[:notify_using][:target].call(jid), CONFIG[:notify_using][:body].call(jid, body) ) end def plan Plan.for_customer(self) || pending_plan end def pending_plan Plan.pending_for_customer(self) end def balance result = DB.exec_params(<<-SQL, [@customer_id]).first&.[]("balance") SELECT balance FROM balances WHERE customer_id=$1 SQL result || BigDecimal.new(0) end def add_btc_credit(txid, btc_amount, fiat_amount, cad_to_usd) add_transaction(txid, btc_amount, fiat_amount, "Bitcoin payment") if (bonus = plan.bonus_for(fiat_amount, cad_to_usd)) add_transaction("bonus_for_#{txid}", bonus, "Bitcoin payment bonus") end notify_btc_credit(txid, btc_amount, fiat_amount, bonus) end def notify_btc_credit(txid, btc_amount, fiat_amount, bonus) tx_hash, = txid.split("/", 2) notify([ "Your Bitcoin transaction of #{btc_amount.to_s('F')} BTC ", "has been added as $#{'%.4f' % fiat_amount} (#{plan.currency}) ", ("+ $#{'%.4f' % bonus} bonus " if bonus), "to your account.\n(txhash: #{tx_hash})" ].compact.join) end def add_transaction(id, amount, note) DB.exec_params(<<-SQL, [@customer_id, id, amount, note]) INSERT INTO transactions (customer_id, transaction_id, amount, note) VALUES ($1, $2, $3, $4) ON CONFLICT (transaction_id) DO NOTHING SQL end end REDIS.hgetall("pending_btc_transactions").each do |(txid, customer_id)| tx_hash, address = txid.split("/", 2) transaction = ELECTRUM.gettransaction(tx_hash) next unless transaction.confirmations >= CONFIG[:required_confirmations] btc = transaction.amount_for(address) if btc <= 0 warn "Transaction shows as #{btc}, skipping #{txid}" next end DB.transaction do customer = Customer.new(customer_id) if (plan = customer.plan) amount = btc * btc_sell_price.fetch(plan.currency).round(4, :floor) customer.add_btc_credit(txid, btc, amount, cad_to_usd) customer.plan.activate_any_pending_plan! REDIS.hdel("pending_btc_transactions", txid) else warn "No plan for #{customer_id} cannot save #{txid}" end end end Net::HTTP.post_form(URI(CONFIG[:healthchecks_url].to_s), {})