#!/usr/bin/ruby
# frozen_string_literal: true
# Usage: bin/process_pending-btc_transactions '{
# 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)
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)
return BigDecimal.new(0) if fiat_amount <= 15
fiat_amount * case fiat_amount
when (15..29.99)
0.01
when (30..139.99)
0.03
else
0.05
end
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, date_range)
VALUES
($1, $2, tsrange($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
return unless @customer.add_transaction(
"activate_#{@customer.id}_#{name}_until_#{@go_until}",
-price,
"Activate pending plan"
)
insert(start: Date.today, expire: @go_until)
REDIS.del("pending_plan_for-#{@customer.id}")
notify_approved
end
def notify_approved
sid = REDIS.get("reg-sid_for-#{@customer.id}")
tel = REDIS.get("reg-session_tel-#{sid}")&.sub(/\+/, "%2B")
@customer.notify(
"Your JMP account has been approved. To complete " \
"your signup, click/tap here: " \
"https://jmp.chat/sp1a/register4/?sid=#{sid}&number=#{tel}"
)
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)
return unless add_transaction(txid, fiat_amount, "Bitcoin payment")
if (bonus = plan.bonus_for(fiat_amount)) > 0
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 > 0),
"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]).cmd_tuples > 0
INSERT INTO transactions
(customer_id, transaction_id, amount, note)
VALUES
($1, $2, $3, $4)
ON CONFLICT (transaction_id) DO NOTHING
SQL
end
end
done = REDIS.hgetall("pending_btc_transactions").map 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
# This is a send, not a receive, do not record it
REDIS.hdel("pending_btc_transactions", 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)
customer.plan.activate_any_pending_plan!
REDIS.hdel("pending_btc_transactions", txid)
txid
else
warn "No plan for #{customer_id} cannot save #{txid}"
end
end
end
puts done.compact.join("\n")