~singpolyma/jmp-pay

ba4d587bda369fc1757b0e29886ebaaed1ac269b — Stephen Paul Weber 2 years ago 6cd9f94
Cronjob to check pending BTC transactions

When they become confirmed, insert them into the transactions table.
We can never double-insert because of the PRIMARY KEY on transaction_id, so the
script is always safe to run even if something ends up in redis twice.
4 files changed, 135 insertions(+), 0 deletions(-)

M .rubocop.yml
M Gemfile
A bin/process_pending_btc_transactions
M lib/electrum.rb
M .rubocop.yml => .rubocop.yml +3 -0
@@ 1,3 1,6 @@
AllCops:
  TargetRubyVersion: 2.3

Metrics/LineLength:
  Max: 80


M Gemfile => Gemfile +1 -0
@@ 4,6 4,7 @@ source "https://rubygems.org"

gem "braintree"
gem "dhall"
gem "money-open-exchange-rates"
gem "pg"
gem "redis"
gem "roda"

A bin/process_pending_btc_transactions => bin/process_pending_btc_transactions +98 -0
@@ 0,0 1,98 @@
#!/usr/bin/ruby
# frozen_string_literal: true

# Usage: bin/process_pending-btc_transactions '{
#        oxr_app_id = "",
#        required_confirmations = 3,
#        electrum = env:ELECTRUM_CONFIG,
#        plans = ./plans.dhall
#        }'

require "bigdecimal"
require "dhall"
require "money/bank/open_exchange_rates_bank"
require "net/http"
require "nokogiri"
require "pg"
require "redis"

require_relative "../lib/electrum"

CONFIG =
	Dhall::Coder
	.new(safe: Dhall::Coder::JSON_LIKE + [Symbol])
	.load(ARGV[0], transform_keys: :to_sym)

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)

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_id)
		row = DB.exec_params(<<-SQL, [customer_id]).first
			SELECT plan_name FROM customer_plans WHERE customer_id=$1 LIMIT 1
		SQL
		return unless row
		plan = CONFIG[:plans].find { |p| p["plan_name"] = row["plan_name"] }
		new(plan) if plan
	end

	def initialize(plan)
		@plan = plan
	end

	def currency
		@plan[:currency]
	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
		plan = Plan.for_customer(customer_id)
		if plan
			amount = btc * btc_sell_price.fetch(plan.currency).round(4, :floor)
			DB.exec_params(<<-SQL, [customer_id, txid, amount])
				INSERT INTO transactions
					(customer_id, transaction_id, amount, note)
				VALUES
						($1, $2, $3, 'Bitcoin payment')
				ON CONFLICT (transaction_id) DO NOTHING
			SQL
		else
			warn "No plan for #{customer_id} cannot save #{txid}"
		end
	end
	REDIS.hdel("pending_btc_transactions", txid)
end

M lib/electrum.rb => lib/electrum.rb +33 -0
@@ 1,5 1,6 @@
# frozen_string_literal: true

require "bigdecimal"
require "json"
require "net/http"
require "securerandom"


@@ 15,6 16,38 @@ class Electrum
		rpc_call(:getaddresshistory, address: address)["result"]
	end

	def gettransaction(tx_hash)
		Transaction.new(self, tx_hash, rpc_call(
			:deserialize,
			[rpc_call(:gettransaction, txid: tx_hash)["result"]]
		)["result"])
	end

	def get_tx_status(tx_hash)
		rpc_call(:get_tx_status, txid: tx_hash)["result"]
	end

	class Transaction
		def initialize(electrum, tx_hash, tx)
			@electrum = electrum
			@tx_hash = tx_hash
			@tx = tx
		end

		def confirmations
			@electrum.get_tx_status(@tx_hash)["confirmations"]
		end

		def amount_for(*addresses)
			BigDecimal.new(
				@tx["outputs"]
					.select { |o| addresses.include?(o["address"]) }
					.map { |o| o["value_sats"] }
					.sum
			) * 0.00000001
		end
	end

protected

	def rpc_call(method, params)