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 activate(customer_id, months)
57 DB.exec_params(
58 "INSERT INTO plan_log VALUES ($1, $2, $3, $4)",
59 [customer_id, @plan[:name], Time.now, Date.today >> months]
60 )
61 end
62end
63
64class CreditCardGateway
65 def initialize(jid, customer_id=nil)
66 @jid = jid
67 @customer_id = customer_id
68
69 @gateway = Braintree::Gateway.new(
70 environment: BRAINTREE_CONFIG[:environment].to_s,
71 merchant_id: BRAINTREE_CONFIG[:merchant_id].to_s,
72 public_key: BRAINTREE_CONFIG[:public_key].to_s,
73 private_key: BRAINTREE_CONFIG[:private_key].to_s
74 )
75 end
76
77 def check_customer_id(cid)
78 return cid unless ENV["RACK_ENV"] == "production"
79
80 raise "customer_id does not match" unless @customer_id == cid
81
82 cid
83 end
84
85 def customer_id
86 customer_id = REDIS.get(redis_key_jid)
87 return customer_id if check_customer_id(customer_id)
88
89 result = @gateway.customer.create
90 raise "Braintree customer create failed" unless result.success?
91 @customer_id = result.customer.id
92 save_customer_id!
93 end
94
95 def save_customer_id!
96 unless REDIS.set(redis_key_jid, @customer_id) == "OK"
97 raise "Saving new jid,customer to redis failed"
98 end
99
100 unless REDIS.set(redis_key_customer_id, @jid) == "OK"
101 raise "Saving new customer,jid to redis failed"
102 end
103
104 @customer_id
105 end
106
107 def client_token
108 @gateway.client_token.generate(customer_id: customer_id)
109 end
110
111 def default_payment_method=(nonce)
112 @gateway.payment_method.create(
113 customer_id: customer_id,
114 payment_method_nonce: nonce,
115 options: {
116 make_default: true
117 }
118 )
119 end
120
121 def buy_plan(plan_name, months, nonce)
122 plan = Plan.for(plan_name)
123 result = @gateway.transaction.sale(
124 amount: plan.price(months),
125 payment_method_nonce: nonce,
126 merchant_account_id: plan.merchant_account,
127 options: {submit_for_settlement: true}
128 )
129 return false unless result.success?
130 plan.activate(@customer_id, months)
131 true
132 end
133
134protected
135
136 def redis_key_jid
137 "jmp_customer_id-#{@jid}"
138 end
139
140 def redis_key_customer_id
141 "jmp_customer_jid-#{@customer_id}"
142 end
143end
144
145class UnknownTransactions
146 def self.from(customer_id, address, tx_hashes)
147 values = tx_hashes.map do |tx_hash|
148 "('#{DB.escape_string(tx_hash)}/#{DB.escape_string(address)}')"
149 end
150 rows = DB.exec_params(<<-SQL)
151 SELECT transaction_id FROM
152 (VALUES #{values.join(',')}) AS t(transaction_id)
153 LEFT JOIN transactions USING (transaction_id)
154 WHERE transactions.transaction_id IS NULL
155 SQL
156 new(customer_id, rows.map { |row| row["transaction_id"] })
157 end
158
159 def initialize(customer_id, transaction_ids)
160 @customer_id = customer_id
161 @transaction_ids = transaction_ids
162 end
163
164 def enqueue!
165 REDIS.hset(
166 "pending_btc_transactions",
167 *@transaction_ids.flat_map { |txid| [txid, @customer_id] }
168 )
169 end
170end
171
172class JmpPay < Roda
173 plugin :render, engine: "slim"
174 plugin :common_logger, $stdout
175
176 def redis_key_btc_addresses
177 "jmp_customer_btc_addresses-#{request.params['customer_id']}"
178 end
179
180 def verify_address_customer_id(r)
181 return if REDIS.sismember(redis_key_btc_addresses, request.params["address"])
182
183 warn "Address and customer_id do not match"
184 r.halt(
185 403,
186 {"Content-Type" => "text/plain"},
187 "Address and customer_id do not match"
188 )
189 end
190
191 route do |r|
192 r.on "electrum_notify" do
193 verify_address_customer_id(r)
194
195 UnknownTransactions.from(
196 request.params["customer_id"],
197 request.params["address"],
198 ELECTRUM
199 .getaddresshistory(request.params["address"])
200 .map { |item| item["tx_hash"] }
201 ).enqueue!
202
203 "OK"
204 end
205
206 r.on :jid do |jid|
207 Sentry.set_user(id: request.params["customer_id"], jid: jid)
208
209 gateway = CreditCardGateway.new(
210 jid,
211 request.params["customer_id"]
212 )
213
214 r.on "activate" do
215 Sentry.configure_scope do |scope|
216 scope.set_transaction_name("activate")
217 scope.set_context(
218 "activate",
219 plan_name: request.params["plan_name"]
220 )
221 end
222
223 render = lambda do |l={}|
224 view(
225 "activate",
226 locals: {
227 token: gateway.client_token,
228 customer_id: gateway.customer_id,
229 error: false
230 }.merge(l)
231 )
232 end
233
234 r.get do
235 render.call
236 end
237
238 r.post do
239 result = gateway.buy_plan(
240 request.params["plan_name"],
241 5,
242 request.params["braintree_nonce"]
243 )
244 if result
245 r.redirect request.params["return_to"], 303
246 else
247 render.call(error: true)
248 end
249 end
250 end
251
252 r.on "credit_cards" do
253 r.get do
254 view(
255 "credit_cards",
256 locals: {
257 token: gateway.client_token,
258 customer_id: gateway.customer_id
259 }
260 )
261 end
262
263 r.post do
264 gateway.default_payment_method = request.params["braintree_nonce"]
265 "OK"
266 end
267 end
268 end
269 end
270end
271
272run JmpPay.freeze.app