Oh hey look it's paypal

This commit is contained in:
Andrew Brown 2020-01-15 12:37:54 -06:00
parent 786c8f5cfc
commit 4082bbae2b
19 changed files with 656 additions and 6 deletions

View File

@ -26,6 +26,8 @@ gem 'authority'
# Billing
gem 'stripe'
gem 'stripe_event'
gem 'paypal_client'
gem 'paypal-checkout-sdk'
# Design
gem 'material_icons'

View File

@ -1105,6 +1105,10 @@ GEM
factory_bot_rails (5.1.1)
factory_bot (~> 5.1.0)
railties (>= 4.2.0)
faraday (0.17.3)
multipart-post (>= 1.2, < 3)
faraday_middleware (0.13.1)
faraday (>= 0.7.4, < 1.0)
faye-websocket (0.10.9)
eventmachine (>= 0.12.0)
websocket-driver (>= 0.5.1)
@ -1233,6 +1237,7 @@ GEM
mixpanel-ruby (2.2.2)
multi_json (1.14.1)
multi_test (0.1.2)
multipart-post (2.1.1)
mustache (1.1.1)
nenv (0.3.0)
nested_form (0.3.2)
@ -1263,6 +1268,13 @@ GEM
activerecord (>= 4.0, < 6.1)
parser (2.6.5.0)
ast (~> 2.4.0)
paypal-checkout-sdk (1.0.3)
paypalhttp (~> 1.0.0)
paypal_client (0.3.1)
activesupport (> 4.2.8)
faraday (~> 0.15)
faraday_middleware (~> 0.12)
paypalhttp (1.0.0)
pg (1.2.1)
pry (0.12.2)
coderay (~> 1.1.0)
@ -1523,6 +1535,8 @@ DEPENDENCIES
newrelic_rpm
paperclip
paranoia
paypal-checkout-sdk
paypal_client
pg (~> 1.1)
pry
puma (~> 4.3)

View File

@ -1,7 +1,7 @@
class SubscriptionsController < ApplicationController
protect_from_forgery except: :stripe_webhook
before_action :authenticate_user!
before_action :authenticate_user!, except: [:redeem]
before_action :set_navbar_actions
before_action :set_sidenav_expansion
@ -32,6 +32,58 @@ class SubscriptionsController < ApplicationController
def show
end
def prepay
@invoices = current_user.paypal_invoices
.where.not(status: 'CREATED')
.includes(:page_unlock_promo_code)
.order('id desc')
promo_code_ids = @invoices.map(&:page_unlock_promo_code_id).flatten
@promo_codes = PageUnlockPromoCode.where(id: promo_code_ids)
end
def redeem
@code = PageUnlockPromoCode.find_by(code: params[:code])
end
def prepay_redirect_to_paypal
months = params[:months].to_i
# Create an invoice on Paypal to be paid
ppi = PaypalService.create_prepay_invoice(months)
# Create a mirrored invoice of our own to mark paid later
invoice = PaypalInvoice.create!(
user: current_user,
paypal_id: ppi.id,
status: ppi.status,
months: months,
amount_cents: 100 * PaypalService.months_price(months),
approval_url: ppi.links.detect { |l| l.rel == "approve" }.href
)
# Send the user off to pay!
# redirect_to PaypalService.checkout_url(invoice, prepay_path)
redirect_to invoice.approval_url
end
def capture_paypal_prepay
# request = PayPalCheckoutSdk::Orders::OrdersCaptureRequest::new("APPROVED-ORDER-ID")
# begin
# # Call API with your client and get a response for your call
# response = client.execute(request)
# # If call returns body in response, you can get the deserialized version from the result attribute of the response
# order = response.result
# puts order
# rescue PayPalHttp::HttpError => ioe
# # Something went wrong server-side
# puts ioe.status_code
# puts ioe.headers["debug_id"]
# end
end
def change
new_plan_id = params[:stripe_plan_id]
possible_plan_ids = SubscriptionService.available_plans.pluck(:stripe_plan_id)

View File

@ -0,0 +1,43 @@
class PaypalAcceptanceWaitJob < ApplicationJob
queue_as :paypal
def perform(*args)
invoice_id = args.shift
invoice = PaypalInvoice.find_by(paypal_id: invoice_id)
info = PaypalService.order_info(invoice_id)
if info[:status] == 'CREATED'
# If we're still in a CREATED state, keep requeuing for up to 24 hours
if DateTime.current <= invoice.created_at + 24.hours
PaypalAcceptanceWaitJob
.set(wait: 30.seconds)
.perform_later(invoice.paypal_id)
end
elsif info[:status] == 'APPROVED'
# Once a user has approved a payment, we need to capture that payment
unless invoice.status == 'APPROVED'
invoice.update(status: 'APPROVED')
invoice.capture_funds!
end
PaypalAcceptanceWaitJob
.set(wait: 30.seconds)
.perform_later(invoice.paypal_id)
elsif info[:status] == 'COMPLETED'
# Once a payment has been captured, generate the code for use!
unless invoice.status == 'COMPLETED'
invoice.update(status: 'COMPLETED')
invoice.generate_promo_code!
end
else
# Something unexpected happened! Wow!
invoice.update(status: info[:status])
raise info.inspect
end
end
end

View File

@ -0,0 +1,37 @@
class PaypalInvoice < ApplicationRecord
belongs_to :user
belongs_to :page_unlock_promo_code, optional: true
after_create :watch_for_approval
def next_step_url
# switch to receipt after approving
self.approval_url
end
def watch_for_approval
PaypalAcceptanceWaitJob.perform_later(self.paypal_id)
end
def capture_funds!
PaypalService.capture_invoice_funds(self.paypal_id)
end
def generate_promo_code!
self.page_unlock_promo_code = PageUnlockPromoCode.create(
code: 'PP' + (0...12).map { (65 + rand(26)).chr }.join,
page_types: Rails.application.config.content_types[:premium].map(&:name),
uses_remaining: 1,
days_active: 30 * self.months.to_i,
internal_description: "Prepaid with PayPal",
description: "Prepaid Premium subscription"
)
self.save!
end
def activateable?
self.status == 'COMPLETED' &&
page_unlock_promo_code.present? &&
page_unlock_promo_code.uses_remaining > 0
end
end

View File

@ -30,6 +30,7 @@ class User < ApplicationRecord
BillingPlan::PREMIUM_IDS.include?(self.selected_billing_plan_id) || active_promo_codes.any?
end
has_many :promotions, dependent: :destroy
has_many :paypal_invoices
has_many :image_uploads, dependent: :destroy

View File

@ -0,0 +1,138 @@
class PaypalService < Service
include PayPalCheckoutSdk::Orders
def self.create_prepay_invoice(n_months)
# Create a Paypal invoice to redirect to for payment
request = PayPalCheckoutSdk::Orders::OrdersCreateRequest::new
request.request_body({
intent: "CAPTURE",
purchase_units: [{
amount: {
currency_code: "USD",
value: PaypalService.months_price(n_months),
description: "Notebook.ai Premium (#{n_months} month#{'s' unless n_months == 1})",
payment_instruction: "some text for paymen instruction",
soft_descriptor: "soft descriptor"
}
}]
})
begin
response = PaypalService.client.execute(request)
order = response.result
order
rescue PayPalHttp::HttpError => ioe
# Something went wrong server-side
# puts ioe.status_code
# puts ioe.headers["debug_id"]
raise ioe.inspect
end
end
def self.capture_invoice_funds(invoice_id)
request = PayPalCheckoutSdk::Orders::OrdersCaptureRequest::new(invoice_id)
begin
# Call API with your client and get a response for your call
response = client.execute(request)
# If call returns body in response, you can get the deserialized version from the result attribute of the response
order = response.result
puts order
rescue PayPalHttp::HttpError => ioe
# Something went wrong server-side
puts ioe.status_code
puts ioe.headers["debug_id"]
end
end
def self.checkout_url(invoice, return_path)
app_host = Rails.env.production? ? 'https://www.notebook.ai' : 'http://localhost:3000'
paypal_host = Rails.env.production? ? 'https://www.paypal.com' : 'https://www.sandbox.paypal.com'
values = {
business: 'sb-43r7at861878@business.example.com',
cmd: "_xclick",
# image_url: 150x50 url
upload: 1,
invoice: invoice.paypal_id,
item_name: "Notebook.ai Premium",
item_number: "#{invoice.months} month#{'s' unless invoice.months == 1} of Premium",
quantity: '1',
amount: months_price(invoice.months),
no_note: 1,
no_shipping: 1,
return: "#{app_host}#{return_path}",
cancel_return: "#{app_host}/my/billing/prepay",
}
"#{paypal_host}/cgi-bin/webscr?" + values.to_query
end
def self.capture_payment(paypal_invoice_id)
resp = Faraday.post("https://api.sandbox.paypal.com/v2/checkout/orders/#{paypal_invoice_id}/capture") do |req|
# req.params['limit'] = 100
req.headers['Content-Type'] = 'application/json'
req.headers['Authorization'] = 'Basic <ARd0BEqsIN77DZvZc_R-VeuSR5jo_rYUhxao-ScHrwtBvfymH0XvizvDpd1sRykFT6arD3kDPaVaAz2s:EJPXLRDlIjxiI85O4LIma6MGISNbuwn9erZAJo3fGFvZNPmxSun7iqkAFLMtNOOwptMEai5IMrm1eRgR>'
# req.body = {query: 'salmon'}.to_json
end
end
def self.order_info(order_id)
request = OrdersGetRequest::new(order_id)
response = client::execute(request)
puts "Status Code: " + response.status_code.to_s
puts "Status: " + response.result.status
puts "Order ID: " + response.result.id
puts "Intent: " + response.result.intent
puts "Links:"
for link in response.result.links
puts "\t#{link["rel"]}: #{link["href"]}\tCall Type: #{link["method"]}"
end
puts "Gross Amount: " + response.result.purchase_units[0].amount.currency_code + response.result.purchase_units[0].amount.value
{
order_id: response.result.id,
status: response.result.status
}
end
def self.months_price(n_months)
case n_months
when 1
9.00
when 3
24.00
when 6
48.00
when 12
84.00
else
raise "Invalid month prepay: #{n_months}"
end
end
def self.client
@paypal_client ||= begin
client_id = Rails.application.config.paypal[:client_id]
client_secret = Rails.application.config.paypal[:client_secret]
environment = if Rails.env.production?
PayPal::PayPalEnvironment.new(client_id, client_secret)
else
PayPal::SandboxEnvironment.new(client_id, client_secret)
end
PayPal::PayPalHttpClient.new(environment)
end
end
end

View File

@ -227,9 +227,6 @@
</div>
</div>
</div>
<div class="row">
<div class="col s12 m12 l3 hide-on-small-only"></div>
<div class="col s12 m12 l9">
<div class="card">

View File

@ -0,0 +1,209 @@
<div class="row">
<div class="col s12 m10 offset-m1">
<div class="hoverable card">
<div class="card-content">
<div class="card-title">You can now purchase sharable Premium codes for yourself or others</div>
<p>
Making a purchase below will generate a single-use code that can be redeemed at any time for a Premium subscription on Notebook.ai.
You can purchase these codes for yourself if you'd like to prepay for a certain amount of months without a recurring monthly subscription,
or for others if you'd like to gift a Notebook.ai Premium membership to someone else! You can purchase as many codes as you'd like
and redeem them whenever you'd like.
</p>
</div>
</div>
</div>
</div>
<h5 class="center grey-text">Choose a Premium code to purchase</h5>
<div class="row">
<div class="col s12 m6">
<%= link_to prepay_paypal_gateway_path(months: 1), class: 'black-text' do %>
<div class="hoverable card blue lighten-4" style="height: 140px;">
<div class="card-content">
<div class="card-title">
<i class="material-icons left">star</i>
1 month of Premium
</div>
<p>
All Premium features
for <strong>$9.00</strong>
</p>
</div>
</div>
<% end %>
</div>
<div class="col s12 m6">
<%= link_to prepay_paypal_gateway_path(months: 3), class: 'black-text' do %>
<div class="hoverable card blue lighten-4" style="height: 140px;">
<div class="card-content">
<div class="card-title">
<i class="material-icons left">star</i>
3 months of Premium
</div>
<p>
All Premium features
for <strong>$24.00</strong><br />
(save $3.00 compared to monthly)
</p>
</div>
</div>
<% end %>
</div>
<div class="col s12 m6">
<%= link_to prepay_paypal_gateway_path(months: 6), class: 'black-text' do %>
<div class="hoverable card blue lighten-4" style="height: 140px;">
<div class="card-content">
<div class="card-title">
<i class="material-icons left">star</i>
6 months of Premium
</div>
<p>
All Premium features
for <strong>$48.00</strong><br />
(save $6.00 compared to monthly)
</p>
</div>
</div>
<% end %>
</div>
<div class="col s12 m6">
<%= link_to prepay_paypal_gateway_path(months: 12), class: 'black-text' do %>
<div class="hoverable card blue lighten-4" style="height: 140px;">
<div class="card-content">
<div class="card-title">
<i class="material-icons left">star</i>
12 months of Premium
</div>
<p>
All Premium features
for <strong>$84.00</strong><br />
(save $24.00 compared to monthly)
</p>
</div>
</div>
<% end %>
</div>
</div>
<div class="row">
<div class="col s12 m10 offset-m1">
Selecting a package will take you to Paypal to complete your purchase, where you can pay from a Paypal balance or with a credit/debit card.
Afterwards, you will be redirected here where you will find the code you've purchased listed below.
</div>
</div>
<div class="row">
<div class="col s12">
<h5 class="center grey-text">Your purchased Premium codes</h5>
<p class="center grey-text">
Please note that codes may take up to 5 minutes to appear here after purchase.
</p>
<% if current_user.on_premium_plan? && @invoices.any? %>
<div class="card-panel green">
Since you already have a Premium subscription active, you won't be able to activate any codes below.
Activation links will appear again whenever you don't have Premium active.
</div>
<% end %>
</div>
<% @invoices.each do |invoice| %>
<div class="col s12 m12 l6">
<div class="hoverable card medium <%= 'blue lighten-5' if invoice.activateable? %>">
<div class="card-content">
<div class="card-title">
<% if invoice.page_unlock_promo_code&.uses_remaining&.zero? %>
<span class="badge orange black-text">used</span>
<% end %>
<%= pluralize invoice.months, 'month' %> of Premium
</div>
<p>
<% if invoice.page_unlock_promo_code.present? %>
<% if invoice.activateable? %>
Promo code: <strong><%= invoice.page_unlock_promo_code.code %></strong>
<% end %>
<% else %>
<% if invoice.status == 'APPROVED' %>
<em>Processing payment...</em>
<% else %>
<%= link_to 'Awaiting payment', invoice.next_step_url %>
<% end %>
<% end %>
</p>
<p class="grey-text">
<% unless invoice.page_unlock_promo_code&.uses_remaining&.zero? %>
<% if invoice.status == 'CREATED' %>
Ready for payment
<% else %>
Purchased
<% end %>
<%= time_ago_in_words invoice.created_at %> ago
<% end %>
</p>
<% if invoice.page_unlock_promo_code %>
<hr style="margin: 20px 0" />
<p>
<div>
Unlocks these <%= pluralize invoice.page_unlock_promo_code.page_types.count, 'page type' %>
for <%= invoice.months * 30 %> days:
</div>
<% invoice.page_unlock_promo_code.page_types.each do |page_type| %>
<% page_type_class = page_type.constantize %>
<%= link_to polymorphic_path(page_type_class) do %>
<i class="material-icons <%= page_type_class.color %>-text tooltipped" data-tooltip="<%= page_type.pluralize %>">
<%= page_type_class.icon %>
</i>
<% end %>
<% end %>
</p>
<% end %>
</div>
<% if invoice.activateable? %>
<div class="card-action">
<%= link_to '#', class: 'btn hoverable black-text white right activator', onclick: 'return false' do %>
<i class="material-icons left">share</i>
Share
<% end %>
<% unless current_user.on_premium_plan? %>
<%= form_for :promotional_code, url: redeem_path do |form| %>
<%= form.hidden_field :promo_code, value: invoice.page_unlock_promo_code.code %>
<%= form.submit 'Activate', class: 'hoverable btn blue white-text', data: { confirm: "Are you sure you wish to activate this promo code? It can only be used once." } %>
<% end %>
<% end %>
</div>
<% else %>
<div class="card-action">
<% if invoice.page_unlock_promo_code && invoice.page_unlock_promo_code.uses_remaining.zero? %>
Activated <%= time_ago_in_words invoice.page_unlock_promo_code.promotions.last.created_at %> ago
<% else %>
<% if invoice.status == 'CREATED' %>
Ready for payment
<% else %>
Purchased
<% end %>
<%= time_ago_in_words invoice.created_at %> ago
<% end %>
</div>
<% end %>
<% if invoice.page_unlock_promo_code.present? %>
<div class="card-reveal">
<span class="card-title grey-text text-darken-4">
Share this code
<i class="material-icons right">close</i>
</span>
<p>
Anyone you send this code to can redeem it for their own account, activating the
<%= pluralize invoice.months, 'month' %> of Premium immediately. To share it, send
the following link with them:
</p>
<p>
<input type="text" value="<%= gift_code_url(code: invoice.page_unlock_promo_code.code, referral: current_user.referral_code.code) %>" onClick="this.select();">
</p>
<p>
If they don't already have a Notebook.ai account, you'll also be credited with their referral.
</p>
</div>
<% end %>
</div>
</div>
<% end %>
</div>

View File

@ -0,0 +1,89 @@
<% if @code.nil? %>
<div class="red white-text card-panel center">
This code is no longer valid.
</div>
<% elsif @code.uses_remaining.zero?%>
<div class="green white-text card-panel center">
This code has been activated!
</div>
<div class="center">
<%= link_to 'Click here to customize your notebook pages', customization_content_types_path, class: 'btn btn-large blue white-text' %>
</div>
<% else %>
<h1 class="animated zoomInUp center">
Someone has gifted you<br />
<strong><%= pluralize(@code.days_active / 30, 'month') %></strong><br />
of Notebook.ai Premium!
</h1>
<div class="row">
<div class="col s12">
<div class="hoverable card">
<div class="card-content">
<div class="card-title">Activate your free Premium benefits</div>
<p>
You can activate your Premium benefits by clicking "Activate" below.
Your account will be immediately upgraded for <%= pluralize @code.days_active, 'day' %>
and will automatically downgrade back to the free tier afterwards instead of renewing monthly.
This code can only be used <%= pluralize @code.uses_remaining, 'time' %>, so thank your friend!
</p>
</div>
</div>
</div>
<% if user_signed_in? %>
<% if current_user.on_premium_plan? %>
<div class="col s12">
<div class="card-panel green white-text">
<p>
Since you already have a Premium subscription active, you can't activate this code yet.
An activation link will appear again whenever you don't have Premium active.
</p>
<p>
Don't worry&mdash;codes don't expire!
</p>
</div>
</div>
<% else %>
<%= form_for :promotional_code, url: redeem_path do |form| %>
<%= form.hidden_field :promo_code, value: @code.code %>
<%= form.submit 'Activate', class: 'hoverable btn btn-large blue white-text col s12', data: { confirm: "Are you sure you wish to activate this promo code? It can only be used once." } %>
<% end %>
<% end %>
<% else %>
<div class="center">
Before activating your Premium, please either create a Notebook.ai account or log in to an existing one.<br />
</div>
<div class="col s12 m6">
<%= link_to 'Create an account', new_user_registration_path, class: 'hoverable btn-large btn blue white-text', style: 'width: 100%' %>
</div>
<div class="col s12 m6">
<%= link_to 'Log in', new_user_session_path, class: 'hoverable btn-large btn blue white-text', style: 'width: 100%' %>
</div>
<% end %>
<% unless user_signed_in? %>
<div class="center">
When you're logged in, return to this link so you can redeem this code!
</div>
<% end %>
</div>
<div style="font-size: 1.5em; padding-top: 100px; font-weight: bold" id="how-to-redeem">Notebook pages available with Premium</div>
<p>
Create any of these pages &mdash; and as many as you need &mdash; to start putting ideas to paper and flesh your world out one piece at a time.
Each page comes with a fully-customizable template that will ask you questions to get you started, but stay out of your way when you've got your
momentum.
</p>
<div class="row">
<% (Rails.application.config.content_types[:all]).each do |content_type| %>
<div class="col l4 m6 s12">
<%= render partial: 'cards/intros/content_type_intro', locals: { content_type: content_type } %>
</div>
<% end %>
</div>
<% end %>

View File

@ -1 +1 @@
Rails.application.config.mixpanel_token = ENV.fetch('MIXPANEL_TOKEN', 'test')
Rails.application.config.mixpanel_token = ENV.fetch('MIXPANEL_TOKEN', 'test')

View File

@ -0,0 +1,4 @@
Rails.application.config.paypal = {
client_id: ENV.fetch('PAYPAL_CLIENT_ID', ''),
client_secret: ENV.fetch('PAYPAL_SECRET', '')
}

View File

@ -49,6 +49,9 @@ Rails.application.routes.draw do
get '/subscription', to: 'subscriptions#new', as: :subscription
get '/history', to: 'subscriptions#history', as: :billing_history
get '/referrals', to: 'subscriptions#referrals', as: :referrals
get '/prepay', to: 'subscriptions#prepay', as: :prepay
get '/prepay_redirect_to_paypal', to: 'subscriptions#prepay_redirect_to_paypal', as: :prepay_paypal_gateway
get '/gift/:code', to: 'subscriptions#redeem', as: :gift_code
get '/to/:stripe_plan_id', to: 'subscriptions#change', as: :change_subscription

View File

@ -0,0 +1,13 @@
class CreatePaypalInvoices < ActiveRecord::Migration[6.0]
def change
create_table :paypal_invoices do |t|
t.string :paypal_id
t.string :status
t.references :user, null: false, foreign_key: true
t.integer :months
t.integer :amount_cents
t.timestamps
end
end
end

View File

@ -0,0 +1,5 @@
class AddPromoCodeLinkToPayoPaypalInvoice < ActiveRecord::Migration[6.0]
def change
add_reference :paypal_invoices, :page_unlock_promo_code, foreign_key: true
end
end

View File

@ -0,0 +1,5 @@
class AddApprovalLinkToPaypalInvoices < ActiveRecord::Migration[6.0]
def change
add_column :paypal_invoices, :approval_url, :string
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2020_01_03_190122) do
ActiveRecord::Schema.define(version: 2020_01_16_072334) do
create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false
@ -1664,6 +1664,20 @@ ActiveRecord::Schema.define(version: 2020_01_03_190122) do
t.integer "past_owner_id"
end
create_table "paypal_invoices", force: :cascade do |t|
t.string "paypal_id"
t.string "status"
t.integer "user_id", null: false
t.integer "months"
t.integer "amount_cents"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.integer "page_unlock_promo_code_id"
t.string "approval_url"
t.index ["page_unlock_promo_code_id"], name: "index_paypal_invoices_on_page_unlock_promo_code_id"
t.index ["user_id"], name: "index_paypal_invoices_on_user_id"
end
create_table "planet_countries", force: :cascade do |t|
t.integer "user_id"
t.integer "planet_id"
@ -2858,6 +2872,8 @@ ActiveRecord::Schema.define(version: 2020_01_03_190122) do
add_foreign_key "location_notable_towns", "users"
add_foreign_key "notice_dismissals", "users"
add_foreign_key "page_tags", "users"
add_foreign_key "paypal_invoices", "page_unlock_promo_codes"
add_foreign_key "paypal_invoices", "users"
add_foreign_key "planet_countries", "countries"
add_foreign_key "planet_countries", "planets"
add_foreign_key "planet_countries", "users"

15
test/fixtures/paypal_invoices.yml vendored Normal file
View File

@ -0,0 +1,15 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
paypal_id: MyString
status: MyString
user: one
months: 1
amount_cents: 1
two:
paypal_id: MyString
status: MyString
user: two
months: 1
amount_cents: 1

View File

@ -0,0 +1,7 @@
require 'test_helper'
class PaypalInvoiceTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end