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 = \(jid: Text) -> "+12266669977@cheogram.com",
 11#          body = \(jid: Text) -> \(body: Text) -> "/msg ${jid} ${body}",
 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
 34REDIS = Redis.new
 35ELECTRUM = Electrum.new(**CONFIG[:electrum])
 36
 37DB = PG.connect(dbname: "jmp")
 38DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
 39DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
 40
 41BlatherNotify.start(
 42	CONFIG[:notify_using][:jid],
 43	CONFIG[:notify_using][:password]
 44)
 45
 46unless (cad_to_usd = REDIS.get("cad_to_usd")&.to_f)
 47	oxr = Money::Bank::OpenExchangeRatesBank.new(Money::RatesStore::Memory.new)
 48	oxr.app_id = CONFIG.fetch(:oxr_app_id)
 49	oxr.update_rates
 50	cad_to_usd = oxr.get_rate("CAD", "USD")
 51	REDIS.set("cad_to_usd", cad_to_usd, ex: 60*60)
 52end
 53
 54canadianbitcoins = Nokogiri::HTML.parse(
 55	Net::HTTP.get(URI("https://www.canadianbitcoins.com"))
 56)
 57
 58bitcoin_row = canadianbitcoins.at("#ticker > table > tbody > tr")
 59raise "Bitcoin row has moved" unless bitcoin_row.at("td").text == "Bitcoin"
 60
 61btc_sell_price = {}
 62btc_sell_price[:CAD] = BigDecimal.new(
 63	bitcoin_row.at("td:nth-of-type(3)").text.match(/^\$(\d+\.\d+)/)[1]
 64)
 65btc_sell_price[:USD] = btc_sell_price[:CAD] * cad_to_usd
 66
 67class Plan
 68	def self.for_customer(customer)
 69		row = DB.exec_params(<<-SQL, [customer.id]).first
 70			SELECT plan_name FROM customer_plans WHERE customer_id=$1 LIMIT 1
 71		SQL
 72		from_name(customer, row&.[]("plan_name"))
 73	end
 74
 75	def self.pending_for_customer(customer)
 76		from_name(
 77			customer,
 78			REDIS.get("pending_plan_for-#{customer.id}"),
 79			klass: Pending
 80		)
 81	end
 82
 83	def self.from_name(customer, plan_name, klass: Plan)
 84		return unless plan_name
 85		plan = CONFIG[:plans].find { |p| p[:name] == plan_name }
 86		klass.new(customer, plan) if plan
 87	end
 88
 89	def initialize(customer, plan)
 90		@customer = customer
 91		@plan = plan
 92	end
 93
 94	def name
 95		@plan[:name]
 96	end
 97
 98	def currency
 99		@plan[:currency]
100	end
101
102	def bonus_for(fiat_amount)
103		return BigDecimal.new(0) if fiat_amount <= 15
104		fiat_amount * case fiat_amount
105		when (15..29.99)
106			0.01
107		when (30..139.99)
108			0.03
109		else
110			0.05
111		end
112	end
113
114	def price
115		BigDecimal.new(@plan[:monthly_price].to_i) * 0.0001
116	end
117
118	def insert(start:, expire:)
119		params = [@customer.id, name, start, expire]
120		DB.exec_params(<<-SQL, params)
121			INSERT INTO plan_log
122				(customer_id, plan_name, date_range)
123			VALUES
124				($1, $2, tsrange($3, $4))
125		SQL
126	end
127
128	def activate_any_pending_plan!; end
129
130	class Pending < Plan
131		def initialize(customer, plan)
132			super
133			@go_until = Date.today >> 1
134		end
135
136		def activation_amount
137			camnt = BigDecimal.new(CONFIG[:activation_amount].to_i) * 0.0001
138			[camnt, price].max
139		end
140
141		def activate_any_pending_plan!
142			if @customer.balance < activation_amount
143				@customer.notify(
144					"Your account could not be activated because your " \
145					"balance of $#{@customer.balance} is less that the " \
146					"required activation amount of $#{activation_amount}. " \
147					"Please buy more credit to have your account activated."
148				)
149			else
150				charge_insert_notify
151			end
152		end
153
154	protected
155
156		def charge_insert_notify
157			return unless @customer.add_transaction(
158				"activate_#{@customer.id}_#{name}_until_#{@go_until}",
159				-price,
160				"Activate pending plan"
161			)
162			insert(start: Date.today, expire: @go_until)
163			REDIS.del("pending_plan_for-#{@customer.id}")
164			notify_approved
165		end
166
167		def notify_approved
168			sid = REDIS.get("reg-sid_for-#{@customer.id}")
169			tel = REDIS.get("reg-session_tel-#{sid}")&.sub(/\+/, "%2B")
170			@customer.notify(
171				"Your JMP account has been approved. To complete " \
172				"your signup, click/tap here: " \
173				"https://jmp.chat/sp1a/register4/?sid=#{sid}&number=#{tel}"
174			)
175		end
176	end
177end
178
179class Customer
180	def initialize(customer_id)
181		@customer_id = customer_id
182	end
183
184	def id
185		@customer_id
186	end
187
188	def notify(body)
189		jid = REDIS.get("jmp_customer_jid-#{@customer_id}")
190		raise "No JID for #{customer_id}" unless jid
191		BlatherNotify.say(
192			CONFIG[:notify_using][:target].call(jid),
193			CONFIG[:notify_using][:body].call(jid, body)
194		)
195	end
196
197	def plan
198		Plan.for_customer(self) || pending_plan
199	end
200
201	def pending_plan
202		Plan.pending_for_customer(self)
203	end
204
205	def balance
206		result = DB.exec_params(<<-SQL, [@customer_id]).first&.[]("balance")
207			SELECT balance FROM balances WHERE customer_id=$1
208		SQL
209		result || BigDecimal.new(0)
210	end
211
212	def add_btc_credit(txid, btc_amount, fiat_amount)
213		return unless add_transaction(txid, fiat_amount, "Bitcoin payment")
214		if (bonus = plan.bonus_for(fiat_amount)) > 0
215			add_transaction("bonus_for_#{txid}", bonus, "Bitcoin payment bonus")
216		end
217		notify_btc_credit(txid, btc_amount, fiat_amount, bonus)
218	end
219
220	def notify_btc_credit(txid, btc_amount, fiat_amount, bonus)
221		tx_hash, = txid.split("/", 2)
222		notify([
223			"Your Bitcoin transaction of #{btc_amount.to_s('F')} BTC ",
224			"has been added as $#{'%.4f' % fiat_amount} (#{plan.currency}) ",
225			("+ $#{'%.4f' % bonus} bonus " if bonus > 0),
226			"to your account.\n(txhash: #{tx_hash})"
227		].compact.join)
228	end
229
230	def add_transaction(id, amount, note)
231		DB.exec_params(<<-SQL, [@customer_id, id, amount, note]).cmd_tuples > 0
232			INSERT INTO transactions
233				(customer_id, transaction_id, amount, note)
234			VALUES
235					($1, $2, $3, $4)
236			ON CONFLICT (transaction_id) DO NOTHING
237		SQL
238	end
239end
240
241done = REDIS.hgetall("pending_btc_transactions").map do |(txid, customer_id)|
242	tx_hash, address = txid.split("/", 2)
243	transaction = ELECTRUM.gettransaction(tx_hash)
244	next unless transaction.confirmations >= CONFIG[:required_confirmations]
245	btc = transaction.amount_for(address)
246	if btc <= 0
247		# This is a send, not a receive, do not record it
248		REDIS.hdel("pending_btc_transactions", txid)
249		next
250	end
251	DB.transaction do
252		customer = Customer.new(customer_id)
253		if (plan = customer.plan)
254			amount = btc * btc_sell_price.fetch(plan.currency).round(4, :floor)
255			customer.add_btc_credit(txid, btc, amount)
256			customer.plan.activate_any_pending_plan!
257			REDIS.hdel("pending_btc_transactions", txid)
258			txid
259		else
260			warn "No plan for #{customer_id} cannot save #{txid}"
261		end
262	end
263end
264
265puts done.compact.join("\n")