1# frozen_string_literal: true
2
3require "braintree"
4require "date"
5require "delegate"
6require "dhall"
7require "pg"
8require "redis"
9require "roda"
10
11if ENV["RACK_ENV"] == "development"
12 require "pry-rescue"
13 use PryRescue::Rack
14end
15
16require_relative "lib/electrum"
17
18require "sentry-ruby"
19Sentry.init do |config|
20 config.traces_sample_rate = 1
21end
22use Sentry::Rack::CaptureExceptions
23
24REDIS = Redis.new
25PLANS = Dhall.load("env:PLANS").sync
26BRAINTREE_CONFIG = Dhall.load("env:BRAINTREE_CONFIG").sync
27ELECTRUM = Electrum.new(
28 **Dhall::Coder.load("env:ELECTRUM_CONFIG", transform_keys: :to_sym)
29)
30
31DB = PG.connect(dbname: "jmp")
32DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
33DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
34
35class Plan
36 def self.for(plan_name)
37 new(PLANS.find { |p| p[:name].to_s == plan_name })
38 end
39
40 def initialize(plan)
41 @plan = plan
42 end
43
44 def price(months=1)
45 (BigDecimal.new(@plan[:monthly_price].to_i) * months) / 10000
46 end
47
48 def currency
49 @plan[:currency].to_s.to_sym
50 end
51
52 def merchant_account
53 BRAINTREE_CONFIG[:merchant_accounts][currency]
54 end
55
56 def self.active?(customer_id)
57 DB.exec_params(<<~SQL, [customer_id]).first&.[]("count").to_i > 0
58 SELECT count(1) AS count FROM customer_plans
59 WHERE customer_id=$1 AND expires_at > NOW()
60 SQL
61 end
62
63 def activate(customer_id, months)
64 DB.exec_params(
65 "INSERT INTO plan_log VALUES ($1, $2, $3, $4)",
66 [customer_id, @plan[:name], Time.now, Date.today >> months]
67 )
68 true
69 end
70end
71
72class CreditCardGateway
73 def initialize(jid, customer_id=nil)
74 @jid = jid
75 @customer_id = customer_id
76
77 @gateway = Braintree::Gateway.new(
78 environment: BRAINTREE_CONFIG[:environment].to_s,
79 merchant_id: BRAINTREE_CONFIG[:merchant_id].to_s,
80 public_key: BRAINTREE_CONFIG[:public_key].to_s,
81 private_key: BRAINTREE_CONFIG[:private_key].to_s
82 )
83 end
84
85 def check_customer_id(cid)
86 return cid unless ENV["RACK_ENV"] == "production"
87
88 raise "customer_id does not match" unless @customer_id == cid
89
90 cid
91 end
92
93 def customer_id
94 customer_id = REDIS.get(redis_key_jid)
95 return customer_id if check_customer_id(customer_id)
96
97 result = @gateway.customer.create
98 raise "Braintree customer create failed" unless result.success?
99 @customer_id = result.customer.id
100 save_customer_id!
101 end
102
103 def save_customer_id!
104 unless REDIS.set(redis_key_jid, @customer_id) == "OK"
105 raise "Saving new jid,customer to redis failed"
106 end
107
108 unless REDIS.set(redis_key_customer_id, @jid) == "OK"
109 raise "Saving new customer,jid to redis failed"
110 end
111
112 @customer_id
113 end
114
115 def client_token
116 @gateway.client_token.generate(customer_id: customer_id)
117 end
118
119 def default_payment_method=(nonce)
120 @gateway.payment_method.create(
121 customer_id: customer_id,
122 payment_method_nonce: nonce,
123 options: {
124 make_default: true
125 }
126 )
127 end
128
129 def decline_guard(ip)
130 customer_declines, ip_declines = REDIS.mget(
131 "jmp_pay_decline-#{@customer_id}",
132 "jmp_pay_decline-#{ip}"
133 )
134 customer_declines.to_i < 2 && ip_declines.to_i < 4
135 end
136
137 def sale(ip:, **kwargs)
138 return false unless decline_guard(ip)
139 result = @gateway.transaction.sale(**kwargs)
140 return true if result.success?
141
142 REDIS.incr("jmp_pay_decline-#{@customer_id}")
143 REDIS.expire("jmp_pay_decline-#{@customer_id}", 60 * 60 * 24)
144 REDIS.incr("jmp_pay_decline-#{ip}")
145 REDIS.expire("jmp_pay_decline-#{ip}", 60 * 60 * 24)
146 false
147 end
148
149 def buy_plan(plan_name, months, nonce, ip)
150 plan = Plan.for(plan_name)
151 sale(
152 ip: ip,
153 amount: plan.price(months),
154 payment_method_nonce: nonce,
155 merchant_account_id: plan.merchant_account,
156 options: {submit_for_settlement: true}
157 ) && plan.activate(@customer_id, months)
158 end
159
160protected
161
162 def redis_key_jid
163 "jmp_customer_id-#{@jid}"
164 end
165
166 def redis_key_customer_id
167 "jmp_customer_jid-#{@customer_id}"
168 end
169end
170
171class UnknownTransactions
172 def self.from(customer_id, address, tx_hashes)
173 self.for(
174 customer_id,
175 fetch_rows_for(address, tx_hashes).map { |row| row["transaction_id"] }
176 )
177 end
178
179 def self.fetch_rows_for(address, tx_hashes)
180 values = tx_hashes.map do |tx_hash|
181 "('#{DB.escape_string(tx_hash)}/#{DB.escape_string(address)}')"
182 end
183 return [] unless values
184 DB.exec_params(<<-SQL)
185 SELECT transaction_id FROM
186 (VALUES #{values.join(',')}) AS t(transaction_id)
187 LEFT JOIN transactions USING (transaction_id)
188 WHERE transactions.transaction_id IS NULL
189 SQL
190 end
191
192 def self.for(customer_id, transaction_ids)
193 transaction_ids.empty? ? None.new : new(customer_id, transaction_ids)
194 end
195
196 def initialize(customer_id, transaction_ids)
197 @customer_id = customer_id
198 @transaction_ids = transaction_ids
199 end
200
201 def enqueue!
202 REDIS.hset(
203 "pending_btc_transactions",
204 *@transaction_ids.flat_map { |txid| [txid, @customer_id] }
205 )
206 end
207
208 class None
209 def enqueue!; end
210 end
211end
212
213class JmpPay < Roda
214 plugin :render, engine: "slim"
215 plugin :common_logger, $stdout
216
217 def redis_key_btc_addresses
218 "jmp_customer_btc_addresses-#{request.params['customer_id']}"
219 end
220
221 def verify_address_customer_id(r)
222 return if REDIS.sismember(redis_key_btc_addresses, request.params["address"])
223
224 warn "Address and customer_id do not match"
225 r.halt([
226 403,
227 {"Content-Type" => "text/plain"},
228 "Address and customer_id do not match"
229 ])
230 end
231
232 route do |r|
233 r.on "electrum_notify" do
234 verify_address_customer_id(r)
235
236 UnknownTransactions.from(
237 request.params["customer_id"],
238 request.params["address"],
239 ELECTRUM
240 .getaddresshistory(request.params["address"])
241 .map { |item| item["tx_hash"] }
242 ).enqueue!
243
244 "OK"
245 end
246
247 r.on :jid do |jid|
248 Sentry.set_user(id: request.params["customer_id"], jid: jid)
249
250 gateway = CreditCardGateway.new(
251 jid,
252 request.params["customer_id"]
253 )
254
255 r.on "activate" do
256 Sentry.configure_scope do |scope|
257 scope.set_transaction_name("activate")
258 scope.set_context(
259 "activate",
260 plan_name: request.params["plan_name"]
261 )
262 end
263
264 render = lambda do |l={}|
265 view(
266 "activate",
267 locals: {
268 token: gateway.client_token,
269 customer_id: gateway.customer_id,
270 error: false
271 }.merge(l)
272 )
273 end
274
275 r.get do
276 if Plan.active?(gateway.customer_id)
277 r.redirect request.params["return_to"], 303
278 else
279 render.call
280 end
281 end
282
283 r.post do
284 result = DB.transaction do
285 Plan.active?(gateway.customer_id) || gateway.buy_plan(
286 request.params["plan_name"],
287 5,
288 request.params["braintree_nonce"],
289 request.ip
290 )
291 end
292 if result
293 r.redirect request.params["return_to"], 303
294 else
295 render.call(error: true)
296 end
297 end
298 end
299
300 r.on "credit_cards" do
301 r.get do
302 view(
303 "credit_cards",
304 locals: {
305 token: gateway.client_token,
306 customer_id: gateway.customer_id
307 }
308 )
309 end
310
311 r.post do
312 gateway.default_payment_method = request.params["braintree_nonce"]
313 "OK"
314 end
315 end
316 end
317 end
318end
319
320run JmpPay.freeze.app