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 = \(tel: Text) -> "${tel}@cheogram.com"
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"
28
29CONFIG =
30 Dhall::Coder
31 .new(safe: Dhall::Coder::JSON_LIKE + [Symbol, Proc])
32 .load(ARGV[0], transform_keys: :to_sym)
33
34Net::HTTP.post_form(URI("#{CONFIG[:healthchecks_url]}/start"), {})
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
60bitcoin_row = canadianbitcoins.at("#ticker > table > tbody > tr")
61raise "Bitcoin row has moved" unless bitcoin_row.at("td").text == "Bitcoin"
62
63btc_sell_price = {}
64btc_sell_price[:CAD] = BigDecimal.new(
65 bitcoin_row.at("td:nth-of-type(3)").text.match(/^\$(\d+\.\d+)/)[1]
66)
67btc_sell_price[:USD] = btc_sell_price[:CAD] * cad_to_usd
68
69class Plan
70 def self.for_customer(customer)
71 row = DB.exec_params(<<-SQL, [customer.id]).first
72 SELECT plan_name FROM customer_plans WHERE customer_id=$1 LIMIT 1
73 SQL
74 from_name(customer, row&.[]("plan_name"))
75 end
76
77 def self.pending_for_customer(customer)
78 from_name(
79 customer,
80 REDIS.get("pending_plan_for-#{customer.id}"),
81 klass: Pending
82 )
83 end
84
85 def self.from_name(customer, plan_name, klass: Plan)
86 return unless plan_name
87 plan = CONFIG[:plans].find { |p| p[:name] == plan_name }
88 klass.new(customer, plan) if plan
89 end
90
91 def initialize(customer, plan)
92 @customer = customer
93 @plan = plan
94 end
95
96 def name
97 @plan[:name]
98 end
99
100 def currency
101 @plan[:currency]
102 end
103
104 def bonus_for(fiat_amount, cad_to_usd)
105 bonus = (0.050167 * fiat_amount) - (currency == :CAD ? 1 : cad_to_usd)
106 return bonus.round(4, :floor) if bonus > 0
107 end
108
109 def price
110 BigDecimal.new(@plan[:monthly_price].to_i) * 0.0001
111 end
112
113 def insert(start:, expire:)
114 params = [@customer.id, name, start, expire]
115 DB.exec_params(<<-SQL, params)
116 INSERT INTO plan_log
117 (customer_id, plan_name, starts_at, expires_at)
118 VALUES
119 ($1, $2, $3, $4)
120 SQL
121 end
122
123 def activate_any_pending_plan!; end
124
125 class Pending < Plan
126 def initialize(customer, plan)
127 super
128 @go_until = Date.today >> 1
129 end
130
131 def activation_amount
132 camnt = BigDecimal.new(CONFIG[:activation_amount].to_i) * 0.0001
133 [camnt, price].max
134 end
135
136 def activate_any_pending_plan!
137 if @customer.balance < activation_amount
138 @customer.notify(
139 "Your account could not be activated because your " \
140 "balance of $#{@customer.balance} is less that the " \
141 "required activation amount of $#{activation_amount}. " \
142 "Please buy more credit to have your account activated."
143 )
144 else
145 charge_insert_notify
146 end
147 end
148
149 protected
150
151 def charge_insert_notify
152 @customer.add_transaction(
153 "activate_#{name}_until_#{@go_until}",
154 -price,
155 "Activate pending plan"
156 )
157 insert(start: Date.today, expire: @go_until)
158 REDIS.del("pending_plan_for-#{@customer.id}")
159 # TODO: @customer.notify("Your account has been activated")
160 end
161 end
162end
163
164class Customer
165 def initialize(customer_id)
166 @customer_id = customer_id
167 end
168
169 def id
170 @customer_id
171 end
172
173 def notify(body)
174 jid = REDIS.get("jmp_customer_jid-#{@customer_id}")
175 raise "No JID for #{customer_id}" unless jid
176 tel = REDIS.lindex("catapult_cred-#{jid}", 3)
177 return unless tel
178 BlatherNotify.say(
179 CONFIG[:notify_using][:target].call(tel.to_s),
180 body
181 )
182 end
183
184 def plan
185 Plan.for_customer(self) || pending_plan
186 end
187
188 def pending_plan
189 Plan.pending_for_customer(self)
190 end
191
192 def balance
193 result = DB.exec_params(<<-SQL, [@customer_id]).first&.[]("balance")
194 SELECT balance FROM balances WHERE customer_id=$1
195 SQL
196 result || BigDecimal.new(0)
197 end
198
199 def add_btc_credit(txid, btc_amount, fiat_amount, cad_to_usd)
200 add_transaction(txid, btc_amount, fiat_amount, "Bitcoin payment")
201 if (bonus = plan.bonus_for(fiat_amount, cad_to_usd))
202 add_transaction("bonus_for_#{txid}", bonus, "Bitcoin payment bonus")
203 end
204 notify_btc_credit(txid, btc_amount, fiat_amount, bonus)
205 end
206
207 def notify_btc_credit(txid, btc_amount, fiat_amount, bonus)
208 tx_hash, = txid.split("/", 2)
209 notify([
210 "Your Bitcoin transaction of #{btc_amount.to_s('F')} BTC ",
211 "has been added as $#{'%.4f' % fiat_amount} (#{plan.currency}) ",
212 ("+ $#{'%.4f' % bonus} bonus " if bonus),
213 "to your account.\n(txhash: #{tx_hash})"
214 ].compact.join)
215 end
216
217 def add_transaction(id, amount, note)
218 DB.exec_params(<<-SQL, [@customer_id, id, amount, note])
219 INSERT INTO transactions
220 (customer_id, transaction_id, amount, note)
221 VALUES
222 ($1, $2, $3, $4)
223 ON CONFLICT (transaction_id) DO NOTHING
224 SQL
225 end
226end
227
228REDIS.hgetall("pending_btc_transactions").each do |(txid, customer_id)|
229 tx_hash, address = txid.split("/", 2)
230 transaction = ELECTRUM.gettransaction(tx_hash)
231 next unless transaction.confirmations >= CONFIG[:required_confirmations]
232 btc = transaction.amount_for(address)
233 if btc <= 0
234 warn "Transaction shows as #{btc}, skipping #{txid}"
235 next
236 end
237 DB.transaction do
238 customer = Customer.new(customer_id)
239 if (plan = customer.plan)
240 amount = btc * btc_sell_price.fetch(plan.currency).round(4, :floor)
241 customer.add_btc_credit(txid, btc, amount, cad_to_usd)
242 customer.plan.activate_any_pending_plan!
243 REDIS.hdel("pending_btc_transactions", txid)
244 else
245 warn "No plan for #{customer_id} cannot save #{txid}"
246 end
247 end
248end
249
250Net::HTTP.post_form(URI(CONFIG[:healthchecks_url].to_s), {})