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"
 28require_relative "../lib/transaction"
 29
 30CONFIG =
 31	Dhall::Coder
 32	.new(safe: Dhall::Coder::JSON_LIKE + [Symbol, Proc])
 33	.load(ARGV[0], transform_keys: :to_sym)
 34
 35REDIS = Redis.new
 36ELECTRUM = Electrum.new(**CONFIG[:electrum])
 37
 38DB = PG.connect(dbname: "jmp")
 39DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
 40DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
 41
 42BlatherNotify.start(
 43	CONFIG[:notify_using][:jid],
 44	CONFIG[:notify_using][:password]
 45)
 46
 47unless (cad_to_usd = REDIS.get("cad_to_usd")&.to_f)
 48	oxr = Money::Bank::OpenExchangeRatesBank.new(Money::RatesStore::Memory.new)
 49	oxr.app_id = CONFIG.fetch(:oxr_app_id)
 50	oxr.update_rates
 51	cad_to_usd = oxr.get_rate("CAD", "USD")
 52	REDIS.set("cad_to_usd", cad_to_usd, ex: 60 * 60)
 53end
 54
 55canadianbitcoins = Nokogiri::HTML.parse(
 56	Net::HTTP.get(URI("https://www.canadianbitcoins.com"))
 57)
 58
 59case CONFIG[:electrum][:currency]
 60when "btc"
 61	exchange_row = canadianbitcoins.at("#ticker > table > tbody > tr")
 62	raise "Bitcoin row has moved" unless exchange_row.at("td").text == "Bitcoin"
 63when "bch"
 64	exchange_row = canadianbitcoins.at("#ticker > table > tbody > tr:nth-child(2)")
 65	raise "Bitcoin Cash row has moved" unless exchange_row.at("td").text == "Bitcoin Cash"
 66else
 67	raise "Unknown currency #{CONFIG[:electrum][:currency]}"
 68end
 69
 70sell_price = {}
 71sell_price[:CAD] = BigDecimal(
 72	exchange_row.at("td:nth-of-type(4)").text.match(/^\$(\d+\.\d+)/)[1]
 73)
 74sell_price[:USD] = sell_price[:CAD] * cad_to_usd
 75
 76class Plan
 77	def self.for_customer(customer)
 78		row = DB.exec_params(<<-SQL, [customer.id]).first
 79			SELECT customer_plans.plan_name, UPPER(date_range) - LOWER(date_range) < '2 seconds' AS pd
 80			FROM customer_plans LEFT JOIN plan_log
 81				ON customer_plans.customer_id = plan_log.customer_id
 82				AND plan_log.date_range -|- tsrange(expires_at, expires_at, '[]')
 83			WHERE customer_plans.customer_id=$1 LIMIT 1
 84		SQL
 85		return nil unless row
 86
 87		from_name(customer, row["plan_name"], klass: row["pd"] ? Pending : Plan)
 88	end
 89
 90	def self.from_name(customer, plan_name, klass: Plan)
 91		return unless plan_name
 92
 93		plan = CONFIG[:plans].find { |p| p[:name] == plan_name }
 94		klass.new(customer, plan) if plan
 95	end
 96
 97	def initialize(customer, plan)
 98		@customer = customer
 99		@plan = plan
100	end
101
102	def name
103		@plan[:name]
104	end
105
106	def currency
107		@plan[:currency]
108	end
109
110	def price
111		BigDecimal(@plan[:monthly_price].to_i) * 0.0001
112	end
113
114	def notify_any_pending_plan!; end
115
116	class Pending < Plan
117		def initialize(customer, plan)
118			super
119			@go_until = Date.today >> 1
120		end
121
122		def activation_amount
123			camnt = BigDecimal(CONFIG[:activation_amount].to_i) * 0.0001
124			[camnt, price].max
125		end
126
127		def notify_any_pending_plan!
128			if @customer.balance < activation_amount
129				@customer.notify(
130					"Your account could not be activated because your " \
131					"balance of $#{@customer.balance} is less that the " \
132					"required activation amount of $#{activation_amount}. " \
133					"Please buy more credit to have your account activated."
134				)
135			else
136				notify_approved
137			end
138		end
139
140	protected
141
142		def notify_approved
143			@customer.notify(
144				"Your JMP account has been approved. To complete " \
145				"your signup, click/tap this link: xmpp:cheogram.com " \
146				"and send register jmp.chat to the bot.",
147				"Your JMP account has been approved. To complete " \
148				"your signup, click/tap this link: " \
149				"xmpp:cheogram.com/CHEOGRAM%25jabber%3Aiq%3Aregister" \
150				"?command;node=jabber%3Aiq%3Aregister"
151			)
152		end
153	end
154end
155
156class Customer
157	def initialize(customer_id)
158		@customer_id = customer_id
159	end
160
161	def id
162		@customer_id
163	end
164
165	def notify(body, onboarding_body=nil)
166		jid = REDIS.get("jmp_customer_jid-#{@customer_id}")
167		raise "No JID for #{customer_id}" unless jid
168
169		if jid =~ /onboarding.cheogram.com/ && onboarding_body
170			body = onboarding_body
171		end
172
173		BlatherNotify.say(
174			CONFIG[:notify_using][:target].call(jid),
175			CONFIG[:notify_using][:body].call(jid, body)
176		)
177	end
178
179	def plan
180		Plan.for_customer(self)
181	end
182
183	def balance
184		result = DB.exec_params(<<-SQL, [@customer_id]).first&.[]("balance")
185			SELECT balance FROM balances WHERE customer_id=$1
186		SQL
187		result || BigDecimal(0)
188	end
189
190	def add_btc_credit(txid, btc_amount, fiat_amount)
191		tx = Transaction.new(
192			@customer_id,
193			txid,
194			fiat_amount,
195			"Cryptocurrency payment"
196		)
197		return unless tx.save
198
199		tx.bonus&.save
200		notify_btc_credit(txid, btc_amount, fiat_amount, tx.bonus_amount)
201	end
202
203	def notify_btc_credit(txid, btc_amount, fiat_amount, bonus)
204		tx_hash, = txid.split("/", 2)
205		notify([
206			"Your transaction of #{btc_amount.to_s('F')} ",
207			CONFIG[:electrum][:currency],
208			" has been added as $#{'%.4f' % fiat_amount} (#{plan.currency}) ",
209			("+ $#{'%.4f' % bonus} bonus " if bonus.positive?),
210			"to your account.\n(txhash: #{tx_hash})"
211		].compact.join)
212	end
213end
214
215done = REDIS.hgetall("pending_#{CONFIG[:electrum][:currency]}_transactions").map { |(txid, customer_id)|
216	tx_hash, address = txid.split("/", 2)
217
218	transaction = begin
219		ELECTRUM.gettransaction(tx_hash)
220	rescue Electrum::NoTransaction
221		warn $!.to_s
222		next
223	end
224
225	next unless transaction.confirmations >= CONFIG[:required_confirmations]
226
227	btc = transaction.amount_for(address)
228	if btc <= 0
229		# This is a send, not a receive, do not record it
230		REDIS.hdel("pending_#{CONFIG[:electrum][:currency]}_transactions", txid)
231		next
232	end
233	DB.transaction do
234		customer = Customer.new(customer_id)
235		if (plan = customer.plan)
236			amount = btc * sell_price.fetch(plan.currency).round(4, :floor)
237			customer.add_btc_credit(txid, btc, amount)
238			plan.notify_any_pending_plan!
239			REDIS.hdel("pending_#{CONFIG[:electrum][:currency]}_transactions", txid)
240			txid
241		else
242			warn "No plan for #{customer_id} cannot save #{txid}"
243		end
244	end
245}
246
247puts done.compact.join("\n")