1# frozen_string_literal: true
2
3require "braintree"
4require "date"
5require "delegate"
6require "dhall"
7require "forwardable"
8require "pg"
9require "redis"
10require "roda"
11require "uri"
12
13if ENV["RACK_ENV"] == "development"
14 require "pry-rescue"
15 use PryRescue::Rack
16end
17
18require_relative "lib/auto_top_up_repo"
19require_relative "lib/three_d_secure_repo"
20require_relative "lib/electrum"
21
22require "sentry-ruby"
23Sentry.init do |config|
24 config.traces_sample_rate = 1
25end
26use Sentry::Rack::CaptureExceptions
27
28REDIS = Redis.new
29PLANS = Dhall.load("env:PLANS").sync
30BRAINTREE_CONFIG = Dhall.load("env:BRAINTREE_CONFIG").sync
31ELECTRUM = Electrum.new(
32 **Dhall::Coder.load("env:ELECTRUM_CONFIG", transform_keys: :to_sym)
33)
34
35DB = PG.connect(dbname: "jmp")
36DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
37DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
38
39class CreditCardGateway
40 class ErrorResult < StandardError
41 def self.for(result)
42 if result.verification&.status == "gateway_rejected" &&
43 result.verification&.gateway_rejection_reason == "cvv"
44 new("fieldInvalidForCvv")
45 else
46 new(result.message)
47 end
48 end
49 end
50
51 def initialize(jid, customer_id=nil)
52 @jid = jid
53 @customer_id = customer_id
54
55 @gateway = Braintree::Gateway.new(
56 environment: BRAINTREE_CONFIG[:environment].to_s,
57 merchant_id: BRAINTREE_CONFIG[:merchant_id].to_s,
58 public_key: BRAINTREE_CONFIG[:public_key].to_s,
59 private_key: BRAINTREE_CONFIG[:private_key].to_s
60 )
61 end
62
63 def check_customer_id(cid)
64 return cid unless ENV["RACK_ENV"] == "production"
65
66 raise "customer_id does not match" unless @customer_id == cid
67
68 cid
69 end
70
71 def customer_id
72 customer_id = REDIS.get(redis_key_jid)
73 return customer_id if check_customer_id(customer_id)
74
75 result = @gateway.customer.create
76 raise "Braintree customer create failed" unless result.success?
77
78 @customer_id = result.customer.id
79 save_customer_id!
80 end
81
82 def save_customer_id!
83 unless REDIS.set(redis_key_jid, @customer_id) == "OK"
84 raise "Saving new jid,customer to redis failed"
85 end
86
87 unless REDIS.set(redis_key_customer_id, @jid) == "OK"
88 raise "Saving new customer,jid to redis failed"
89 end
90
91 @customer_id
92 end
93
94 def customer_plan
95 name = DB.exec_params(<<~SQL, [customer_id]).first&.[]("plan_name")
96 SELECT plan_name FROM customer_plans WHERE customer_id=$1 LIMIT 1
97 SQL
98 PLANS.find { |plan| plan[:name].to_s == name }
99 end
100
101 def merchant_account
102 plan = customer_plan
103 return unless plan
104
105 BRAINTREE_CONFIG[:merchant_accounts][plan[:currency]]
106 end
107
108 def client_token
109 kwargs = {}
110 kwargs[:merchant_account_id] = merchant_account.to_s if merchant_account
111 @gateway.client_token.generate(customer_id: customer_id, **kwargs)
112 end
113
114 def payment_methods?
115 !@gateway.customer.find(customer_id).payment_methods.empty?
116 end
117
118 def default_method(nonce)
119 result = @gateway.payment_method.create(
120 customer_id: customer_id,
121 payment_method_nonce: nonce,
122 options: {
123 verify_card: true,
124 make_default: true
125 }
126 )
127 raise ErrorResult.for(result) unless result.success?
128
129 result
130 end
131
132 def remove_method(token)
133 @gateway.payment_method.delete(token)
134 end
135
136protected
137
138 def redis_key_jid
139 "jmp_customer_id-#{@jid}"
140 end
141
142 def redis_key_customer_id
143 "jmp_customer_jid-#{@customer_id}"
144 end
145end
146
147class UnknownTransactions
148 def self.from(customer_id, address, tx_hashes)
149 self.for(
150 customer_id,
151 fetch_rows_for(address, tx_hashes).map { |row|
152 row["transaction_id"]
153 }
154 )
155 end
156
157 def self.fetch_rows_for(address, tx_hashes)
158 values = tx_hashes.map { |tx_hash|
159 "('#{DB.escape_string(tx_hash)}/#{DB.escape_string(address)}')"
160 }
161 return [] if values.empty?
162
163 DB.exec_params(<<-SQL)
164 SELECT transaction_id FROM
165 (VALUES #{values.join(',')}) AS t(transaction_id)
166 LEFT JOIN transactions USING (transaction_id)
167 WHERE transactions.transaction_id IS NULL
168 SQL
169 end
170
171 def self.for(customer_id, transaction_ids)
172 transaction_ids.empty? ? None.new : new(customer_id, transaction_ids)
173 end
174
175 def initialize(customer_id, transaction_ids)
176 @customer_id = customer_id
177 @transaction_ids = transaction_ids
178 end
179
180 def enqueue!
181 REDIS.hset(
182 "pending_btc_transactions",
183 *@transaction_ids.flat_map { |txid| [txid, @customer_id] }
184 )
185 end
186
187 class None
188 def enqueue!; end
189 end
190end
191
192class JmpPay < Roda
193 SENTRY_DSN = ENV["SENTRY_DSN"] && URI(ENV["SENTRY_DSN"])
194 plugin :render, engine: "slim"
195 plugin :common_logger, $stdout
196
197 extend Forwardable
198 def_delegators :request, :params
199
200 def redis_key_btc_addresses
201 "jmp_customer_btc_addresses-#{params['customer_id']}"
202 end
203
204 def verify_address_customer_id(r)
205 return if REDIS.sismember(redis_key_btc_addresses, params["address"])
206
207 warn "Address and customer_id do not match"
208 r.halt([
209 403,
210 { "Content-Type" => "text/plain" },
211 "Address and customer_id do not match"
212 ])
213 end
214
215 route do |r|
216 r.on "electrum_notify" do
217 verify_address_customer_id(r)
218
219 UnknownTransactions.from(
220 params["customer_id"],
221 params["address"],
222 ELECTRUM
223 .getaddresshistory(params["address"])
224 .map { |item| item["tx_hash"] }
225 ).enqueue!
226
227 "OK"
228 end
229
230 r.on :jid do |jid|
231 Sentry.set_user(id: params["customer_id"], jid: jid)
232
233 gateway = CreditCardGateway.new(jid, params["customer_id"])
234 topup = AutoTopUpRepo.new
235
236 r.on "credit_cards" do
237 r.get do
238 view(
239 "credit_cards",
240 locals: {
241 token: gateway.client_token,
242 customer_id: gateway.customer_id,
243 auto_top_up: topup.find(gateway.customer_id) ||
244 (gateway.payment_methods? ? "" : "15")
245 }
246 )
247 end
248
249 r.post do
250 result = gateway.default_method(params["braintree_nonce"])
251 ThreeDSecureRepo.new.put_from_payment_method(
252 gateway.customer_id,
253 result.payment_method
254 )
255 topup.put(
256 gateway.customer_id,
257 params["auto_top_up_amount"].to_i
258 )
259 "OK"
260 rescue ThreeDSecureRepo::Failed
261 gateway.remove_method($!.message)
262 response.status = 400
263 "hostedFieldsFieldsInvalidError"
264 rescue CreditCardGateway::ErrorResult
265 response.status = 400
266 $!.message
267 end
268 end
269 end
270 end
271end
272
273run JmpPay.freeze.app