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#        activation_amount = 10000
 15#        }'
 16
 17require "bigdecimal"
 18require "dhall"
 19require "money/bank/open_exchange_rates_bank"
 20require "net/http"
 21require "nokogiri"
 22require "pg"
 23require "redis"
 24
 25require_relative "../lib/blather_notify"
 26require_relative "../lib/electrum"
 27
 28CONFIG =
 29	Dhall::Coder
 30	.new(safe: Dhall::Coder::JSON_LIKE + [Symbol, Proc])
 31	.load(ARGV[0], transform_keys: :to_sym)
 32
 33REDIS = Redis.new
 34ELECTRUM = Electrum.new(**CONFIG[:electrum])
 35
 36DB = PG.connect(dbname: "jmp")
 37DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
 38DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
 39
 40BlatherNotify.start(
 41	CONFIG[:notify_using][:jid],
 42	CONFIG[:notify_using][:password]
 43)
 44
 45unless (cad_to_usd = REDIS.get("cad_to_usd")&.to_f)
 46	oxr = Money::Bank::OpenExchangeRatesBank.new(Money::RatesStore::Memory.new)
 47	oxr.app_id = CONFIG.fetch(:oxr_app_id)
 48	oxr.update_rates
 49	cad_to_usd = oxr.get_rate("CAD", "USD")
 50	REDIS.set("cad_to_usd", cad_to_usd, ex: 60*60)
 51end
 52
 53canadianbitcoins = Nokogiri::HTML.parse(
 54	Net::HTTP.get(URI("https://www.canadianbitcoins.com"))
 55)
 56
 57bitcoin_row = canadianbitcoins.at("#ticker > table > tbody > tr")
 58raise "Bitcoin row has moved" unless bitcoin_row.at("td").text == "Bitcoin"
 59
 60btc_sell_price = {}
 61btc_sell_price[:CAD] = BigDecimal.new(
 62	bitcoin_row.at("td:nth-of-type(3)").text.match(/^\$(\d+\.\d+)/)[1]
 63)
 64btc_sell_price[:USD] = btc_sell_price[:CAD] * cad_to_usd
 65
 66class Plan
 67	def self.for_customer(customer)
 68		row = DB.exec_params(<<-SQL, [customer.id]).first
 69			SELECT plan_name FROM customer_plans WHERE customer_id=$1 LIMIT 1
 70		SQL
 71		from_name(customer, row&.[]("plan_name"))
 72	end
 73
 74	def self.pending_for_customer(customer)
 75		from_name(
 76			customer,
 77			REDIS.get("pending_plan_for-#{customer.id}"),
 78			klass: Pending
 79		)
 80	end
 81
 82	def self.from_name(customer, plan_name, klass: Plan)
 83		return unless plan_name
 84		plan = CONFIG[:plans].find { |p| p[:name] == plan_name }
 85		klass.new(customer, plan) if plan
 86	end
 87
 88	def initialize(customer, plan)
 89		@customer = customer
 90		@plan = plan
 91	end
 92
 93	def name
 94		@plan[:name]
 95	end
 96
 97	def currency
 98		@plan[:currency]
 99	end
100
101	def bonus_for(fiat_amount, cad_to_usd)
102		bonus = (0.050167 * fiat_amount) - (currency == :CAD ? 1 : cad_to_usd)
103		return bonus.round(4, :floor) if bonus > 0
104	end
105
106	def price
107		BigDecimal.new(@plan[:monthly_price].to_i) * 0.0001
108	end
109
110	def insert(start:, expire:)
111		params = [@customer.id, name, start, expire]
112		DB.exec_params(<<-SQL, params)
113			INSERT INTO plan_log
114				(customer_id, plan_name, starts_at, expires_at)
115			VALUES
116				($1, $2, $3, $4)
117		SQL
118	end
119
120	def activate_any_pending_plan!; end
121
122	class Pending < Plan
123		def initialize(customer, plan)
124			super
125			@go_until = Date.today >> 1
126		end
127
128		def activation_amount
129			camnt = BigDecimal.new(CONFIG[:activation_amount].to_i) * 0.0001
130			[camnt, price].max
131		end
132
133		def activate_any_pending_plan!
134			if @customer.balance < activation_amount
135				@customer.notify(
136					"Your account could not be activated because your " \
137					"balance of $#{@customer.balance} is less that the " \
138					"required activation amount of $#{activation_amount}. " \
139					"Please buy more credit to have your account activated."
140				)
141			else
142				charge_insert_notify
143			end
144		end
145
146	protected
147
148		def charge_insert_notify
149			@customer.add_transaction(
150				"activate_#{name}_until_#{@go_until}",
151				-price,
152				"Activate pending plan"
153			)
154			insert(start: Date.today, expire: @go_until)
155			REDIS.del("pending_plan_for-#{@customer.id}")
156			# TODO: @customer.notify("Your account has been activated")
157		end
158	end
159end
160
161class Customer
162	def initialize(customer_id)
163		@customer_id = customer_id
164	end
165
166	def id
167		@customer_id
168	end
169
170	def notify(body)
171		jid = REDIS.get("jmp_customer_jid-#{@customer_id}")
172		tel = REDIS.lindex("catapult_cred-#{jid}", 3)
173		BlatherNotify.say(
174			CONFIG[:notify_using][:target].call(tel.to_s),
175			body
176		)
177	end
178
179	def plan
180		Plan.for_customer(self) || pending_plan
181	end
182
183	def pending_plan
184		Plan.pending_for_customer(self)
185	end
186
187	def balance
188		result = DB.exec_params(<<-SQL, [@customer_id]).first&.[]("balance")
189			SELECT balance FROM balances WHERE customer_id=$1
190		SQL
191		result || BigDecimal.new(0)
192	end
193
194	def add_btc_credit(txid, fiat_amount, cad_to_usd)
195		add_transaction(txid, fiat_amount, "Bitcoin payment")
196		if (bonus = plan.bonus_for(fiat_amount, cad_to_usd))
197			add_transaction("bonus_for_#{txid}", bonus, "Bitcoin payment bonus")
198		end
199		notify_btc_credit(txid, fiat_amount, bonus)
200	end
201
202	def notify_btc_credit(txid, fiat_amount, bonus)
203		tx_hash, = txid.split("/", 2)
204		notify([
205			"Your Bitcoin transaction has been added as ",
206			"$#{'%.4f' % fiat_amount} ",
207			("+ $#{'%.4f' % bonus} bonus " if bonus),
208			"to your account.\n(txhash: #{tx_hash})"
209		].compact.join)
210	end
211
212	def add_transaction(id, amount, note)
213		DB.exec_params(<<-SQL, [@customer_id, id, amount, note])
214			INSERT INTO transactions
215				(customer_id, transaction_id, amount, note)
216			VALUES
217					($1, $2, $3, $4)
218			ON CONFLICT (transaction_id) DO NOTHING
219		SQL
220	end
221end
222
223REDIS.hgetall("pending_btc_transactions").each do |(txid, customer_id)|
224	tx_hash, address = txid.split("/", 2)
225	transaction = ELECTRUM.gettransaction(tx_hash)
226	next unless transaction.confirmations >= CONFIG[:required_confirmations]
227	btc = transaction.amount_for(address)
228	if btc <= 0
229		warn "Transaction shows as #{btc}, skipping #{txid}"
230		next
231	end
232	DB.transaction do
233		customer = Customer.new(customer_id)
234		if (plan = customer.plan)
235			amount = btc * btc_sell_price.fetch(plan.currency).round(4, :floor)
236			customer.add_btc_credit(txid, amount, cad_to_usd)
237			customer.plan.activate_any_pending_plan!
238			REDIS.hdel("pending_btc_transactions", txid)
239		else
240			warn "No plan for #{customer_id} cannot save #{txid}"
241		end
242	end
243end