process_pending_btc_transactions

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