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