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")