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