diff --git a/.env b/.env index cf2be1d43f..6b1b17c7e5 100644 --- a/.env +++ b/.env @@ -20,10 +20,12 @@ JWT_SECRET=1c053cacaa6aa0963e211cdc7cb5b5f4bc0d99cb9b7b5aca0c1f16f104102486053e8 STRIPE_PUBLISHABLE_KEY=pk_test_aQbfVWeOwvtES5FRSY7iIjk9 STRIPE_SECRET_KEY=sk_test_Qk8TgJVXgeC1xJWu0tmwCW0l -BRAINTREE_ENVIRONMENT=sandbox -BRAINTREE_MERCHANT_ID=yxyphwjwxwn5yvtf -BRAINTREE_PUBLIC_KEY=9kb7vby5mfc28gvd -BRAINTREE_PRIVATE_KEY=87a642e593a3edf2a59b0afa1b724a00 +PAYPAL_ENVIRONMENT=sandbox +PAYPAL_CLIENT_ID=AZcJV0afwc_FYjxmfZcT_PWmX7jIQAcMiv9Z5OwivUUzO2qzQMBPN5HTITxq_tsU1C-n1Yno6GfvAM7h +PAYPAL_CLIENT_SECRET=EDTD7pSMst33o7_XySy5_Yv4KWfFnJmdsokhBAiQHD_1_xWzaHYA7NOSFihAw5OlVSNo6x_9Cq0HAqzY +PAYPAL_PRO_PLAN=P-34H53708K8545322APIWLKNY +PAYPAL_PATRON_PLAN=P-50324317G1271574TPIWMNIQ + STREAM_API_KEY=sjm3sx9mgcx2 STREAM_API_SECRET=uxupxkuw5ckrd2fe9qz88zne4qagwmdm9yaegbsm8xuymqja6ff8jr678636v3c4 diff --git a/Gemfile b/Gemfile index c1095f990a..8ad2607094 100644 --- a/Gemfile +++ b/Gemfile @@ -88,6 +88,7 @@ gem 'stream_rails', github: 'GetStream/stream-rails', gem 'typhoeus' # Parallelize scraping tasks # Cash Money +gem 'paypal-sdk-rest', '~> 1' gem 'stripe' # Rack Middleware diff --git a/Gemfile.lock b/Gemfile.lock index 4d06f9e8db..e9e4539a32 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -407,6 +407,9 @@ GEM parallel (1.12.1) paranoia (2.4.1) activerecord (>= 4.0, < 5.3) + paypal-sdk-rest (1.7.3) + multi_json (~> 1.0) + xml-simple pg (0.21.0) pg_query (1.0.2) pghero (2.2.0) @@ -663,6 +666,7 @@ GEM hashdiff webrobots (0.1.2) where-or (0.1.6) + xml-simple (1.1.5) PLATFORMS ruby @@ -724,6 +728,7 @@ DEPENDENCIES paperclip-optimizer parallel paranoia (~> 2.4) + paypal-sdk-rest (~> 1) pg pg_query pghero diff --git a/app/actions/pro/create_paypal_agreement.rb b/app/actions/pro/create_paypal_agreement.rb new file mode 100644 index 0000000000..14be53cc16 --- /dev/null +++ b/app/actions/pro/create_paypal_agreement.rb @@ -0,0 +1,36 @@ +# Create a PayPal Billing Agreement to be authorized by a user and return the token which can be +# used to authorize it on the client side. +module Pro + class CreatePaypalAgreement < Action + include PayPal::SDK::REST + + parameter :user, required: true, load: User + parameter :tier, required: true + + validates :tier, inclusion: { in: %w[pro patron] } + + def call + agreement = Agreement.new( + start_date: 30.seconds.from_now.iso8601, + payer: { payment_method: 'paypal' }, + name: "Kitsu #{tier.upcase}", + description: "Yearly Kitsu #{tier.upcase} Subscription", + plan: paypal_plan + ) + + agreement.create! + + { token: agreement.token } + end + + private + + def paypal_plan_id + ENV["PAYPAL_#{tier.upcase}_PLAN"] + end + + def paypal_plan + Plan.new(id: paypal_plan_id) + end + end +end diff --git a/app/actions/pro/subscribe_with_paypal.rb b/app/actions/pro/subscribe_with_paypal.rb new file mode 100644 index 0000000000..8bfb6035da --- /dev/null +++ b/app/actions/pro/subscribe_with_paypal.rb @@ -0,0 +1,28 @@ +# Execute a PayPal Billing Agreement based on a provided token, and set up the User's +# ProSubscription to match it. +module Pro + class SubscribeWithPayPal < Action + include PayPal::SDK::REST + + parameter :user, required: true, load: User + parameter :tier, required: true + parameter :token, required: true + + validates :tier, inclusion: { in: %w[pro patron] } + + def call + user.pro_subscription&.destroy! + + agreement = Agreement.new(token: token) + agreement.execute! + + subscription = ProSubscription::PayPalSubscription.create!( + user: user, + tier: tier, + billing_id: agreement.id + ) + + { subscription: subscription } + end + end +end diff --git a/app/models/pro_subscription.rb b/app/models/pro_subscription.rb index 3337c6fb9d..3b8c886dc7 100644 --- a/app/models/pro_subscription.rb +++ b/app/models/pro_subscription.rb @@ -6,6 +6,11 @@ class NoCancellationError < StandardError; end pro: 1, patron: 2 } + enum state: { + pending: 0, # Waiting for initial setup to complete + current: 1, # Currently up-to-date on payments + errored: 2 # Error during processing + } validates :type, presence: true validates :billing_id, presence: true diff --git a/app/models/pro_subscription/paypal_subscription.rb b/app/models/pro_subscription/paypal_subscription.rb new file mode 100644 index 0000000000..5fc0015763 --- /dev/null +++ b/app/models/pro_subscription/paypal_subscription.rb @@ -0,0 +1,19 @@ +class ProSubscription + class PayPalSubscription < ProSubscription + PayPal = PayPal::SDK::REST + + def billing_service + :paypal + end + + def agreement + @agreement ||= PayPal::Agreement.find(billing_id) + end + + alias_method :cancel!, :destroy! + + after_destroy do + agreement.cancel!(note: 'Cancelled on website') + end + end +end diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index e5b66fdc6d..78b82ca6a8 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -8,4 +8,5 @@ inflect.acronym 'AMA' inflect.acronym 'ANN' inflect.acronym 'MAL' + inflect.acronym 'PayPal' end diff --git a/config/initializers/paypal-sdk-rest.rb b/config/initializers/paypal-sdk-rest.rb new file mode 100644 index 0000000000..28ffb5ffd1 --- /dev/null +++ b/config/initializers/paypal-sdk-rest.rb @@ -0,0 +1,8 @@ +require 'paypal-sdk-rest' + +PayPal::SDK.configure( + mode: ENV['PAYPAL_ENVIRONMENT'], + client_id: ENV['PAYPAL_CLIENT_ID'], + client_secret: ENV['PAYPAL_CLIENT_SECRET'] +) +PayPal::SDK.logger = Rails.logger diff --git a/db/migrate/20190316024235_add_state_to_pro_subscriptions.rb b/db/migrate/20190316024235_add_state_to_pro_subscriptions.rb new file mode 100644 index 0000000000..41e8342f2a --- /dev/null +++ b/db/migrate/20190316024235_add_state_to_pro_subscriptions.rb @@ -0,0 +1,6 @@ +class AddStateToProSubscriptions < ActiveRecord::Migration + def change + add_column :pro_subscriptions, :state, :integer, default: 0, null: false + add_column :pro_subscriptions, :error, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index df09c79dac..f163976504 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20190223050111) do +ActiveRecord::Schema.define(version: 20190316024235) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -1327,6 +1327,8 @@ t.datetime "updated_at", null: false t.string "type", null: false t.integer "tier", default: 0, null: false + t.integer "state", default: 0, null: false + t.string "error" end add_index "pro_subscriptions", ["user_id"], name: "index_pro_subscriptions_on_user_id", using: :btree diff --git a/lib/tasks/paypal-pro.rake b/lib/tasks/paypal-pro.rake new file mode 100644 index 0000000000..2d33acec75 --- /dev/null +++ b/lib/tasks/paypal-pro.rake @@ -0,0 +1,46 @@ +namespace :paypal_pro do + desc 'Resave all paperclip attachments' + task setup: :environment do + [ + { + name: 'PRO', + cost: '19.00' + }, + { + name: 'PATRON', + cost: '49.00' + } + ].each do |tier| + PayPal::SDK.logger = Logger.new(nil) + plan = PayPal::SDK::REST::Plan.new( + name: "Kitsu #{tier[:name]}", + description: "Yearly subscription to Kitsu #{tier[:name]}", + type: 'INFINITE', + payment_definitions: [{ + name: "Kitsu #{tier[:name]} Yearly", + type: 'REGULAR', + frequency_interval: '1', + frequency: 'YEAR', + amount: { + currency: 'USD', + value: tier[:cost] + } + }], + merchant_preferences: { + cancel_url: 'https://www.paypal.com/checkoutnow/error', + return_url: 'https://www.paypal.com/checkoutnow/error', + max_fail_attempts: '0', + auto_bill_amount: 'YES', + initial_fail_amount_action: 'CANCEL' + } + ) + plan.create! + + # They won't let you activate in the same request as creation + patch = PayPal::SDK::REST::Patch.new(op: 'replace', path: '/', value: { state: 'ACTIVE' }) + plan.update!(patch) + + puts "PAYPAL_#{tier[:name]}_PLAN=#{plan.id}" + end + end +end diff --git a/spec/actions/pro/create_paypal_agreement_spec.rb b/spec/actions/pro/create_paypal_agreement_spec.rb new file mode 100644 index 0000000000..8f1b7eb615 --- /dev/null +++ b/spec/actions/pro/create_paypal_agreement_spec.rb @@ -0,0 +1,8 @@ +require 'rails_helper' + +RSpec.describe Pro::CreatePaypalAgreement do + it 'should create an agreement and return the token' do + response = Pro::CreatePaypalAgreement.call(user: build(:user), tier: 'pro') + expect(response.token).to start_with('EC-') + end +end diff --git a/spec/actions/pro/subscribe_with_paypal_spec.rb b/spec/actions/pro/subscribe_with_paypal_spec.rb new file mode 100644 index 0000000000..52e7a9c180 --- /dev/null +++ b/spec/actions/pro/subscribe_with_paypal_spec.rb @@ -0,0 +1,13 @@ +require 'rails_helper' + +RSpec.describe Pro::SubscribeWithPayPal do + it 'should execute an agreement based on a token' do + agreement = instance_double(PayPal::SDK::REST::Agreement) + expect(agreement).to receive(:execute!) + allow(agreement).to receive(:id).and_return('IH8PAYPAL') + allow(PayPal::SDK::REST::Agreement).to receive(:new).and_return(agreement) + + result = Pro::SubscribeWithPayPal.call(user: build(:user), tier: 'pro', token: 'POOP') + expect(result.subscription.billing_id).to eq('IH8PAYPAL') + end +end diff --git a/spec/models/pro_subscription/paypal_subscription_spec.rb b/spec/models/pro_subscription/paypal_subscription_spec.rb new file mode 100644 index 0000000000..0fcbdf5c6b --- /dev/null +++ b/spec/models/pro_subscription/paypal_subscription_spec.rb @@ -0,0 +1,20 @@ +require 'rails_helper' + +RSpec.describe ProSubscription::PayPalSubscription, type: :model do + describe '#billing_service' do + it 'should return :paypal' do + expect(ProSubscription::PayPalSubscription.new.billing_service).to eq(:paypal) + end + end + + context 'after destruction' do + it 'should cancel the agreement on PayPal' do + user = create(:user) + sub = ProSubscription::PayPalSubscription.create!(user: user, tier: 'pro', billing_id: 'POOP') + agreement = instance_double(PayPal::SDK::REST::Agreement) + allow(sub).to receive(:agreement).and_return(agreement) + expect(sub.agreement).to receive(:cancel!).once + sub.destroy! + end + end +end diff --git a/spec/support/webmock.rb b/spec/support/webmock.rb index 08806bf0a1..d65761d966 100644 --- a/spec/support/webmock.rb +++ b/spec/support/webmock.rb @@ -7,7 +7,8 @@ 'lorempixel.com', 'localhost', 'elasticsearch:9200', - 'oembed.com' + 'oembed.com', + 'api.sandbox.paypal.com' ]) RSpec.shared_context 'MAL CDN' do