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 descriptor: { name: "JMPchat" },
108 options: {
109 store_in_vault_on_success: true, submit_for_settlement: true
110 }, **kwargs
111 )
112 end
113 end
114
115 def customer
116 @gateway.customer
117 end
118
119 def payment_method
120 @gateway.payment_method
121 end
122end
123
124class CardVault
125 def self.for(gateway, nonce, amount=nil)
126 if amount&.positive?
127 CardDeposit.new(gateway, nonce, amount)
128 else
129 new(gateway, nonce)
130 end
131 end
132
133 def initialize(gateway, nonce)
134 @gateway = gateway
135 @nonce = nonce
136 end
137
138 def call(auto_top_up_amount)
139 result = vault!
140 ThreeDSecureRepo.new.put_from_result(result)
141 AutoTopUpRepo.new.put(
142 @gateway.customer_id,
143 auto_top_up_amount
144 )
145 result
146 end
147
148 def vault!
149 @gateway.default_method(@nonce)
150 end
151
152 class CardDeposit < self
153 def initialize(gateway, nonce, amount)
154 super(gateway, nonce)
155 @amount = amount
156
157 return unless @amount < 15 || @amount > 35
158
159 raise CreditCardGateway::ErrorResult, "amount too low or too high"
160 end
161
162 def call(*)
163 result = super
164 Transaction.new(
165 @gateway.customer_id,
166 result.transaction.id,
167 @amount,
168 "Credit card payment"
169 ).save
170 result
171 end
172
173 def vault!
174 @gateway.sale(@nonce, @amount)
175 end
176 end
177end
178
179class JmpPay < Roda
180 SENTRY_DSN = ENV.fetch("SENTRY_DSN", nil)&.then { |v| URI(v) }
181 plugin :render, engine: "slim"
182 plugin :cookies, path: "/"
183 plugin :common_logger, $stdout
184 plugin :content_for
185 plugin(
186 :assets,
187 css: { tom_select: "tom_select.scss", loader: "loader.scss" },
188 js: {
189 section_list: "section_list.js",
190 tom_select: "tom_select.js",
191 htmx: "htmx.js"
192 },
193 add_suffix: true
194 )
195
196 extend Forwardable
197 def_delegators :request, :params
198
199 def electrum
200 case params["currency"]
201 when "bch"
202 ELECTRUM_BCH
203 else
204 ELECTRUM
205 end
206 end
207
208 def nil_empty(s)
209 s.to_s == "" ? nil : s
210 end
211
212 def stallion_shipment
213 {
214 to_address: {
215 country_code: params["country-name"],
216 postal_code: params["postal-code"],
217 address1: nil_empty(params["street-address"]),
218 email: nil_empty(params["email"])
219 }.compact,
220 weight_unit: "g",
221 weight:
222 (params["esim_adapter_quantity"].to_i * 10) +
223 (params["pcsc_quantity"].to_i * 30),
224 size_unit: "in",
225 length: 18,
226 width: 8.5,
227 height: params["pcsc_quantity"].to_i.positive? ? 1 : 0.1,
228 package_type: "Large Envelope Or Flat",
229 items: [
230 {
231 title: "Memory Card",
232 description: "Memory Card",
233 sku: "003",
234 quantity: params["esim_adapter_quantity"].to_i,
235 value: params["currency"] == "CAD" ? 54.99 : 39.99,
236 currency: params["currency"],
237 country_of_origin: "CN"
238 },
239 {
240 title: "Card Reader",
241 description: "Card Reader",
242 sku: "002",
243 quantity: params["pcsc_quantity"].to_i,
244 value: params["currency"] == "CAD" ? 13.50 : 10,
245 currency: params["currency"],
246 country_of_origin: "CN"
247 }
248 ].select { |item| item[:quantity].positive? },
249 insured: true
250 }
251 end
252
253 def get_rates
254 uri = URI("https://ship.stallionexpress.ca/api/v4/rates")
255 Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
256 req = Net::HTTP::Post.new(uri)
257 req["Content-Type"] = "application/json"
258 req["Authorization"] = "Bearer #{ENV.fetch('STALLION_TOKEN')}"
259 body = stallion_shipment
260 req.body = body.to_json
261 JSON.parse(http.request(req).read_body)
262 end
263 end
264
265 def retail_rate(rates)
266 return unless rates&.first&.dig("total")
267
268 total = BigDecimal(rates.first["total"], 2)
269 total *= 0.75 if params["currency"] == "USD"
270 params["country-name"] == "US" ? total.round : total.ceil
271 end
272
273 route do |r|
274 r.on "electrum_notify" do
275 REDIS.lpush(
276 "exciting_#{electrum.currency}_addrs",
277 "#{params['address']}/#{params["customer_id"]}"
278 )
279
280 "OK"
281 end
282
283 r.assets
284
285 atfd = r.cookies["atfd"] || SecureRandom.uuid
286 one_year = 60 * 60 * 24 * 365
287 response.set_cookie(
288 "atfd",
289 value: atfd, expires: Time.now + one_year
290 )
291 params.delete("atfd") if params["atfd"].to_s == ""
292 antifrauds = [atfd, r.ip, params["atfd"]].compact.uniq
293
294 r.on "esim-adapter" do
295 r.get "braintree" do
296 gateway = CreditCardGateway.new(params["currency"], antifrauds)
297 if gateway.antifraud
298 next view(
299 :message,
300 locals: { message: "Please contact support" }
301 )
302 end
303 render :braintree, locals: { token: gateway.client_token }
304 end
305
306 r.get "total" do
307 next "" unless params["postal-code"].to_s != ""
308
309 resp = get_rates
310 next resp["errors"].to_a.join("<br />") unless resp["success"]
311
312 render :total, locals: resp.merge("body" => stallion_shipment)
313 end
314
315 r.post do
316 gateway = CreditCardGateway.new(params["currency"], antifrauds)
317 if gateway.antifraud
318 next view(
319 :message,
320 locals: { message: "Please contact support" }
321 )
322 end
323
324 cost = stallion_shipment[:items].map { |item| item[:quantity] * item[:value] }.sum
325 rate = retail_rate(get_rates["rates"])
326
327 unless BigDecimal(params["amount"], 2) >= (cost + rate)
328 raise "Invalid amount"
329 end
330
331 sale_result = gateway.sale(params["braintree_nonce"], params["amount"])
332
333 postal_lookup = JSON.parse(Net::HTTP.get(URI(
334 "https://app.zipcodebase.com/api/v1/search?" \
335 "apikey=#{ENV.fetch('ZIPCODEBASE_TOKEN')}&" \
336 "codes=#{params['postal-code']}&" \
337 "country=#{params['country-name']}"
338 )))
339
340 uri = URI("https://ship.stallionexpress.ca/api/v4/orders")
341 Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
342 req = Net::HTTP::Post.new(uri)
343 req["Content-Type"] = "application/json"
344 req["Authorization"] = "Bearer #{ENV.fetch('STALLION_TOKEN')}"
345 body = stallion_shipment
346 body.merge!(body.delete(:to_address))
347 body[:store_id] = ENV.fetch("STALLION_STORE_ID")
348 body[:value] = BigDecimal(params["amount"], 2)
349 body[:currency] = params["currency"]
350 body[:order_at] = Time.now.strftime("%Y-%m-%d %H:%m:%S")
351 body[:order_id] = sale_result.transaction.id
352 body[:name] = "#{params['given-name']} #{params['family-name']}"
353 body[:city] = postal_lookup["results"].values.first.first["city"]
354 body[:province_code] = postal_lookup["results"].values.first.first["state_code"]
355 req.body = body.to_json
356 resp = JSON.parse(http.request(req).read_body)
357 view :receipt, locals: resp.merge("rate" => rate)
358 end
359 rescue CreditCardGateway::ErrorResult
360 response.status = 400
361 $!.message
362 end
363
364 r.get do
365 view :esim_adapter, locals: { antifraud: atfd, ip: request.ip }
366 end
367 end
368
369 r.on :jid do |jid|
370 Sentry.set_user(id: params["customer_id"], jid: jid)
371
372 customer_id = params["customer_id"]
373 gateway = CreditCardCustomerGateway.new(jid, customer_id, antifrauds)
374 topup = AutoTopUpRepo.new
375
376 r.on "credit_cards" do
377 r.get do
378 if gateway.antifraud
379 return view(
380 :message,
381 locals: { message: "Please contact support" }
382 )
383 end
384
385 view(
386 "credit_cards",
387 locals: {
388 jid: jid,
389 token: gateway.client_token,
390 customer_id: gateway.customer_id,
391 antifraud: atfd,
392 auto_top_up: topup.find(gateway.customer_id) ||
393 (gateway.payment_methods? ? "" : "15")
394 }
395 )
396 end
397
398 r.post do
399 CardVault
400 .for(
401 gateway, params["braintree_nonce"],
402 params["amount"].to_d
403 ).call(params["auto_top_up_amount"].to_i)
404 "OK"
405 rescue ThreeDSecureRepo::Failed
406 gateway.remove_method($!.message)
407 response.status = 400
408 "hostedFieldsFieldsInvalidError"
409 rescue CreditCardGateway::ErrorResult
410 response.status = 400
411 $!.message
412 end
413 end
414 end
415 end
416end
417
418run JmpPay.freeze.app