diff --git a/.rubocop.yml b/.rubocop.yml index 3f393ef57e08bb0befa746868e21df62110414b8..7f9dd6ee5fefe67fb206abd69c4ce17f213fdd9d 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,3 +1,6 @@ +AllCops: + TargetRubyVersion: 2.3 + Metrics/LineLength: Max: 80 diff --git a/Gemfile b/Gemfile index 2ae45b84bafadb0b12b35bb9948a071b18a0319d..5bf40146a0f9ec231f778874cb5b1dd2ad650bb8 100644 --- a/Gemfile +++ b/Gemfile @@ -4,6 +4,7 @@ source "https://rubygems.org" gem "braintree" gem "dhall" +gem "money-open-exchange-rates" gem "pg" gem "redis" gem "roda" diff --git a/bin/process_pending_btc_transactions b/bin/process_pending_btc_transactions new file mode 100755 index 0000000000000000000000000000000000000000..51cbcc8cac3d3b606d4f6963986c9e3c458a2ac7 --- /dev/null +++ b/bin/process_pending_btc_transactions @@ -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 diff --git a/lib/electrum.rb b/lib/electrum.rb index 8a972d5857500659bdcef7a7d3b91725fdcfee3b..1cdaf908d6635df2dafd1b35587165af0e5f389e 100644 --- a/lib/electrum.rb +++ b/lib/electrum.rb @@ -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)