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/pending_transaction_repo"
 29require_relative "../lib/transaction"
 30
 31CONFIG =
 32	Dhall::Coder
 33	.new(safe: Dhall::Coder::JSON_LIKE + [Symbol, Proc])
 34	.load(ARGV[0], transform_keys: :to_sym)
 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
 60case CONFIG[:electrum][:currency]
 61when "btc"
 62	exchange_row = canadianbitcoins.at("#ticker > table > tbody > tr")
 63	raise "Bitcoin row has moved" unless exchange_row.at("td").text == "Bitcoin"
 64when "bch"
 65	exchange_row = canadianbitcoins.at(
 66		"#ticker > table > tbody > tr:nth-child(2)"
 67	)
 68	if exchange_row.at("td").text != "Bitcoin Cash"
 69		raise "Bitcoin Cash row has moved"
 70	end
 71else
 72	raise "Unknown currency #{CONFIG[:electrum][:currency]}"
 73end
 74
 75sell_price = {}
 76sell_price[:CAD] = BigDecimal(
 77	exchange_row.at("td:nth-of-type(4)").text.match(/^\$(\d+\.\d+)/)[1]
 78)
 79sell_price[:USD] = sell_price[:CAD] * cad_to_usd
 80
 81class Plan
 82	def self.for_customer(customer)
 83		row = DB.exec_params(<<-SQL, [customer.id]).first
 84			SELECT customer_plans.plan_name, UPPER(date_range) - LOWER(date_range) < '2 seconds' AS pd
 85			FROM customer_plans LEFT JOIN plan_log
 86				ON customer_plans.customer_id = plan_log.customer_id
 87				AND plan_log.date_range -|- tsrange(expires_at, expires_at, '[]')
 88			WHERE customer_plans.customer_id=$1 LIMIT 1
 89		SQL
 90		return nil unless row
 91
 92		from_name(customer, row["plan_name"], klass: row["pd"] ? Pending : Plan)
 93	end
 94
 95	def self.from_name(customer, plan_name, klass: Plan)
 96		return unless plan_name
 97
 98		plan = CONFIG[:plans].find { |p| p[:name] == plan_name }
 99		klass.new(customer, plan) if plan
100	end
101
102	def initialize(customer, plan)
103		@customer = customer
104		@plan = plan
105	end
106
107	def name
108		@plan[:name]
109	end
110
111	def currency
112		@plan[:currency]
113	end
114
115	def price
116		BigDecimal(@plan[:monthly_price].to_i) * 0.0001
117	end
118
119	def notify_any_pending_plan!; end
120
121	class Pending < Plan
122		def initialize(customer, plan)
123			super
124			@go_until = Date.today >> 1
125		end
126
127		def activation_amount
128			camnt = BigDecimal(CONFIG[:activation_amount].to_i) * 0.0001
129			[camnt, price].max
130		end
131
132		def notify_any_pending_plan!
133			if @customer.balance < activation_amount
134				@customer.notify(
135					"Your account could not be activated because your " \
136					"balance of $#{@customer.balance} is less that the " \
137					"required activation amount of $#{activation_amount}. " \
138					"Please buy more credit to have your account activated."
139				)
140			else
141				notify_approved
142			end
143		end
144
145	protected
146
147		def notify_approved
148			@customer.notify(
149				"Your JMP account has been approved. To complete " \
150				"your signup, click/tap this link: xmpp:cheogram.com " \
151				"and send register jmp.chat to the bot.",
152				"Your JMP account has been approved. To complete " \
153				"your signup, click/tap this link: " \
154				"xmpp:cheogram.com/CHEOGRAM%25jabber%3Aiq%3Aregister" \
155				"?command;node=jabber%3Aiq%3Aregister"
156			)
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, onboarding_body=nil)
171		jid = REDIS.get("jmp_customer_jid-#{@customer_id}")
172		raise "No JID for #{@customer_id}" unless jid
173
174		if jid =~ /onboarding.cheogram.com/ && onboarding_body
175			body = onboarding_body
176		end
177
178		BlatherNotify.say(
179			CONFIG[:notify_using][:target].call(jid),
180			CONFIG[:notify_using][:body].call(jid, body)
181		)
182	end
183
184	def plan
185		Plan.for_customer(self)
186	end
187
188	def balance
189		result = DB.exec_params(<<-SQL, [@customer_id]).first&.[]("balance")
190			SELECT balance FROM balances WHERE customer_id=$1
191		SQL
192		result || BigDecimal(0)
193	end
194
195	def add_btc_credit(txid, btc_amount, fiat_amount)
196		tx = Transaction.new(
197			@customer_id,
198			txid,
199			fiat_amount,
200			"Cryptocurrency payment",
201			settled_after: Time.now + (5 * 24 * 60 * 60)
202		)
203		return unless tx.save
204
205		tx.bonus&.save
206		notify_btc_credit(txid, btc_amount, fiat_amount, tx.bonus_amount)
207	end
208
209	def notify_btc_credit(txid, btc_amount, fiat_amount, bonus)
210		tx_hash, = txid.split("/", 2)
211		notify([
212			"Your transaction of #{btc_amount.to_s('F')} ",
213			CONFIG[:electrum][:currency],
214			" has been added as $#{'%.4f' % fiat_amount} (#{plan.currency}) ",
215			("+ $#{'%.4f' % bonus} bonus " if bonus.positive?),
216			"to your account.\n(txhash: #{tx_hash})"
217		].compact.join)
218	end
219end
220
221repo = PendingTransactionRepo.new(
222	"pending_#{CONFIG[:electrum][:currency]}_transactions",
223	ignored_key: "ignored_#{CONFIG[:electrum][:currency]}_transactions",
224	customer_address_template: lambda { |customer_id|
225		"jmp_customer_#{CONFIG[:electrum][:currency]}_addresses-#{customer_id}"
226	}
227)
228
229repo.error_handler do |e|
230	case e
231	when Electrum::NoTransaction
232		warn e.to_s
233		true # Skip and continue
234	end
235end
236
237done = repo.map { |pending, customer_id|
238	next unless pending.confirmations >= CONFIG[:required_confirmations]
239
240	if pending.outgoing?
241		repo.mark_ignored(pending)
242		repo.remove_transaction(pending)
243		next
244	end
245	DB.transaction do
246		customer = Customer.new(customer_id)
247		if (plan = customer.plan)
248			btc = pending.value
249			amount = btc * sell_price.fetch(plan.currency).round(4, :floor)
250			customer.add_btc_credit(pending.txid, btc, amount)
251			plan.notify_any_pending_plan!
252			repo.remove_transaction(pending)
253			pending.txid
254		else
255			warn "No plan for #{customer_id} cannot save #{pending.txid}"
256		end
257	end
258}
259
260puts done.join("\n")