From 821ca9d76a0bfc0fa2617ece9a0f5e434e07c601 Mon Sep 17 00:00:00 2001 From: Seth Call Date: Fri, 3 Apr 2015 15:34:12 -0500 Subject: [PATCH] * VRFS-2875 - sales record keeping as well as recurly hook processing --- admin/app/admin/jam_track_right.rb | 2 +- admin/app/admin/recurly_health.rb | 15 ++ db/manifest | 3 +- db/up/sales.sql | 49 ++++ ruby/lib/jam_ruby.rb | 3 + .../models/recurly_transaction_web_hook.rb | 77 ++++++ ruby/lib/jam_ruby/models/sale.rb | 32 +++ ruby/lib/jam_ruby/models/sale_line_item.rb | 41 +++ ruby/lib/jam_ruby/models/user.rb | 3 + ruby/lib/jam_ruby/recurly_client.rb | 33 +-- ruby/spec/factories.rb | 26 ++ .../recurly_transaction_web_hook_spec.rb | 244 ++++++++++++++++++ ruby/spec/jam_ruby/models/sale_spec.rb | 72 ++++++ ruby/spec/jam_ruby/recurly_client_spec.rb | 45 +++- .../stylesheets/client/jamkazam.css.scss | 2 +- .../landings/landing_page_new.css.scss | 4 + web/app/controllers/api_recurly_controller.rb | 22 +- .../api_recurly_web_hook_controller.rb | 30 +++ web/config/application.rb | 3 + web/config/routes.rb | 3 + .../api_recurly_web_hook_controller_spec.rb | 109 ++++++++ web/spec/features/checkout_spec.rb | 52 ++++ 22 files changed, 835 insertions(+), 35 deletions(-) create mode 100644 admin/app/admin/recurly_health.rb create mode 100644 db/up/sales.sql create mode 100644 ruby/lib/jam_ruby/models/recurly_transaction_web_hook.rb create mode 100644 ruby/lib/jam_ruby/models/sale.rb create mode 100644 ruby/lib/jam_ruby/models/sale_line_item.rb create mode 100644 ruby/spec/jam_ruby/models/recurly_transaction_web_hook_spec.rb create mode 100644 ruby/spec/jam_ruby/models/sale_spec.rb create mode 100644 web/app/controllers/api_recurly_web_hook_controller.rb create mode 100644 web/spec/controllers/api_recurly_web_hook_controller_spec.rb diff --git a/admin/app/admin/jam_track_right.rb b/admin/app/admin/jam_track_right.rb index 3c591234a..48da8231a 100644 --- a/admin/app/admin/jam_track_right.rb +++ b/admin/app/admin/jam_track_right.rb @@ -64,7 +64,7 @@ ActiveAdmin.register JamRuby::JamTrackRight, :as => 'JamTrackRights' do begin client.find_or_create_account(user, billing_info) - client.place_order(user, jam_track, nil) + client.place_order(user, jam_track, nil, nil) rescue RecurlyClientError=>x redirect_to admin_jam_track_rights_path, notice: "Could not order #{jam_track} for #{user.to_s}: #{x.errors.inspect}" else diff --git a/admin/app/admin/recurly_health.rb b/admin/app/admin/recurly_health.rb new file mode 100644 index 000000000..518648403 --- /dev/null +++ b/admin/app/admin/recurly_health.rb @@ -0,0 +1,15 @@ +ActiveAdmin.register_page "Recurly Health" do + menu :parent => 'Misc' + + content :title => "Recurly Transaction Totals" do + table_for Sale.check_integrity do + column "Total", :total + column "Unknown", :not_known + column "Successes", :succeeded + column "Failures", :failed + column "Refunds", :refunded + column "Voids", :voided + end + end + +end \ No newline at end of file diff --git a/db/manifest b/db/manifest index 1d16c74df..1294b5a96 100755 --- a/db/manifest +++ b/db/manifest @@ -272,4 +272,5 @@ jam_track_id_to_varchar.sql drop_position_unique_jam_track.sql recording_client_metadata.sql preview_support_mp3.sql -jam_track_duration.sql \ No newline at end of file +jam_track_duration.sql +sales.sql \ No newline at end of file diff --git a/db/up/sales.sql b/db/up/sales.sql new file mode 100644 index 000000000..fc8f118a6 --- /dev/null +++ b/db/up/sales.sql @@ -0,0 +1,49 @@ +CREATE TABLE sales ( + id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id VARCHAR(64) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + order_total DECIMAL NOT NULL DEFAULT 0, + shipping_info JSON, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE sale_line_items ( + id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4(), + product_type VARCHAR NOT NULL, + product_id VARCHAR(64), + unit_price DECIMAL NOT NULL, + quantity INTEGER NOT NULL, + free INTEGER NOT NULL, + sales_tax DECIMAL, + shipping_handling DECIMAL NOT NULL, + recurly_plan_code VARCHAR NOT NULL, + recurly_subscription_uuid VARCHAR, + sale_id VARCHAR(64) NOT NULL REFERENCES sales(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE recurly_transaction_web_hooks ( + id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4(), + recurly_transaction_id VARCHAR NOT NULL, + transaction_type VARCHAR NOT NULL, + subscription_id VARCHAR NOT NULL, + action VARCHAR NOT NULL, + status VARCHAR NOT NULL, + amount_in_cents INT, + user_id VARCHAR(64) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + invoice_id VARCHAR, + invoice_number_prefix VARCHAR, + invoice_number INTEGER, + message VARCHAR, + reference VARCHAR, + transaction_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + + + +CREATE UNIQUE INDEX sale_line_items_recurly_subscription_uuid_ndx ON sale_line_items(recurly_subscription_uuid); +CREATE INDEX recurly_transaction_web_hooks_subscription_id_ndx ON recurly_transaction_web_hooks(subscription_id); +CREATE UNIQUE INDEX jam_track_rights_recurly_subscription_uuid_ndx ON jam_track_rights(recurly_subscription_uuid); + diff --git a/ruby/lib/jam_ruby.rb b/ruby/lib/jam_ruby.rb index 6d61d32a4..da4fc6a48 100755 --- a/ruby/lib/jam_ruby.rb +++ b/ruby/lib/jam_ruby.rb @@ -206,6 +206,9 @@ require "jam_ruby/models/jam_company" require "jam_ruby/models/user_sync" require "jam_ruby/models/video_source" require "jam_ruby/models/text_message" +require "jam_ruby/models/sale" +require "jam_ruby/models/sale_line_item" +require "jam_ruby/models/recurly_transaction_web_hook" require "jam_ruby/jam_tracks_manager" require "jam_ruby/jam_track_importer" require "jam_ruby/jmep_manager" diff --git a/ruby/lib/jam_ruby/models/recurly_transaction_web_hook.rb b/ruby/lib/jam_ruby/models/recurly_transaction_web_hook.rb new file mode 100644 index 000000000..0139cba9e --- /dev/null +++ b/ruby/lib/jam_ruby/models/recurly_transaction_web_hook.rb @@ -0,0 +1,77 @@ +module JamRuby + class RecurlyTransactionWebHook < ActiveRecord::Base + + belongs_to :user, class_name: 'JamRuby::User' + + validates :recurly_transaction_id, presence: true + validates :subscription_id, presence: true + validates :action, presence: true + validates :status, presence: true + validates :amount_in_cents, numericality: {only_integer: true} + validates :user, presence: true + + SUCCESSFUL_PAYMENT = 'payment' + FAILED_PAYMENT = 'failed_payment' + REFUND = 'refund' + VOID = 'void' + + def self.is_transaction_web_hook?(document) + + return false if document.root.nil? + case document.root.name + when 'successful_payment_notification' + true + when 'successful_refund_notification' + true + when 'failed_payment_notification' + true + when 'void_payment_notification' + true + else + false + end + end + + # see spec for examples of XML + def self.create_from_xml(document) + + transaction = RecurlyTransactionWebHook.new + + case document.root.name + when 'successful_payment_notification' + transaction.transaction_type = SUCCESSFUL_PAYMENT + when 'successful_refund_notification' + transaction.transaction_type = REFUND + when 'failed_payment_notification' + transaction.transaction_type = FAILED_PAYMENT + when 'void_payment_notification' + transaction.transaction_type = VOID + else + raise 'unknown document type ' + document.root.name + end + + transaction.recurly_transaction_id = document.at_css('transaction id').content + transaction.user_id = document.at_css('account account_code').content + transaction.subscription_id = document.at_css('subscription_id').content + transaction.invoice_id = document.at_css('invoice_id').content + transaction.invoice_number_prefix = document.at_css('invoice_number_prefix').content + transaction.invoice_number = document.at_css('invoice_number').content + transaction.action = document.at_css('action').content + transaction.status = document.at_css('status').content + transaction.transaction_at = Time.parse(document.at_css('date').content) + transaction.amount_in_cents = document.at_css('amount_in_cents').content + transaction.reference = document.at_css('reference').content + transaction.message = document.at_css('message').content + + transaction.save! + + # now that we have the transaction saved, we also need to delete the jam_track_right if this is a refund, or voided + + if transaction.transaction_type == 'refund' || transaction.transaction_type == 'void' + right = JamTrackRight.find_by_recurly_subscription_uuid(transaction.subscription_id) + right.destroy if right + end + transaction + end + end +end diff --git a/ruby/lib/jam_ruby/models/sale.rb b/ruby/lib/jam_ruby/models/sale.rb new file mode 100644 index 000000000..63f42657b --- /dev/null +++ b/ruby/lib/jam_ruby/models/sale.rb @@ -0,0 +1,32 @@ +module JamRuby + + # a sale is created every time someone tries to buy something + class Sale < ActiveRecord::Base + + belongs_to :user, class_name: 'JamRuby::User' + has_many :sale_line_items, class_name: 'JamRuby::SaleLineItem' + + validates :order_total, numericality: { only_integer: false } + validates :user, presence: true + + def self.create(user) + sale = Sale.new + sale.user = user + sale.order_total = 0 + sale.save + sale + end + + def self.check_integrity + SaleLineItem.select([:total, :not_known, :succeeded, :failed, :refunded, :voided]).find_by_sql( + "SELECT COUNT(sale_line_items.id) AS total, + COUNT(CASE WHEN transactions.id IS NULL THEN 1 ELSE null END) not_known, + COUNT(CASE WHEN transactions.transaction_type = '#{RecurlyTransactionWebHook::SUCCESSFUL_PAYMENT}' THEN 1 ELSE null END) succeeded, + COUNT(CASE WHEN transactions.transaction_type = '#{RecurlyTransactionWebHook::FAILED_PAYMENT}' THEN 1 ELSE null END) failed, + COUNT(CASE WHEN transactions.transaction_type = '#{RecurlyTransactionWebHook::REFUND}' THEN 1 ELSE null END) refunded, + COUNT(CASE WHEN transactions.transaction_type = '#{RecurlyTransactionWebHook::VOID}' THEN 1 ELSE null END) voided + FROM sale_line_items + LEFT OUTER JOIN recurly_transaction_web_hooks as transactions ON subscription_id = recurly_subscription_uuid") + end + end +end \ No newline at end of file diff --git a/ruby/lib/jam_ruby/models/sale_line_item.rb b/ruby/lib/jam_ruby/models/sale_line_item.rb new file mode 100644 index 000000000..f90b460fa --- /dev/null +++ b/ruby/lib/jam_ruby/models/sale_line_item.rb @@ -0,0 +1,41 @@ +module JamRuby + class SaleLineItem < ActiveRecord::Base + + belongs_to :sale, class_name: 'JamRuby::Sale' + belongs_to :jam_track, class_name: 'JamRuby::JamTrack' + belongs_to :jam_track_right, class_name: 'JamRuby::JamTrackRight' + + JAMBLASTER = 'JamBlaster' + JAMCLOUD = 'JamCloud' + JAMTRACK = 'JamTrack' + + validates :product_type, inclusion: {in: [JAMBLASTER, JAMCLOUD, JAMTRACK]} + validates :unit_price, numericality: {only_integer: false} + validates :quantity, numericality: {only_integer: true} + validates :free, numericality: {only_integer: true} + validates :sales_tax, numericality: {only_integer: false}, allow_nil: true + validates :shipping_handling, numericality: {only_integer: false} + validates :recurly_plan_code, presence:true + validates :sale, presence:true + + def self.create_from_shopping_cart(sale, shopping_cart, recurly_subscription_uuid) + product_info = shopping_cart.product_info + + sale.order_total = sale.order_total + product_info[:total_price] + + sale_line_item = SaleLineItem.new + sale_line_item.product_type = shopping_cart.cart_type + sale_line_item.unit_price = product_info[:price] + sale_line_item.quantity = product_info[:quantity] + sale_line_item.free = product_info[:marked_for_redeem] + sale_line_item.sales_tax = nil + sale_line_item.shipping_handling = 0 + sale_line_item.recurly_plan_code = product_info[:plan_code] + sale_line_item.product_id = shopping_cart.cart_id + sale_line_item.recurly_subscription_uuid = recurly_subscription_uuid + sale_line_item.sale = sale + sale_line_item.save + sale_line_item + end + end +end \ No newline at end of file diff --git a/ruby/lib/jam_ruby/models/user.rb b/ruby/lib/jam_ruby/models/user.rb index 8cc54d7b4..4d9b3b21a 100644 --- a/ruby/lib/jam_ruby/models/user.rb +++ b/ruby/lib/jam_ruby/models/user.rb @@ -159,6 +159,9 @@ module JamRuby # score history has_many :from_score_histories, :class_name => "JamRuby::ScoreHistory", foreign_key: 'from_user_id' has_many :to_score_histories, :class_name => "JamRuby::ScoreHistory", foreign_key: 'to_user_id' + has_many :sales, :class_name => 'JamRuby::Sale', dependent: :destroy + has_many :recurly_transaction_web_hooks, :class_name => 'JamRuby::RecurlyTransactionWebHook', dependent: :destroy + # This causes the authenticate method to be generated (among other stuff) #has_secure_password diff --git a/ruby/lib/jam_ruby/recurly_client.rb b/ruby/lib/jam_ruby/recurly_client.rb index 516aa7235..ce9f44f9f 100644 --- a/ruby/lib/jam_ruby/recurly_client.rb +++ b/ruby/lib/jam_ruby/recurly_client.rb @@ -1,7 +1,8 @@ require 'recurly' module JamRuby class RecurlyClient - def initialize() + def initialize() + @log = Logging.logger[self] end def create_account(current_user, billing_info) @@ -66,7 +67,8 @@ module JamRuby if(account.present?) begin account.transactions.find_each do |transaction| - if transaction.amount_in_cents > 0 # Account creation adds a transaction record + # XXX this isn't correct because we create 0 dollar transactions too (for free stuff) + #if transaction.amount_in_cents > 0 # Account creation adds a transaction record payments << { :created_at => transaction.created_at, :amount_in_cents => transaction.amount_in_cents, @@ -74,7 +76,7 @@ module JamRuby :payment_method => transaction.payment_method, :reference => transaction.reference } - end + #end end rescue Recurly::Error, NoMethodError => x raise RecurlyClientError, x.to_s @@ -175,7 +177,7 @@ module JamRuby raise RecurlyClientError.new(plan.errors) if plan.errors.any? end - def place_order(current_user, jam_track, shopping_cart) + def place_order(current_user, jam_track, shopping_cart, sale) jam_track_right = nil account = get_account(current_user) if (account.present?) @@ -203,29 +205,30 @@ module JamRuby raise RecurlyClientError.new(subscription.errors) if subscription.errors.any? + # add a line item for the sale + sale_line_item = SaleLineItem.create_from_shopping_cart(sale, shopping_cart, subscription.uuid) + + unless sale_line_item.valid? + @log.error("sale item invalid! #{sale_line_item.errors.inspect}") + puts("sale item invalid! #{sale_line_item.errors.inspect}") + Stats.write('web.recurly.purchase.sale_invalid', {message: sale.errors.to_s, value:1}) + end + # delete from shopping cart the subscription shopping_cart.destroy if shopping_cart - # Reload and make sure it went through: - account = get_account(current_user) - - account.subscriptions.find_each do |subscription| - if subscription.plan.plan_code == jam_track.plan_code - recurly_subscription_uuid = subscription.uuid - break - end - end + recurly_subscription_uuid = subscription.uuid end - raise RecurlyClientError, "Plan code '#{paid_subscription.plan_code}' doesn't match jam track: '#{jam_track.plan_code}'" unless recurly_subscription_uuid + #raise RecurlyClientError, "Plan code '#{paid_subscription.plan_code}' doesn't match jam track: '#{jam_track.plan_code}'" unless recurly_subscription_uuid jam_track_right = JamRuby::JamTrackRight.find_or_create_by_user_id_and_jam_track_id(current_user.id, jam_track.id) do |jam_track_right| jam_track_right.redeemed = free end + # also if the purchase was a free one, then update the user record to no longer allow redeemed jamtracks User.where(id: current_user.id).update_all(has_redeemable_jamtrack: false) if free - # also if the purchase was a free one, then update the user record to no longer allow redeemed jamtracks # this can't go in the block above, as it's here to fix bad subscription UUIDs in an update path if jam_track_right.recurly_subscription_uuid != recurly_subscription_uuid jam_track_right.recurly_subscription_uuid = recurly_subscription_uuid diff --git a/ruby/spec/factories.rb b/ruby/spec/factories.rb index 74f0fb924..1f4e3c662 100644 --- a/ruby/spec/factories.rb +++ b/ruby/spec/factories.rb @@ -769,4 +769,30 @@ FactoryGirl.define do bpm 120 tap_in_count 3 end + + factory :sale, :class => JamRuby::Sale do + order_total 0 + association :user, factory:user + end + + factory :recurly_transaction_web_hook, :class => JamRuby::RecurlyTransactionWebHook do + + transaction_type JamRuby::RecurlyTransactionWebHook::SUCCESSFUL_PAYMENT + sequence(:recurly_transaction_id ) { |n| "recurly-transaction-id-#{n}" } + sequence(:subscription_id ) { |n| "subscription-id-#{n}" } + sequence(:invoice_id ) { |n| "invoice-id-#{n}" } + sequence(:invoice_number ) { |n| 1000 + n } + invoice_number_prefix nil + action 'purchase' + status 'success' + transaction_at Time.now + amount_in_cents 199 + reference 100000 + message 'meh' + association :user, factory: :user + + factory :recurly_transaction_web_hook_failed do + transaction_type JamRuby::RecurlyTransactionWebHook::FAILED_PAYMENT + end + end end diff --git a/ruby/spec/jam_ruby/models/recurly_transaction_web_hook_spec.rb b/ruby/spec/jam_ruby/models/recurly_transaction_web_hook_spec.rb new file mode 100644 index 000000000..3f69ce5eb --- /dev/null +++ b/ruby/spec/jam_ruby/models/recurly_transaction_web_hook_spec.rb @@ -0,0 +1,244 @@ +require 'spec_helper' + +# verifies that all webhooks work, except for the failed_payment_notification hook, since I don't have an example of it. +# because the other 3 types work, I feel pretty confident it will work + +# testing with CURL: +# curl -X POST -d @filename.txt http://localhost:3000/api/recurly/webhook --header "Content-Type:text/xml" --user monkeytoesspeartoss:frizzyfloppymushface +# where @filename.txt is either empty (creates no row), or the contents of one of the create_from_xml tests below (replacing the account_code with a real user_id in our system) + +describe RecurlyTransactionWebHook do + + let(:refund_xml) {' + + + 56d5b2c6-2a4b-46e4-a984-ec1fbe83a50d + + sethcall@gmail.com + Seth + Call + + + + 2de439790e8fceb7fc385a4a89b89883 + 2da71ad9c657adf9fe618e4f058c78bb + + 1033 + 2da71ad97c826a7b784c264ac59c04de + refund + 2015-04-01T14:41:40Z + 216 + success + Successful test transaction + 3819545 + subscription + + Street address and postal code match. + + + true + true + false + +' + } + + let(:void_xml) { +' + + + 56d5b2c6-2a4b-46e4-a984-ec1fbe83a50d + + sethcall@gmail.com + Seth + Call + + + + 2de4370332f709c768313d4f47a9af1d + 2da71ad9c657adf9fe618e4f058c78bb + + 1033 + 2da71ad97c826a7b784c264ac59c04de + refund + 2015-04-01T14:38:59Z + 216 + void + Successful test transaction + 3183996 + subscription + + Street address and postal code match. + + + true + false + false + +' + } + + let(:success_xml) { +' + + + 56d5b2c6-2a4b-46e4-a984-ec1fbe83a50d + + seth@jamkazam.com + Seth + Call + + + + 2de4448533db12d6d92b4c4b4e90a4f1 + 2de44484fa4528b504555f43ac8bf42f + + 1037 + 2de44484b460d95863799a431383b165 + purchase + 2015-04-01T14:53:44Z + 216 + success + Successful test transaction + 6249355 + subscription + + Street address and postal code match. + + + true + true + true + +' + } + describe "sales integrity maintanence" do + + before(:each) do + @user = FactoryGirl.create(:user, id: '56d5b2c6-2a4b-46e4-a984-ec1fbe83a50d') + end + + it "deletes jam_track_right when refunded" do + + # create a jam_track right, which should be whacked as soon as we craete the web hook + jam_track_right = FactoryGirl.create(:jam_track_right, user: @user, recurly_subscription_uuid: '2da71ad97c826a7b784c264ac59c04de') + + document = Nokogiri::XML(refund_xml) + + RecurlyTransactionWebHook.create_from_xml(document) + + JamTrackRight.find_by_id(jam_track_right.id).should be_nil + end + + it "deletes jam_track_right when voided" do + # create a jam_track right, which should be whacked as soon as we craete the web hook + jam_track_right = FactoryGirl.create(:jam_track_right, user: @user, recurly_subscription_uuid: '2da71ad97c826a7b784c264ac59c04de') + + document = Nokogiri::XML(void_xml) + + RecurlyTransactionWebHook.create_from_xml(document) + + JamTrackRight.find_by_id(jam_track_right.id).should be_nil + end + end + + + describe "is_transaction_web_hook?" do + + it "successful payment" do + document = Nokogiri::XML('') + RecurlyTransactionWebHook.is_transaction_web_hook?(document).should be_true + end + + it "successful refund" do + document = Nokogiri::XML('') + RecurlyTransactionWebHook.is_transaction_web_hook?(document).should be_true + end + + it "failed payment" do + document = Nokogiri::XML('') + RecurlyTransactionWebHook.is_transaction_web_hook?(document).should be_true + end + + it "void" do + document = Nokogiri::XML('') + RecurlyTransactionWebHook.is_transaction_web_hook?(document).should be_true + end + + it "not a transaction web hook" do + document = Nokogiri::XML('') + RecurlyTransactionWebHook.is_transaction_web_hook?(document).should be_false + end + end + describe "create_from_xml" do + + before(:each) do + @user = FactoryGirl.create(:user, id: '56d5b2c6-2a4b-46e4-a984-ec1fbe83a50d') + end + + it "successful payment" do + + document = Nokogiri::XML(success_xml) + + transaction = RecurlyTransactionWebHook.create_from_xml(document) + transaction.valid?.should be_true + transaction.user.should eq(@user) + transaction.transaction_type.should eq('payment') + transaction.subscription_id.should eq('2de44484b460d95863799a431383b165') + transaction.invoice_id.should eq('2de44484fa4528b504555f43ac8bf42f') + transaction.invoice_number_prefix.should eq('') + transaction.invoice_number.should eq(1037) + transaction.recurly_transaction_id.should eq('2de4448533db12d6d92b4c4b4e90a4f1') + transaction.action.should eq('purchase') + transaction.transaction_at.should eq(Time.parse('2015-04-01T14:53:44Z')) + transaction.amount_in_cents.should eq(216) + transaction.status.should eq('success') + transaction.message.should eq('Successful test transaction') + transaction.reference.should eq('6249355') + end + + it "successful refund" do + document = Nokogiri::XML(refund_xml) + + transaction = RecurlyTransactionWebHook.create_from_xml(document) + transaction.valid?.should be_true + transaction.user.should eq(@user) + transaction.transaction_type.should eq('refund') + transaction.subscription_id.should eq('2da71ad97c826a7b784c264ac59c04de') + transaction.invoice_id.should eq('2da71ad9c657adf9fe618e4f058c78bb') + transaction.invoice_number_prefix.should eq('') + transaction.invoice_number.should eq(1033) + transaction.recurly_transaction_id.should eq('2de439790e8fceb7fc385a4a89b89883') + transaction.action.should eq('refund') + transaction.transaction_at.should eq(Time.parse('2015-04-01T14:41:40Z')) + transaction.amount_in_cents.should eq(216) + transaction.status.should eq('success') + transaction.message.should eq('Successful test transaction') + transaction.reference.should eq('3819545') + + end + + it "successful void" do + document = Nokogiri::XML(void_xml) + + transaction = RecurlyTransactionWebHook.create_from_xml(document) + transaction.valid?.should be_true + transaction.user.should eq(@user) + transaction.transaction_type.should eq('void') + transaction.subscription_id.should eq('2da71ad97c826a7b784c264ac59c04de') + transaction.invoice_id.should eq('2da71ad9c657adf9fe618e4f058c78bb') + transaction.invoice_number_prefix.should eq('') + transaction.invoice_number.should eq(1033) + transaction.recurly_transaction_id.should eq('2de4370332f709c768313d4f47a9af1d') + transaction.action.should eq('refund') + transaction.transaction_at.should eq(Time.parse('2015-04-01T14:38:59Z')) + transaction.amount_in_cents.should eq(216) + transaction.status.should eq('void') + transaction.message.should eq('Successful test transaction') + transaction.reference.should eq('3183996') + end + end +end + +# https://github.com/killbilling/recurly-java-library/blob/master/src/main/java/com/ning/billing/recurly/model/push/payment/FailedPaymentNotification.java +# failed_payment_notification \ No newline at end of file diff --git a/ruby/spec/jam_ruby/models/sale_spec.rb b/ruby/spec/jam_ruby/models/sale_spec.rb new file mode 100644 index 000000000..0c9b089b1 --- /dev/null +++ b/ruby/spec/jam_ruby/models/sale_spec.rb @@ -0,0 +1,72 @@ + +require 'spec_helper' + +describe Sale do + + describe "check_integrity" do + + let(:user) {FactoryGirl.create(:user)} + let(:jam_track) {FactoryGirl.create(:jam_track)} + + it "empty" do + check_integrity = Sale.check_integrity + check_integrity.length.should eq(1) + r = check_integrity[0] + r.total.to_i.should eq(0) + r.not_known.to_i.should eq(0) + r.succeeded.to_i.should eq(0) + r.failed.to_i.should eq(0) + r.refunded.to_i.should eq(0) + r.voided.to_i.should eq(0) + end + + it "one unknown sale" do + sale = Sale.create(user) + shopping_cart = ShoppingCart.create(user, jam_track) + SaleLineItem.create_from_shopping_cart(sale, shopping_cart, 'some_recurly_uuid') + + check_integrity = Sale.check_integrity + r = check_integrity[0] + r.total.to_i.should eq(1) + r.not_known.to_i.should eq(1) + r.succeeded.to_i.should eq(0) + r.failed.to_i.should eq(0) + r.refunded.to_i.should eq(0) + r.voided.to_i.should eq(0) + end + + it "one succeeded sale" do + sale = Sale.create(user) + shopping_cart = ShoppingCart.create(user, jam_track) + SaleLineItem.create_from_shopping_cart(sale, shopping_cart, 'some_recurly_uuid') + FactoryGirl.create(:recurly_transaction_web_hook, subscription_id: 'some_recurly_uuid') + + + check_integrity = Sale.check_integrity + r = check_integrity[0] + r.total.to_i.should eq(1) + r.not_known.to_i.should eq(0) + r.succeeded.to_i.should eq(1) + r.failed.to_i.should eq(0) + r.refunded.to_i.should eq(0) + r.voided.to_i.should eq(0) + end + + it "one failed sale" do + sale = Sale.create(user) + shopping_cart = ShoppingCart.create(user, jam_track) + SaleLineItem.create_from_shopping_cart(sale, shopping_cart, 'some_recurly_uuid') + FactoryGirl.create(:recurly_transaction_web_hook_failed, subscription_id: 'some_recurly_uuid') + + check_integrity = Sale.check_integrity + r = check_integrity[0] + r.total.to_i.should eq(1) + r.not_known.to_i.should eq(0) + r.succeeded.to_i.should eq(0) + r.failed.to_i.should eq(1) + r.refunded.to_i.should eq(0) + r.voided.to_i.should eq(0) + end + end +end + diff --git a/ruby/spec/jam_ruby/recurly_client_spec.rb b/ruby/spec/jam_ruby/recurly_client_spec.rb index 5597f9894..cf51c4b8c 100644 --- a/ruby/spec/jam_ruby/recurly_client_spec.rb +++ b/ruby/spec/jam_ruby/recurly_client_spec.rb @@ -2,7 +2,6 @@ require 'spec_helper' require "jam_ruby/recurly_client" describe RecurlyClient do let(:jamtrack) { FactoryGirl.create(:jam_track, plan_code: 'jamtrack-acdc-backinblack') } - #let(:client) { RecurlyClient.new } before :all do @client = RecurlyClient.new @@ -88,23 +87,48 @@ describe RecurlyClient do end it "can place order" do + sale = Sale.create(@user) + sale = Sale.find(sale.id) + shopping_cart = ShoppingCart.create @user, @jamtrack, 1, true history_items = @client.payment_history(@user).length @client.find_or_create_account(@user, @billing_info) - expect{@client.place_order(@user, @jamtrack, nil)}.not_to raise_error() + expect{@client.place_order(@user, @jamtrack, shopping_cart, sale)}.not_to raise_error() + + # verify jam_track_rights data + @user.jam_track_rights.should_not be_nil + @user.jam_track_rights.should have(1).items + @user.jam_track_rights.last.jam_track.id.should eq(@jamtrack.id) + + # verify sales data + sale = Sale.find(sale.id) + sale.sale_line_items.length.should == 1 + sale_line_item = sale.sale_line_items[0] + sale_line_item.product_type.should eq(JamTrack::PRODUCT_TYPE) + sale_line_item.unit_price.should eq(@jamtrack.price) + sale_line_item.quantity.should eq(1) + sale_line_item.free.should eq(1) + sale_line_item.sales_tax.should be_nil + sale_line_item.shipping_handling.should eq(0) + sale_line_item.recurly_plan_code.should eq(@jamtrack.plan_code) + sale_line_item.product_id.should eq(@jamtrack.id) + sale_line_item.recurly_subscription_uuid.should_not be_nil + sale_line_item.recurly_subscription_uuid.should eq(@user.jam_track_rights.last.recurly_subscription_uuid) + + # verify subscription is in Recurly subs = @client.get_account(@user).subscriptions subs.should_not be_nil subs.should have(1).items - @user.jam_track_rights.should_not be_nil - @user.jam_track_rights.should have(1).items - @user.jam_track_rights.last.jam_track.id.should eq(@jamtrack.id) + @client.payment_history(@user).should have(history_items+1).items end it "can refund subscription" do - @client.find_or_create_account(@user, @billing_info) + sale = Sale.create(@user) + shopping_cart = ShoppingCart.create @user, @jamtrack, 1 + @client.find_or_create_account(@user, @billing_info) # Place order: - expect{@client.place_order(@user, @jamtrack, nil)}.not_to raise_error() + expect{@client.place_order(@user, @jamtrack, shopping_cart, sale)}.not_to raise_error() active_subs=@client.get_account(@user).subscriptions.find_all{|t|t.state=='active'} @jamtrack.reload @jamtrack.jam_track_rights.should have(1).items @@ -119,11 +143,14 @@ describe RecurlyClient do end it "detects error on double order" do + sale = Sale.create(@user) + shopping_cart = ShoppingCart.create @user, @jamtrack, 1 @client.find_or_create_account(@user, @billing_info) - jam_track_right = @client.place_order(@user, @jamtrack, nil) + jam_track_right = @client.place_order(@user, @jamtrack, shopping_cart, sale) jam_track_right.recurly_subscription_uuid.should_not be_nil - jam_track_right2 = @client.place_order(@user, @jamtrack, nil) + shopping_cart = ShoppingCart.create @user, @jamtrack, 1 + jam_track_right2 = @client.place_order(@user, @jamtrack, shopping_cart, sale) jam_track_right.should eq(jam_track_right2) jam_track_right.recurly_subscription_uuid.should eq(jam_track_right.recurly_subscription_uuid) end diff --git a/web/app/assets/stylesheets/client/jamkazam.css.scss b/web/app/assets/stylesheets/client/jamkazam.css.scss index b898d2958..d1e2b4192 100644 --- a/web/app/assets/stylesheets/client/jamkazam.css.scss +++ b/web/app/assets/stylesheets/client/jamkazam.css.scss @@ -134,7 +134,7 @@ input[type="button"] { } .hidden { - display:none !important; + display:none; } .small { diff --git a/web/app/assets/stylesheets/landings/landing_page_new.css.scss b/web/app/assets/stylesheets/landings/landing_page_new.css.scss index 2fd40ea5f..897152536 100644 --- a/web/app/assets/stylesheets/landings/landing_page_new.css.scss +++ b/web/app/assets/stylesheets/landings/landing_page_new.css.scss @@ -8,6 +8,10 @@ body.web.landing_page { margin:0 0 5px; padding:7px 0; display:inline-block; + + &.hidden { + display:none; + } } .row { @include border_box_sizing; diff --git a/web/app/controllers/api_recurly_controller.rb b/web/app/controllers/api_recurly_controller.rb index 480e33ee8..44b50c48a 100644 --- a/web/app/controllers/api_recurly_controller.rb +++ b/web/app/controllers/api_recurly_controller.rb @@ -123,16 +123,22 @@ class ApiRecurlyController < ApiController error=nil response = {jam_tracks:[]} - current_user.shopping_carts.each do |shopping_cart| - jam_track = shopping_cart.cart_product + sale = Sale.create(current_user) - # if shopping_cart has any marked_for_redeem, then we zero out the price by passing in 'free' - # NOTE: shopping_carts have the idea of quantity, but you should only be able to buy at most one JamTrack. So anything > 0 is considered free for a JamTrack + if sale.valid? + current_user.shopping_carts.each do |shopping_cart| + jam_track = shopping_cart.cart_product - jam_track_right = @client.place_order(current_user, jam_track, shopping_cart) - # build up the response object with JamTracks that were purchased. - # if this gets more complicated, we should switch to RABL - response[:jam_tracks] << {name: jam_track.name, id: jam_track.id, jam_track_right_id: jam_track_right.id, version: jam_track.version} + # if shopping_cart has any marked_for_redeem, then we zero out the price by passing in 'free' + # NOTE: shopping_carts have the idea of quantity, but you should only be able to buy at most one JamTrack. So anything > 0 is considered free for a JamTrack + + jam_track_right = @client.place_order(current_user, jam_track, shopping_cart, sale) + # build up the response object with JamTracks that were purchased. + # if this gets more complicated, we should switch to RABL + response[:jam_tracks] << {name: jam_track.name, id: jam_track.id, jam_track_right_id: jam_track_right.id, version: jam_track.version} + end + else + error = 'can not create sale' end if error diff --git a/web/app/controllers/api_recurly_web_hook_controller.rb b/web/app/controllers/api_recurly_web_hook_controller.rb new file mode 100644 index 000000000..a809800b1 --- /dev/null +++ b/web/app/controllers/api_recurly_web_hook_controller.rb @@ -0,0 +1,30 @@ +class ApiRecurlyWebHookController < ApiController + + http_basic_authenticate_with name: Rails.application.config.recurly_webhook_user, password: Rails.application.config.recurly_webhook_pass + + before_filter :api_signed_in_user, only: [] + #respond_to :xml + + + def on_hook + begin + + document = Nokogiri::XML(request.body) + + if RecurlyTransactionWebHook.is_transaction_web_hook?(document) + transaction = RecurlyTransactionWebHook.create_from_xml(document) + end + + rescue Exception => e + Stats.write('web.recurly.webhook.transaction.error', {message: e.to_s, value: 1}) + + log.error("unable to process webhook: #{e.to_s}") + + raise JamArgumentError.new("unable to parse webhook #{e.to_s}") + end + + Stats.write('web.recurly.webhook.transaction.success', {value: 1}) + + render xml: { success: true }, :status => 200 + end +end diff --git a/web/config/application.rb b/web/config/application.rb index 6ded2fb75..33ec5248a 100644 --- a/web/config/application.rb +++ b/web/config/application.rb @@ -182,6 +182,9 @@ if defined?(Bundler) config.bugsnag_key = "4289fc981c8ce3eb0969003c4f498b01" config.bugsnag_notify_release_stages = ["production"] # add 'development' if you want to test a bugsnag feature locally + config.recurly_webhook_user = 'monkeytoesspeartoss' + config.recurly_webhook_pass = 'frizzyfloppymushface' + config.ga_ua = 'UA-44184562-2' # google analytics config.ga_endpoint = 'www.google-analytics.com' config.ga_ua_version = '1' diff --git a/web/config/routes.rb b/web/config/routes.rb index aa0b96d70..b2e89c9d0 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -552,6 +552,9 @@ SampleApp::Application.routes.draw do # latency_tester match '/latency_testers' => 'api_latency_testers#match', :via => :get + + match '/recurly/webhook' => 'api_recurly_web_hook#on_hook', :via => :post + end end diff --git a/web/spec/controllers/api_recurly_web_hook_controller_spec.rb b/web/spec/controllers/api_recurly_web_hook_controller_spec.rb new file mode 100644 index 000000000..87cf27f03 --- /dev/null +++ b/web/spec/controllers/api_recurly_web_hook_controller_spec.rb @@ -0,0 +1,109 @@ +require 'spec_helper' +require 'jam_ruby/recurly_client' + +describe ApiRecurlyWebHookController, :type=>:request do + render_views + + let(:success_xml) { +' + + + 56d5b2c6-2a4b-46e4-a984-ec1fbe83a50d + + seth@jamkazam.com + Seth + Call + + + + 2de4448533db12d6d92b4c4b4e90a4f1 + 2de44484fa4528b504555f43ac8bf42f + + 1037 + 2de44484b460d95863799a431383b165 + purchase + 2015-04-01T14:53:44Z + 216 + success + Successful test transaction + 6249355 + subscription + + Street address and postal code match. + + + true + true + true + +' + } + + let(:no_user_xml) { + ' + + + HUHUHUHUHUHUHUHU + + seth@jamkazam.com + Seth + Call + + + + 2de4448533db12d6d92b4c4b4e90a4f1 + 2de44484fa4528b504555f43ac8bf42f + + 1037 + 2de44484b460d95863799a431383b165 + purchase + 2015-04-01T14:53:44Z + 216 + success + Successful test transaction + 6249355 + subscription + + Street address and postal code match. + + + true + true + true + +' + } + + before(:all) do + User.delete_all + @user = FactoryGirl.create(:user, id: '56d5b2c6-2a4b-46e4-a984-ec1fbe83a50d') + end + + it "no auth" do + request.env['RAW_POST_DATA'] = success_xml + @request.env['RAW_POST_DATA'] = success_xml + post :on_hook, {}, { 'CONTENT_TYPE' => 'application/xml', 'ACCEPT' => 'application/xml' } + response.status.should eq(401) + end + + it "succeeds" do + @request.env['RAW_POST_DATA'] = success_xml + @request.env["HTTP_AUTHORIZATION"] = "Basic " + Base64::encode64(Rails.application.config.recurly_webhook_user + ":" + Rails.application.config.recurly_webhook_pass ) + post :on_hook, {}, { 'Content-Type' => 'application/xml' } + response.status.should eq(200) + end + + it "returns 422 on error" do + @request.env['RAW_POST_DATA'] = no_user_xml + @request.env["HTTP_AUTHORIZATION"] = "Basic " + Base64::encode64(Rails.application.config.recurly_webhook_user + ":" + Rails.application.config.recurly_webhook_pass ) + post :on_hook, {}, { 'Content-Type' => 'application/xml' } + response.status.should eq(422) + end + + it "returns 200 for unknown hook event" do + @request.env['RAW_POST_DATA'] = '' + @request.env["HTTP_AUTHORIZATION"] = "Basic " + Base64::encode64(Rails.application.config.recurly_webhook_user + ":" + Rails.application.config.recurly_webhook_pass ) + post :on_hook, {}, { 'Content-Type' => 'application/xml' } + response.status.should eq(200) + end +end diff --git a/web/spec/features/checkout_spec.rb b/web/spec/features/checkout_spec.rb index 8e72631fd..89b37a366 100644 --- a/web/spec/features/checkout_spec.rb +++ b/web/spec/features/checkout_spec.rb @@ -47,6 +47,7 @@ describe "Checkout", :js => true, :type => :feature, :capybara_feature => true d before(:each) do ShoppingCart.delete_all + Sale.delete_all User.delete_all stub_const("APP_CONFIG", web_config) @@ -545,6 +546,28 @@ describe "Checkout", :js => true, :type => :feature, :capybara_feature => true d acdc.redeemed.should be_false pearljam = jamtrack_pearljam_evenflow.right_for_user(user) pearljam.redeemed.should be_false + + # verify sales data + user.sales.length.should eq(1) + sale = user.sales.first + sale.sale_line_items.length.should eq(2) + + acdc_sale = SaleLineItem.find_by_recurly_subscription_uuid(acdc.recurly_subscription_uuid) + acdc_sale.recurly_plan_code.should eq(jamtrack_acdc_backinblack.plan_code) + acdc_sale.product_type.should eq('JamTrack') + acdc_sale.product_id.should eq(jamtrack_acdc_backinblack.id) + acdc_sale.quantity.should eq(1) + acdc_sale.free.should eq(0) + acdc_sale.unit_price.should eq(1.99) + acdc_sale.sale.should eq(sale) + pearljam_sale = SaleLineItem.find_by_recurly_subscription_uuid(pearljam.recurly_subscription_uuid) + pearljam_sale.recurly_plan_code.should eq(jamtrack_pearljam_evenflow.plan_code) + pearljam_sale.product_type.should eq('JamTrack') + pearljam_sale.product_id.should eq(jamtrack_pearljam_evenflow.id) + pearljam_sale.quantity.should eq(1) + pearljam_sale.free.should eq(0) + pearljam_sale.unit_price.should eq(1.99) + pearljam_sale.sale.should eq(sale) end it "shows purchase error correctly" do @@ -633,6 +656,20 @@ describe "Checkout", :js => true, :type => :feature, :capybara_feature => true d jam_track_right.redeemed.should be_true guy.has_redeemable_jamtrack.should be_false + # verify sales data + guy.sales.length.should eq(1) + sale = guy.sales.first + sale.sale_line_items.length.should eq(1) + acdc_sale = SaleLineItem.find_by_recurly_subscription_uuid(jam_track_right.recurly_subscription_uuid) + acdc_sale.recurly_plan_code.should eq(jamtrack_acdc_backinblack.plan_code) + acdc_sale.product_type.should eq('JamTrack') + acdc_sale.product_id.should eq(jamtrack_acdc_backinblack.id) + acdc_sale.quantity.should eq(1) + acdc_sale.free.should eq(1) + acdc_sale.unit_price.should eq(1.99) + acdc_sale.sale.should eq(sale) + + # now, go back to checkout flow again, and make sure we are told there are no free jam tracks visit "/client#/jamtrack" @@ -661,12 +698,27 @@ describe "Checkout", :js => true, :type => :feature, :capybara_feature => true d # and now we should see confirmation, and a notice that we are in a normal browser find('.thanks-detail.jam-tracks-in-browser') + guy.reload + jam_track_right = jamtrack_pearljam_evenflow.right_for_user(guy) # make sure it appears the user actually bought the jamtrack! jam_track_right.should_not be_nil jam_track_right.redeemed.should be_false guy.has_redeemable_jamtrack.should be_false + # verify sales data + guy.sales.length.should eq(2) + sale = guy.sales.last + sale.sale_line_items.length.should eq(1) + acdc_sale = SaleLineItem.find_by_recurly_subscription_uuid(jam_track_right.recurly_subscription_uuid) + acdc_sale.recurly_plan_code.should eq(jamtrack_pearljam_evenflow.plan_code) + acdc_sale.product_type.should eq('JamTrack') + acdc_sale.product_id.should eq(jamtrack_pearljam_evenflow.id) + acdc_sale.quantity.should eq(1) + acdc_sale.free.should eq(0) + acdc_sale.unit_price.should eq(1.99) + acdc_sale.sale.should eq(sale) + end