process_pending_btc_transactions

  1#!/usr/bin/ruby
  2# frozen_string_literal: true
  3
  4# Usage: bin/process_pending-btc_transactions '{
  5#        oxr_app_id = "",
  6#        required_confirmations = 3,
  7#        notify_using = {
  8#          jid = "",
  9#          password = "",
 10#          target = \(tel: Text) -> "${tel}@cheogram.com"
 11#        },
 12#        electrum = env:ELECTRUM_CONFIG,
 13#        plans = ./plans.dhall
 14#        }'
 15
 16require "bigdecimal"
 17require "dhall"
 18require "money/bank/open_exchange_rates_bank"
 19require "net/http"
 20require "nokogiri"
 21require "pg"
 22require "redis"
 23
 24require_relative "../lib/blather_notify"
 25require_relative "../lib/electrum"
 26
 27CONFIG =
 28	Dhall::Coder
 29	.new(safe: Dhall::Coder::JSON_LIKE + [Symbol, Proc])
 30	.load(ARGV[0], transform_keys: :to_sym)
 31
 32REDIS = Redis.new
 33ELECTRUM = Electrum.new(**CONFIG[:electrum])
 34
 35DB = PG.connect(dbname: "jmp")
 36DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
 37DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
 38
 39BlatherNotify.start(
 40	CONFIG[:notify_using][:jid],
 41	CONFIG[:notify_using][:password]
 42)
 43
 44unless (cad_to_usd = REDIS.get("cad_to_usd")&.to_f)
 45	oxr = Money::Bank::OpenExchangeRatesBank.new(Money::RatesStore::Memory.new)
 46	oxr.app_id = CONFIG.fetch(:oxr_app_id)
 47	oxr.update_rates
 48	cad_to_usd = oxr.get_rate("CAD", "USD")
 49	REDIS.set("cad_to_usd", cad_to_usd, ex: 60*60)
 50end
 51
 52canadianbitcoins = Nokogiri::HTML.parse(
 53	Net::HTTP.get(URI("https://www.canadianbitcoins.com"))
 54)
 55
 56bitcoin_row = canadianbitcoins.at("#ticker > table > tbody > tr")
 57raise "Bitcoin row has moved" unless bitcoin_row.at("td").text == "Bitcoin"
 58
 59btc_sell_price = {}
 60btc_sell_price[:CAD] = BigDecimal.new(
 61	bitcoin_row.at("td:nth-of-type(3)").text.match(/^\$(\d+\.\d+)/)[1]
 62)
 63btc_sell_price[:USD] = btc_sell_price[:CAD] * cad_to_usd
 64
 65class Plan
 66	def self.for_customer(customer_id)
 67		row = DB.exec_params(<<-SQL, [customer_id]).first
 68			SELECT plan_name FROM customer_plans WHERE customer_id=$1 LIMIT 1
 69		SQL
 70		return unless row
 71		plan = CONFIG[:plans].find { |p| p["plan_name"] = row["plan_name"] }
 72		new(plan) if plan
 73	end
 74
 75	def initialize(plan)
 76		@plan = plan
 77	end
 78
 79	def currency
 80		@plan[:currency]
 81	end
 82
 83	def bonus_for(fiat_amount, cad_to_usd)
 84		bonus = (0.050167 * fiat_amount) - (currency == :CAD ? 1 : cad_to_usd)
 85		return bonus.round(4, :floor) if bonus > 0
 86	end
 87end
 88
 89class Customer
 90	def initialize(customer_id)
 91		@customer_id = customer_id
 92	end
 93
 94	def notify(body)
 95		jid = REDIS.get("jmp_customer_jid-#{@customer_id}")
 96		tel = REDIS.lindex("catapult_cred-#{jid}", 3)
 97		BlatherNotify.say(
 98			CONFIG[:notify_using][:target].call(tel.to_s),
 99			body
100		)
101	end
102
103	def plan
104		Plan.for_customer(@customer_id)
105	end
106
107	def add_btc_credit(txid, fiat_amount, cad_to_usd)
108		add_transaction(txid, fiat_amount, "Bitcoin payment")
109		if (bonus = plan.bonus_for(fiat_amount, cad_to_usd))
110			add_transaction("bonus_for_#{txid}", bonus, "Bitcoin payment bonus")
111		end
112		notify_btc_credit(txid, fiat_amount, bonus)
113	end
114
115	def notify_btc_credit(txid, fiat_amount, bonus)
116		tx_hash, = txid.split("/", 2)
117		notify([
118			"Your Bitcoin transaction has been added as ",
119			"$#{'%.4f' % fiat_amount} ",
120			("+ $#{'%.4f' % bonus} bonus " if bonus),
121			"to your account.\n(txhash: #{tx_hash})"
122		].compact.join)
123	end
124
125protected
126
127	def add_transaction(id, amount, note)
128		DB.exec_params(<<-SQL, [@customer_id, id, amount, note])
129			INSERT INTO transactions
130				(customer_id, transaction_id, amount, note)
131			VALUES
132					($1, $2, $3, $4)
133			ON CONFLICT (transaction_id) DO NOTHING
134		SQL
135	end
136end
137
138REDIS.hgetall("pending_btc_transactions").each do |(txid, customer_id)|
139	tx_hash, address = txid.split("/", 2)
140	transaction = ELECTRUM.gettransaction(tx_hash)
141	next unless transaction.confirmations >= CONFIG[:required_confirmations]
142	btc = transaction.amount_for(address)
143	if btc <= 0
144		warn "Transaction shows as #{btc}, skipping #{txid}"
145		next
146	end
147	DB.transaction do
148		customer = Customer.new(customer_id)
149		plan = customer.plan
150		if plan
151			amount = btc * btc_sell_price.fetch(plan.currency).round(4, :floor)
152			customer.add_btc_credit(txid, amount, cad_to_usd)
153		else
154			warn "No plan for #{customer_id} cannot save #{txid}"
155		end
156	end
157	REDIS.hdel("pending_btc_transactions", txid)
158end