1# frozen_string_literal: true
2
3require "braintree"
4require "bigdecimal/util"
5require "countries"
6require "date"
7require "delegate"
8require "dhall"
9require "forwardable"
10require "pg"
11require "redis"
12require "roda"
13require "uri"
14require "json"
15
16if ENV["RACK_ENV"] == "development"
17 require "pry-rescue"
18 use PryRescue::Rack
19end
20
21require_relative "lib/auto_top_up_repo"
22require_relative "lib/customer"
23require_relative "lib/three_d_secure_repo"
24require_relative "lib/electrum"
25require_relative "lib/transaction"
26require_relative "lib/credit_card_customer_gateway"
27
28require "sentry-ruby"
29Sentry.init do |config|
30 config.traces_sample_rate = 0.01
31end
32use Sentry::Rack::CaptureExceptions
33
34REDIS = Redis.new
35PLANS = Dhall.load("env:PLANS").sync
36BRAINTREE_CONFIG = Dhall.load("env:BRAINTREE_CONFIG").sync
37ELECTRUM = Electrum.new(
38 **Dhall::Coder.load("env:ELECTRUM_CONFIG", transform_keys: :to_sym)
39)
40ELECTRUM_BCH = Electrum.new(
41 **Dhall::Coder.load("env:ELECTRON_CASH_CONFIG", transform_keys: :to_sym)
42)
43
44DB = PG.connect(dbname: "jmp")
45DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
46DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
47
48class CreditCardGateway
49 class ErrorResult < StandardError
50 def self.for(result)
51 if result.verification&.status == "gateway_rejected" &&
52 result.verification&.gateway_rejection_reason == "cvv"
53 new("fieldInvalidForCvv")
54 else
55 new(result.message)
56 end
57 end
58 end
59
60 def initialize(currency, antifraud)
61 @currency = currency
62 @antifraud = antifraud
63
64 @gateway = Braintree::Gateway.new(
65 environment: BRAINTREE_CONFIG[:environment].to_s,
66 merchant_id: BRAINTREE_CONFIG[:merchant_id].to_s,
67 public_key: BRAINTREE_CONFIG[:public_key].to_s,
68 private_key: BRAINTREE_CONFIG[:private_key].to_s
69 )
70 end
71
72 def merchant_account
73 BRAINTREE_CONFIG[:merchant_accounts][@currency]
74 end
75
76 def client_token(**kwargs)
77 kwargs[:merchant_account_id] = merchant_account.to_s if merchant_account
78 @gateway.client_token.generate(**kwargs)
79 end
80
81 def antifraud
82 REDIS.mget(@antifraud.map { |k| "jmp_antifraud-#{k}" }).find do |anti|
83 anti.to_i > 2
84 end &&
85 Braintree::ErrorResult.new(
86 @gateway, errors: {}, message: "Please contact support"
87 )
88 end
89
90 def with_antifraud
91 result = antifraud || yield
92 return result if result.success?
93
94 @antifraud.each do |k|
95 REDIS.incr("jmp_antifraud-#{k}")
96 REDIS.expire("jmp_antifraud-#{k}", 60 * 60 * 24)
97 end
98
99 raise ErrorResult.for(result)
100 end
101
102 def sale(nonce, amount, **kwargs)
103 with_antifraud do
104 @gateway.transaction.sale(
105 payment_method_nonce: nonce,
106 amount: amount, merchant_account_id: merchant_account.to_s,
107 options: {
108 store_in_vault_on_success: true, submit_for_settlement: true
109 }, **kwargs
110 )
111 end
112 end
113
114 def customer
115 @gateway.customer
116 end
117
118 def payment_method
119 @gateway.payment_method
120 end
121end
122
123class CardVault
124 def self.for(gateway, nonce, amount=nil)
125 if amount&.positive?
126 CardDeposit.new(gateway, nonce, amount)
127 else
128 new(gateway, nonce)
129 end
130 end
131
132 def initialize(gateway, nonce)
133 @gateway = gateway
134 @nonce = nonce
135 end
136
137 def call(auto_top_up_amount)
138 result = vault!
139 ThreeDSecureRepo.new.put_from_result(result)
140 AutoTopUpRepo.new.put(
141 @gateway.customer_id,
142 auto_top_up_amount
143 )
144 result
145 end
146
147 def vault!
148 @gateway.default_method(@nonce)
149 end
150
151 class CardDeposit < self
152 def initialize(gateway, nonce, amount)
153 super(gateway, nonce)
154 @amount = amount
155
156 return unless @amount < 15 || @amount > 35
157
158 raise CreditCardGateway::ErrorResult, "amount too low or too high"
159 end
160
161 def call(*)
162 result = super
163 Transaction.new(
164 @gateway.customer_id,
165 result.transaction.id,
166 @amount,
167 "Credit card payment"
168 ).save
169 result
170 end
171
172 def vault!
173 @gateway.sale(@nonce, @amount)
174 end
175 end
176end
177
178class JmpPay < Roda
179 SENTRY_DSN = ENV.fetch("SENTRY_DSN", nil)&.then { |v| URI(v) }
180 plugin :render, engine: "slim"
181 plugin :cookies, path: "/"
182 plugin :common_logger, $stdout
183 plugin :content_for
184 plugin(
185 :assets,
186 css: { tom_select: "tom_select.scss", loader: "loader.scss" },
187 js: {
188 section_list: "section_list.js",
189 tom_select: "tom_select.js",
190 htmx: "htmx.js"
191 },
192 add_suffix: true
193 )
194
195 extend Forwardable
196 def_delegators :request, :params
197
198 def electrum
199 case params["currency"]
200 when "bch"
201 ELECTRUM_BCH
202 else
203 ELECTRUM
204 end
205 end
206
207 def nil_empty(s)
208 s.to_s == "" ? nil : s
209 end
210
211 def stallion_shipment
212 {
213 to_address: {
214 country_code: params["country-name"],
215 postal_code: params["postal-code"],
216 address1: nil_empty(params["street-address"]),
217 email: nil_empty(params["email"])
218 }.compact,
219 weight_unit: "g",
220 weight:
221 (params["esim_adapter_quantity"].to_i * 10) +
222 (params["pcsc_quantity"].to_i * 30),
223 size_unit: "in",
224 length: 18,
225 width: 8.5,
226 height: params["pcsc_quantity"].to_i.positive? ? 1 : 0.1,
227 package_type: "Large Envelope Or Flat",
228 items: [
229 {
230 title: "Memory Card",
231 description: "Memory Card",
232 sku: "003",
233 quantity: params["esim_adapter_quantity"].to_i,
234 value: params["currency"] == "CAD" ? 54.99 : 39.99,
235 currency: params["currency"],
236 country_of_origin: "CN"
237 },
238 {
239 title: "Card Reader",
240 description: "Card Reader",
241 sku: "002",
242 quantity: params["pcsc_quantity"].to_i,
243 value: params["currency"] == "CAD" ? 13.50 : 10,
244 currency: params["currency"],
245 country_of_origin: "CN"
246 }
247 ].select { |item| item[:quantity].positive? },
248 insured: true
249 }
250 end
251
252 def get_rates
253 uri = URI("https://ship.stallionexpress.ca/api/v4/rates")
254 Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
255 req = Net::HTTP::Post.new(uri)
256 req["Content-Type"] = "application/json"
257 req["Authorization"] = "Bearer #{ENV.fetch('STALLION_TOKEN')}"
258 body = stallion_shipment
259 req.body = body.to_json
260 JSON.parse(http.request(req).read_body)
261 end
262 end
263
264 def retail_rate(rates)
265 return unless rates&.first&.dig("total")
266
267 total = BigDecimal(rates.first["total"], 2)
268 total *= 0.75 if params["currency"] == "USD"
269 params["country-name"] == "US" ? total.round : total.ceil
270 end
271
272 route do |r|
273 r.on "electrum_notify" do
274 REDIS.lpush(
275 "exciting_#{electrum.currency}_addrs",
276 "#{params['address']}/#{params["customer_id"]}"
277 )
278
279 "OK"
280 end
281
282 r.assets
283
284 atfd = r.cookies["atfd"] || SecureRandom.uuid
285 one_year = 60 * 60 * 24 * 365
286 response.set_cookie(
287 "atfd",
288 value: atfd, expires: Time.now + one_year
289 )
290 params.delete("atfd") if params["atfd"].to_s == ""
291 antifrauds = [atfd, r.ip, params["atfd"]].compact.uniq
292
293 r.on "esim-adapter" do
294 r.get "braintree" do
295 gateway = CreditCardGateway.new(params["currency"], antifrauds)
296 if gateway.antifraud
297 next view(
298 :message,
299 locals: { message: "Please contact support" }
300 )
301 end
302 render :braintree, locals: { token: gateway.client_token }
303 end
304
305 r.get "total" do
306 next "" unless params["postal-code"].to_s != ""
307
308 resp = get_rates
309 next resp["errors"].to_a.join("<br />") unless resp["success"]
310
311 render :total, locals: resp.merge("body" => stallion_shipment)
312 end
313
314 r.post do
315 gateway = CreditCardGateway.new(params["currency"], antifrauds)
316 if gateway.antifraud
317 next view(
318 :message,
319 locals: { message: "Please contact support" }
320 )
321 end
322
323 cost = stallion_shipment[:items].map { |item| item[:quantity] * item[:value] }.sum
324 rate = retail_rate(get_rates["rates"])
325
326 unless BigDecimal(params["amount"], 2) >= (cost + rate)
327 raise "Invalid amount"
328 end
329
330 sale_result = gateway.sale(params["braintree_nonce"], params["amount"])
331
332 postal_lookup = JSON.parse(Net::HTTP.get(URI(
333 "https://app.zipcodebase.com/api/v1/search?" \
334 "apikey=#{ENV.fetch('ZIPCODEBASE_TOKEN')}&" \
335 "codes=#{params['postal-code']}&" \
336 "country=#{params['country-name']}"
337 )))
338
339 uri = URI("https://ship.stallionexpress.ca/api/v4/orders")
340 Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
341 req = Net::HTTP::Post.new(uri)
342 req["Content-Type"] = "application/json"
343 req["Authorization"] = "Bearer #{ENV.fetch('STALLION_TOKEN')}"
344 body = stallion_shipment
345 body.merge!(body.delete(:to_address))
346 body[:store_id] = ENV.fetch("STALLION_STORE_ID")
347 body[:value] = BigDecimal(params["amount"], 2)
348 body[:currency] = params["currency"]
349 body[:order_at] = Time.now.strftime("%Y-%m-%d %H:%m:%S")
350 body[:order_id] = sale_result.transaction.id
351 body[:name] = "#{params['given-name']} #{params['family-name']}"
352 body[:city] = postal_lookup["results"].values.first.first["city"]
353 body[:province_code] = postal_lookup["results"].values.first.first["state_code"]
354 req.body = body.to_json
355 resp = JSON.parse(http.request(req).read_body)
356 view :receipt, locals: resp.merge("rate" => rate)
357 end
358 rescue CreditCardGateway::ErrorResult
359 response.status = 400
360 $!.message
361 end
362
363 r.get do
364 view :esim_adapter, locals: { antifraud: atfd, ip: request.ip }
365 end
366 end
367
368 r.on :jid do |jid|
369 Sentry.set_user(id: params["customer_id"], jid: jid)
370
371 customer_id = params["customer_id"]
372 gateway = CreditCardCustomerGateway.new(jid, customer_id, antifrauds)
373 topup = AutoTopUpRepo.new
374
375 r.on "credit_cards" do
376 r.get do
377 if gateway.antifraud
378 return view(
379 :message,
380 locals: { message: "Please contact support" }
381 )
382 end
383
384 view(
385 "credit_cards",
386 locals: {
387 jid: jid,
388 token: gateway.client_token,
389 customer_id: gateway.customer_id,
390 antifraud: atfd,
391 auto_top_up: topup.find(gateway.customer_id) ||
392 (gateway.payment_methods? ? "" : "15")
393 }
394 )
395 end
396
397 r.post do
398 CardVault
399 .for(
400 gateway, params["braintree_nonce"],
401 params["amount"].to_d
402 ).call(params["auto_top_up_amount"].to_i)
403 "OK"
404 rescue ThreeDSecureRepo::Failed
405 gateway.remove_method($!.message)
406 response.status = 400
407 "hostedFieldsFieldsInvalidError"
408 rescue CreditCardGateway::ErrorResult
409 response.status = 400
410 $!.message
411 end
412 end
413 end
414 end
415end
416
417run JmpPay.freeze.app