diff options
Diffstat (limited to 'billing')
47 files changed, 1233 insertions, 0 deletions
diff --git a/billing/Gemfile b/billing/Gemfile new file mode 100644 index 0000000..68ea51b --- /dev/null +++ b/billing/Gemfile @@ -0,0 +1,23 @@ +source "http://rubygems.org" + +eval(File.read(File.dirname(__FILE__) + '/../common_dependencies.rb')) +eval(File.read(File.dirname(__FILE__) + '/../ui_dependencies.rb')) + +# We require leap_web_core from here so we can use the path option. +gem "leap_web_core", :path => '../core' + +# Declare your gem's dependencies in billing.gemspec. +# Bundler will treat runtime dependencies like base dependencies, and +# development dependencies will be added by default to the :development group. +gemspec + +# jquery-rails is used by the dummy application +#gem "jquery-rails" + +# Declare any dependencies that are still in development here instead of in +# your gemspec. These might include edge Rails or gems from your path or +# Git. Remember to move these dependencies to your gemspec before releasing +# your gem to rubygems.org. + +# To use debugger +# gem 'debugger' diff --git a/billing/README.rdoc b/billing/README.rdoc new file mode 100644 index 0000000..357c02e --- /dev/null +++ b/billing/README.rdoc @@ -0,0 +1,24 @@ += Billing + +This project rocks and uses MIT-LICENSE. + +To set up your own Braintree Sandbox, create an account at: +https://www.braintreepayments.com/get-started + +Login. +In the top right, navigate to your username, and then 'My User' -> 'API Keys' + +Click the button to generate a new API key, and then click the 'View' link to the right of the key. + +There is a section to copy a snippet of code. Select 'Ruby' in the dropdown, and then the button to the right to copy this code to your clipboard. +Then, paste the contents of the clipboard into config/initializers/braintree.rb + +You should not check the private key into version control. + +Now, you should be able to add charges to your own Sandbox when you run the webapp locally. + +You also will want to add a Plan to your Sandbox. Within the Braintree Sandbox, navigate to 'Recurring Billing' -> 'Plans'. From here, you can add a new Plan. The values of the test plan are not important, but the ID will be displayed, so should pick something descriptive. + +Here are credit cared numbers to try in the Sandbox: + +https://www.braintreepayments.com/docs/ruby/reference/sandbox
\ No newline at end of file diff --git a/billing/Rakefile b/billing/Rakefile new file mode 100644 index 0000000..52929c4 --- /dev/null +++ b/billing/Rakefile @@ -0,0 +1,40 @@ +#!/usr/bin/env rake +begin + require 'bundler/setup' +rescue LoadError + puts 'You must `gem install bundler` and `bundle install` to run rake tasks' +end +begin + require 'rdoc/task' +rescue LoadError + require 'rdoc/rdoc' + require 'rake/rdoctask' + RDoc::Task = Rake::RDocTask +end + +RDoc::Task.new(:rdoc) do |rdoc| + rdoc.rdoc_dir = 'rdoc' + rdoc.title = 'LeapWebBilling' + rdoc.options << '--line-numbers' + rdoc.rdoc_files.include('README.rdoc') + rdoc.rdoc_files.include('lib/**/*.rb') +end + +spec = eval(File.read('leap_web_billing.gemspec')) +Gem::PackageTask.new(spec) do |p| + p.gem_spec = spec +end + +Bundler::GemHelper.install_tasks + +require 'rake/testtask' + +Rake::TestTask.new(:test) do |t| + t.libs << 'lib' + t.libs << 'test' + t.pattern = 'test/**/*_test.rb' + t.verbose = false +end + + +task :default => :test diff --git a/billing/app/controllers/billing_base_controller.rb b/billing/app/controllers/billing_base_controller.rb new file mode 100644 index 0000000..dc15194 --- /dev/null +++ b/billing/app/controllers/billing_base_controller.rb @@ -0,0 +1,11 @@ +class BillingBaseController < ApplicationController + before_filter :assign_user + + helper 'billing' + + # required for navigation to work. + def assign_user + @user = current_user + end + +end diff --git a/billing/app/controllers/credit_card_info_controller.rb b/billing/app/controllers/credit_card_info_controller.rb new file mode 100644 index 0000000..75865fe --- /dev/null +++ b/billing/app/controllers/credit_card_info_controller.rb @@ -0,0 +1,34 @@ +class CreditCardInfoController < ApplicationController + before_filter :authorize, :set_user + + def edit + @credit_card = Braintree::CreditCard.find(params[:id]) + customer = Customer.find_by_user_id(current_user.id) + if customer and customer.braintree_customer_id == @credit_card.customer_id + @tr_data = Braintree::TransparentRedirect. + update_credit_card_data(:redirect_url => confirm_credit_card_info_url, + :payment_method_token => @credit_card.token) + else + access_denied + end + + end + + def confirm + @result = Braintree::TransparentRedirect.confirm(request.query_string) + if @result.success? + render :action => "confirm" + else + @credit_card = Braintree::CreditCard.find(@result.params[:payment_method_token]) + render :action => "edit" + end + end + + + private + + def set_user + @user = current_user + end + +end diff --git a/billing/app/controllers/customer_controller.rb b/billing/app/controllers/customer_controller.rb new file mode 100644 index 0000000..14ea8a7 --- /dev/null +++ b/billing/app/controllers/customer_controller.rb @@ -0,0 +1,61 @@ +class CustomerController < BillingBaseController + before_filter :authorize + def show + customer.with_braintree_data! + @default_cc = customer.default_credit_card #TODO not actually right way + @active_subscription = customer.subscriptions + @transactions = customer.braintree_customer.transactions + end + + def new + if customer.has_payment_info? + redirect_to edit_customer_path(customer), :notice => 'Here is your saved customer data' + else + fetch_new_transparent_redirect_data + end + end + + def edit + fetch_edit_transparent_redirect_data + end + + def confirm + @result = Braintree::TransparentRedirect.confirm(request.query_string) + + if @result.success? + customer.braintree_customer = @result.customer + customer.save + render :action => "confirm" + elsif customer.has_payment_info? + fetch_edit_transparent_redirect_data + render :action => "edit" + else + fetch_new_transparent_redirect_data + render :action => "new" + end + end + + protected + + def fetch_new_transparent_redirect_data + @tr_data = Braintree::TransparentRedirect. + create_customer_data(:redirect_url => confirm_customer_url) + end + + def fetch_edit_transparent_redirect_data + customer.with_braintree_data! + @default_cc = customer.default_credit_card + @tr_data = Braintree::TransparentRedirect. + update_customer_data(:redirect_url => confirm_customer_url, + :customer_id => customer.braintree_customer_id) ##?? + end + + def customer + @customer ||= Customer.find(params[:id]) if params[:id] # edit, show + @customer ||= Customer.find_by_user_id(current_user.id) # confirm + @customer ||= Customer.new(user: current_user) + # TODO will want case for admins, presumably + access_denied unless @customer.user == current_user + return @customer + end +end diff --git a/billing/app/controllers/payments_controller.rb b/billing/app/controllers/payments_controller.rb new file mode 100644 index 0000000..224b78e --- /dev/null +++ b/billing/app/controllers/payments_controller.rb @@ -0,0 +1,46 @@ +class PaymentsController < BillingBaseController + before_filter :authorize, :only => [:index] + + def new + fetch_transparent_redirect + end + + def confirm + @result = Braintree::TransparentRedirect.confirm(request.query_string) + if @result.success? + render :action => "confirm" + else + fetch_transparent_redirect + render :action => "new" + end + end + + def index + customer = Customer.find_by_user_id(current_user.id) + braintree_data = Braintree::Customer.find(customer.braintree_customer_id) + # these will be ordered by created_at descending, per http://stackoverflow.com/questions/16425475/ + @transactions = braintree_data.transactions + end + + protected + + + def fetch_transparent_redirect + if @user = current_user #set user for navigation + if @customer = Customer.find_by_user_id(current_user.id) + @customer.with_braintree_data! + braintree_customer_id = @customer.braintree_customer_id + @default_cc = @customer.default_credit_card + else + # TODO: this requires user to add self to vault before making payment. Is that desired functionality? + redirect_to new_customer_path, :notice => 'Before making payment, please add your customer data' + end + end + + # TODO: What is this supposed to do if braintree_customer_id was not set yet? + # Response: it can be used to make a payment that is not attributed to any customer (ie, a donation) + @tr_data = Braintree::TransparentRedirect.transaction_data redirect_url: confirm_payment_url, + transaction: { type: "sale", customer_id: braintree_customer_id, options: {submit_for_settlement: true } } + end + +end diff --git a/billing/app/controllers/subscriptions_controller.rb b/billing/app/controllers/subscriptions_controller.rb new file mode 100644 index 0000000..38dbff1 --- /dev/null +++ b/billing/app/controllers/subscriptions_controller.rb @@ -0,0 +1,45 @@ +class SubscriptionsController < BillingBaseController + before_filter :authorize + before_filter :fetch_subscription, :only => [:show, :destroy] + before_filter :confirm_no_active_subscription, :only => [:new, :create] + + def new + # don't show link to subscribe if they are already subscribed? + credit_card = @customer.default_credit_card #safe to assume default? + @payment_method_token = credit_card.token + @plans = Braintree::Plan.all + end + + # show has no content, so not needed at this point. + + def create + @result = Braintree::Subscription.create( :payment_method_token => params[:payment_method_token], :plan_id => params[:plan_id] ) + end + + def destroy + @result = Braintree::Subscription.cancel params[:id] + end + + def index + customer = Customer.find_by_user_id(current_user.id) + @subscriptions = customer.subscriptions(nil, false) + end + + private + + def fetch_subscription + @subscription = Braintree::Subscription.find params[:id] + @subscription_customer_id = @subscription.transactions.first.customer_details.id #all of subscriptions transactions should have same customer + @customer = Customer.find_by_user_id(current_user.id) + access_denied unless @customer and @customer.braintree_customer_id == @subscription_customer_id + # TODO: will presumably want to allow admins to view/cancel subscriptions for all users + end + + def confirm_no_active_subscription + @customer = Customer.find_by_user_id(current_user.id) + if subscription = @customer.subscriptions # will return active subscription, if it exists + redirect_to subscription_path(subscription.id), :notice => 'You already have an active subscription' + end + end + +end diff --git a/billing/app/helpers/billing_helper.rb b/billing/app/helpers/billing_helper.rb new file mode 100644 index 0000000..7ec9285 --- /dev/null +++ b/billing/app/helpers/billing_helper.rb @@ -0,0 +1,20 @@ +module BillingHelper + + def braintree_form_for(object, options = {}, &block) + options.reverse_merge! params: @result && @result.params[object], + errors: @result && @result.errors.for(object), + builder: BraintreeFormHelper::BraintreeFormBuilder, + url: Braintree::TransparentRedirect.url + + form_for object, options, &block + end + + def show_or_new_customer_link(user) + if (customer = Customer.find_by_user_id(user.id)) and customer.has_payment_info? + show_customer_path(user) + else + new_customer_path + end + end + +end diff --git a/billing/app/helpers/braintree_form_helper.rb b/billing/app/helpers/braintree_form_helper.rb new file mode 100644 index 0000000..cb322fa --- /dev/null +++ b/billing/app/helpers/braintree_form_helper.rb @@ -0,0 +1,64 @@ +module BraintreeFormHelper + class BraintreeFormBuilder < ActionView::Helpers::FormBuilder + include ActionView::Helpers::AssetTagHelper + include ActionView::Helpers::TagHelper + + def initialize(object_name, object, template, options, proc) + super + @braintree_params = @options[:params] + @braintree_errors = @options[:errors] + @braintree_existing = @options[:existing] + end + + def fields_for(record_name, *args, &block) + options = args.extract_options! + options[:builder] = BraintreeFormBuilder + options[:params] = @braintree_params && @braintree_params[record_name] + options[:errors] = @braintree_errors && @braintree_errors.for(record_name) + new_args = args + [options] + super record_name, *new_args, &block + end + + def text_field(method, options = {}) + has_errors = @braintree_errors && @braintree_errors.on(method).any? + field = super(method, options.merge(:value => determine_value(method))) + result = content_tag("div", field, :class => has_errors ? "fieldWithErrors" : "") + result.safe_concat validation_errors(method) + result + end + + protected + + def determine_value(method) + if @braintree_params + @braintree_params[method] + elsif @braintree_existing + + if @braintree_existing.kind_of?(Braintree::CreditCard) + + case method + when :number + method = :masked_number + when :cvv + return nil + end + end + + @braintree_existing.send(method) + else + nil + end + end + + def validation_errors(method) + if @braintree_errors && @braintree_errors.on(method).any? + @braintree_errors.on(method).map do |error| + content_tag("div", ERB::Util.h(error.message), {:style => "color: red;"}) + end.join + else + "" + end + end + end +end + diff --git a/billing/app/helpers/braintree_helper.rb b/billing/app/helpers/braintree_helper.rb new file mode 100644 index 0000000..2d18b6c --- /dev/null +++ b/billing/app/helpers/braintree_helper.rb @@ -0,0 +1,5 @@ +module BraintreeHelper + + +end + diff --git a/billing/app/models/customer.rb b/billing/app/models/customer.rb new file mode 100644 index 0000000..f01c300 --- /dev/null +++ b/billing/app/models/customer.rb @@ -0,0 +1,58 @@ +class Customer < CouchRest::Model::Base + + FIELDS = [:first_name, :last_name, :phone, :website, :company, :fax, :addresses, :credit_cards, :custom_fields] + attr_accessor *FIELDS + + use_database "customers" + belongs_to :user + belongs_to :braintree_customer + + # Braintree::Customer - stored on braintrees servers - we only have the id. + def braintree_customer + @braintree_customer ||= Braintree::Customer.find(braintree_customer_id) + end + + validates :user, presence: true + + design do + view :by_user_id + view :by_braintree_customer_id + end + + def has_payment_info? + !!braintree_customer_id + end + + # from braintree_ruby_examples/rails3_tr_devise and should be tweaked + def with_braintree_data! + return self unless has_payment_info? + + FIELDS.each do |field| + send(:"#{field}=", braintree_customer.send(field)) + end + self + end + + def default_credit_card + return unless has_payment_info? + + credit_cards.find { |cc| cc.default? } + end + + # based on 2nd parameter, either returns the single active subscription (or nil if there isn't one), or an array of all subsciptions + def subscriptions(braintree_data=nil, only_active=true) + self.with_braintree_data! + return unless has_payment_info? + + subscriptions = [] + self.default_credit_card.subscriptions.each do |sub| + if only_active and sub.status == 'Active' + return sub + else + subscriptions << sub + end + end + only_active ? nil : subscriptions + end + +end diff --git a/billing/app/views/credit_card_info/confirm.html.haml b/billing/app/views/credit_card_info/confirm.html.haml new file mode 100644 index 0000000..9dd8176 --- /dev/null +++ b/billing/app/views/credit_card_info/confirm.html.haml @@ -0,0 +1,5 @@ +%h1 Payment Info Confirmation +%p Your payment information was successfully saved. +%dl + %dt Credit Card + %dd= @result.credit_card.masked_number diff --git a/billing/app/views/credit_card_info/edit.html.haml b/billing/app/views/credit_card_info/edit.html.haml new file mode 100644 index 0000000..39269ca --- /dev/null +++ b/billing/app/views/credit_card_info/edit.html.haml @@ -0,0 +1,17 @@ +%h1 Change Credit Card +- if @result + #total-errors{:style => "color:red;"} + = h(@result.errors.size) + error(s) += braintree_form_for :credit_card, :existing => @credit_card do |f| + = field_set_tag "Credit Card" do + %dl + %dt= f.label :number, 'Number' + %dd= f.text_field :number + %dt= f.label :expiration_date, 'Expiration Date (MM/YY)' + %dd= f.text_field :expiration_date + %dt= f.label :cvv, 'CVV' + %dd= f.text_field :cvv + = hidden_field_tag :tr_data, @tr_data + = f.submit 'Save Payment Info', :class => :btn + = link_to t(:cancel), edit_customer_path(@credit_card.customer_id), :class => :btn diff --git a/billing/app/views/customer/_transaction.html.haml b/billing/app/views/customer/_transaction.html.haml new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/billing/app/views/customer/_transaction.html.haml diff --git a/billing/app/views/customer/confirm.html.haml b/billing/app/views/customer/confirm.html.haml new file mode 100644 index 0000000..5551622 --- /dev/null +++ b/billing/app/views/customer/confirm.html.haml @@ -0,0 +1,14 @@ +%h1 Payment Info Confirmation +%p Your payment information was successfully saved. +%dl + %dt First Name + %dd= @result.customer.first_name + %dt Last Name + %dd= @result.customer.last_name + %dt Phone + %dd= @result.customer.phone + %dt Credit Card + - @result.customer.credit_cards.each do |cc| + %dd= cc.masked_number +- customer = Customer.find_by_user_id(current_user.id) += link_to 'View Customer Info', show_customer_path(customer.braintree_customer_id), :class=> :btn
\ No newline at end of file diff --git a/billing/app/views/customer/edit.html.haml b/billing/app/views/customer/edit.html.haml new file mode 100644 index 0000000..8a232c5 --- /dev/null +++ b/billing/app/views/customer/edit.html.haml @@ -0,0 +1,23 @@ +- if @result + #total-errors{:style => "color:red;"} + = h(@result.errors.size) + error(s) += braintree_form_for :customer, existing: @customer do |f| + = field_set_tag "Customer" do + %dl + %dt= f.label :first_name, 'First Name' + %dd= f.text_field :first_name + %dt= f.label :last_name, 'Last Name' + %dd= f.text_field :last_name + %dt= f.label :phone, 'Phone' + %dd= f.text_field :phone + - if @default_cc + = # todo, as they will need a credit card, so not sure about conditional? + %dt= t(:stored_credit_card) + %dd + = @default_cc.masked_number + = link_to t(:change_credit_card), edit_credit_card_info_path(:id => @default_cc.token), :class => :btn + = hidden_field_tag :tr_data, @tr_data + .form-actions + = f.submit t(:save_customer_info), :class => 'btn btn-primary' + = link_to t(:cancel), show_customer_path(@customer), :class=> :btn diff --git a/billing/app/views/customer/new.html.haml b/billing/app/views/customer/new.html.haml new file mode 100644 index 0000000..e1f5ba9 --- /dev/null +++ b/billing/app/views/customer/new.html.haml @@ -0,0 +1,24 @@ +- if @result + #total-errors{:style => "color:red;"} + = h(@result.errors.size) + error(s) += braintree_form_for :customer do |f| + = field_set_tag "Customer" do + %dl + %dt= f.label :first_name, 'First Name' + %dd= f.text_field :first_name + %dt= f.label :last_name, 'Last Name' + %dd= f.text_field :last_name + %dt= f.label :phone, 'Phone' + %dd= f.text_field :phone + = field_set_tag "Credit Card" do + - f.fields_for :credit_card do |cc| + %dl + %dt= cc.label :number, 'Number' + %dd= cc.text_field :number + %dt= cc.label :expiration_date, 'Expiration Date (MM/YY)' + %dd= cc.text_field :expiration_date + %dt= cc.label :cvv, 'CVV' + %dd= cc.text_field :cvv + = hidden_field_tag :tr_data, @tr_data + = f.submit 'Save Payment Info' diff --git a/billing/app/views/customer/show.html.haml b/billing/app/views/customer/show.html.haml new file mode 100644 index 0000000..639d180 --- /dev/null +++ b/billing/app/views/customer/show.html.haml @@ -0,0 +1,22 @@ +.form-actions + = link_to t(:make_payment), new_payment_path, :class => 'btn btn-primary' += render :partial => 'payments/customer_data' +%legend= t(:last_three_transactions) +- counter = 0 += # these will be ordered with most recently created first, per http://stackoverflow.com/questions/16425475/ +- @transactions.each do |t| + - break if counter > 2 # not ruby-like, but object is a Braintree::ResourceCollection so limited methods available + = render :partial => "payments/transaction_details", :locals => {:transaction => t} + - counter += 1 += link_to t(:transaction_history), payments_path +%legend= t(:subscriptions) +- if @active_subscription + = render :partial => "subscriptions/subscription_details", :locals => {:subscription => @active_subscription} +- else + %p + = t(:no_active_subscription) + %p + .form-actions + = link_to t(:subscribe_to_plan), new_subscription_path, :class => :btn +%p + = link_to t(:all_subscriptions), subscriptions_path diff --git a/billing/app/views/payments/_customer_data.html.haml b/billing/app/views/payments/_customer_data.html.haml new file mode 100644 index 0000000..87b8209 --- /dev/null +++ b/billing/app/views/payments/_customer_data.html.haml @@ -0,0 +1,15 @@ +%legend= t(:customer_information) +%dl + %dt First Name + %dd= @customer.first_name + %dt Last Name + %dd= @customer.last_name + %dt Phone + %dd= @customer.phone +%legend= t(:credit_card_information) +%dl + %dt Number + %dd= @default_cc.masked_number + %dt Expiration Date + %dd= @default_cc.expiration_date + = link_to t(:edit_saved_data), edit_customer_path(@customer), :class => :btn diff --git a/billing/app/views/payments/_non_customer_fields.html.haml b/billing/app/views/payments/_non_customer_fields.html.haml new file mode 100644 index 0000000..99b420d --- /dev/null +++ b/billing/app/views/payments/_non_customer_fields.html.haml @@ -0,0 +1,16 @@ += field_set_tag "Customer" do + = f.fields_for :customer do |c| + %div= c.label :first_name, "First Name" + %div= c.text_field :first_name + %div= c.label :last_name, "Last Name" + %div= c.text_field :last_name + %div= c.label :email, "Email" + %div= c.text_field :email += field_set_tag "Credit Card" do + = f.fields_for :credit_card do |c| + %div= c.label :number, "Number" + %div= c.text_field :number + %div= c.label :expiration_date, "Expiration Date (MM/YY)" + %div= c.text_field :expiration_date + %div= c.label :cvv, "CVV" + %div= c.text_field :cvv
\ No newline at end of file diff --git a/billing/app/views/payments/_transaction_details.html.haml b/billing/app/views/payments/_transaction_details.html.haml new file mode 100644 index 0000000..030639e --- /dev/null +++ b/billing/app/views/payments/_transaction_details.html.haml @@ -0,0 +1,15 @@ +%p + = transaction.id + Type: + = transaction.type + Amount: + = number_to_currency(transaction.amount) + Status: + = transaction.status + Date + = transaction.created_at.strftime("%Y-%m-%d") + - if sub_start = transaction.subscription_details.billing_period_start_date + From subscription which started + = sub_start + - else + Not paid as part of subscription
\ No newline at end of file diff --git a/billing/app/views/payments/confirm.html.haml b/billing/app/views/payments/confirm.html.haml new file mode 100644 index 0000000..9479eb9 --- /dev/null +++ b/billing/app/views/payments/confirm.html.haml @@ -0,0 +1,29 @@ +%h1 Payment Result +%div Thank you for your payment. +%h2 Transaction Details +%table + %tr + %td Amount + %td + $#{@result.transaction.amount} + %tr + %td Transaction ID: + %td= @result.transaction.id + %tr + %td First Name: + %td= h @result.transaction.customer_details.first_name + %tr + %td Last Name: + %td= h @result.transaction.customer_details.last_name + %tr + %td Email: + %td= h @result.transaction.customer_details.email + %tr + %td Credit Card: + %td= h @result.transaction.credit_card_details.masked_number + %tr + %td Card Type: + %td= h @result.transaction.credit_card_details.card_type +- if current_user + - customer = Customer.find_by_user_id(current_user.id) + = link_to 'View Customer Info', show_customer_path(customer.braintree_customer_id), :class=> :btn
\ No newline at end of file diff --git a/billing/app/views/payments/index.html.haml b/billing/app/views/payments/index.html.haml new file mode 100644 index 0000000..a3ad067 --- /dev/null +++ b/billing/app/views/payments/index.html.haml @@ -0,0 +1,2 @@ +- @transactions.each do |t| + = render :partial => "transaction_details", :locals => {:transaction => t}
\ No newline at end of file diff --git a/billing/app/views/payments/new.html.haml b/billing/app/views/payments/new.html.haml new file mode 100644 index 0000000..4523e13 --- /dev/null +++ b/billing/app/views/payments/new.html.haml @@ -0,0 +1,18 @@ +%h1 + = t(:payment) +- if @result and @result.errors.size > 0 + %div{:style => "color: red;"} + = h @result.errors.size + error(s) +- if @result and @result.transaction and @result.transaction.status == 'processor_declined' + %div{:style => "color: red;"} + = t(:processor_declined) += braintree_form_for :transaction, :html => {:autocomplete => "off"} do |f| + = f.label :amount, t(:amount) + = f.text_field :amount + - if !@customer + = render :partial => 'non_customer_fields', :locals => {:f => f} + - else + = render :partial => 'customer_data' + = hidden_field_tag :tr_data, @tr_data + = f.submit "Submit Payment", :class => 'btn btn-primary' diff --git a/billing/app/views/subscriptions/_subscription_details.html.haml b/billing/app/views/subscriptions/_subscription_details.html.haml new file mode 100644 index 0000000..fb18210 --- /dev/null +++ b/billing/app/views/subscriptions/_subscription_details.html.haml @@ -0,0 +1,19 @@ +%p + = link_to subscription.id, subscription_path(subscription.id) + Balance: + = number_to_currency(subscription.balance) + Bill on: + = subscription.billing_day_of_month + Start date: + = subscription.first_billing_date + Paid through: + = subscription.paid_through_date + Plan: + = subscription.plan_id + Price: + = subscription.price + - color = (subscription.status == 'Active') ? "green" : "red" + Status: + %font{:color => color} + = subscription.status + - # would be good to get plan name but not sure if that is possible?
\ No newline at end of file diff --git a/billing/app/views/subscriptions/create.html.haml b/billing/app/views/subscriptions/create.html.haml new file mode 100644 index 0000000..2b6c5e9 --- /dev/null +++ b/billing/app/views/subscriptions/create.html.haml @@ -0,0 +1,9 @@ +- if @result.success? + %h1 + Subscription Status + = @result.subscription.status + = render :partial => "subscription_details", :locals => {:subscription => @result.subscription} +- else + %h1 + Error: + = @result.message
\ No newline at end of file diff --git a/billing/app/views/subscriptions/destroy.html.haml b/billing/app/views/subscriptions/destroy.html.haml new file mode 100644 index 0000000..e7ed6e8 --- /dev/null +++ b/billing/app/views/subscriptions/destroy.html.haml @@ -0,0 +1,7 @@ +- if @result.success? + Subscription destroyed +- else + Error: + = @result.message +%p + = link_to 'Customer Information', show_customer_path(@customer.braintree_customer_id), :class=> :btn
\ No newline at end of file diff --git a/billing/app/views/subscriptions/index.html.haml b/billing/app/views/subscriptions/index.html.haml new file mode 100644 index 0000000..0e84619 --- /dev/null +++ b/billing/app/views/subscriptions/index.html.haml @@ -0,0 +1,7 @@ +- active = false +- @subscriptions.each do |s| + - if s.status == 'Active' + - active = true + = render :partial => "subscription_details", :locals => {:subscription => s} +- if !active + = link_to 'subscribe to plan', new_subscription_path, :class => :btn
\ No newline at end of file diff --git a/billing/app/views/subscriptions/new.html.haml b/billing/app/views/subscriptions/new.html.haml new file mode 100644 index 0000000..4183458 --- /dev/null +++ b/billing/app/views/subscriptions/new.html.haml @@ -0,0 +1,15 @@ +- if @payment_method_token + %h1 + Subscribe to plan + = #currently just one plan + = @plans[0].name + = number_to_currency(@plans[0].price) + = simple_form_for :subscription, :url => :subscriptions do |f| + = hidden_field_tag :payment_method_token, @payment_method_token + = hidden_field_tag :plan_id, @plans[0].id + .form-actions + = f.submit t(:subscribe), :class => 'btn btn-primary' +- else + = t(:must_create_customer) + %p + = link_to t(:create_new_customer), new_customer_path diff --git a/billing/app/views/subscriptions/show.html.haml b/billing/app/views/subscriptions/show.html.haml new file mode 100644 index 0000000..10eb667 --- /dev/null +++ b/billing/app/views/subscriptions/show.html.haml @@ -0,0 +1,6 @@ +%h1 + - if @subscription.status == 'Active' + Current + Subscription += render :partial => "subscription_details", :locals => {:subscription => @subscription} += link_to t(:cancel_subscription), subscription_path, :confirm => t(:are_you_sure), :method => :delete, :class => 'btn btn-danger' if @subscription.status == 'Active' # permission check or should that just be on show? diff --git a/billing/config/initializers/braintree.rb b/billing/config/initializers/braintree.rb new file mode 100644 index 0000000..523dbce --- /dev/null +++ b/billing/config/initializers/braintree.rb @@ -0,0 +1,11 @@ +require 'braintree_test_app' + +Braintree::Configuration.logger = Logger.new('log/braintree.log') +Braintree::Configuration.environment = :sandbox +Braintree::Configuration.merchant_id = "bwrdyczvjspmxjhb" +Braintree::Configuration.public_key = "jmw58nbmjg84prbp" +Braintree::Configuration.private_key = "SET_ME" + +if Rails.env.test? + Rails.application.config.middleware.use BraintreeTestApp +end diff --git a/billing/config/locales/en.yml b/billing/config/locales/en.yml new file mode 100644 index 0000000..5245b17 --- /dev/null +++ b/billing/config/locales/en.yml @@ -0,0 +1,5 @@ +en: + create_new_customer: "Create a new Braintree Customer" + must_create_customer: "You must store a customer in braintree before subscribing to a plan" + subscribe: "Subscribe" + save_customer_info: "Save Customer Information"
\ No newline at end of file diff --git a/billing/config/routes.rb b/billing/config/routes.rb new file mode 100644 index 0000000..1bb32df --- /dev/null +++ b/billing/config/routes.rb @@ -0,0 +1,20 @@ +Rails.application.routes.draw do + + match 'payments/new' => 'payments#new', :as => :new_payment + match 'payments/confirm' => 'payments#confirm', :as => :confirm_payment + resources :payments, :only => [:index] + + resources :customer, :only => [:new, :edit] + resources :credit_card_info, :only => [:edit] + + match 'customer/confirm/' => 'customer#confirm', :as => :confirm_customer + match 'customer/show/:id' => 'customer#show', :as => :show_customer + match 'credit_card_info/confirm' => 'credit_card_info#confirm', :as => :confirm_credit_card_info + + resources :subscriptions, :only => [:new, :create, :index, :show, :update, :destroy] + + #match 'transactions/:product_id/new' => 'transactions#new', :as => :new_transaction + #match 'transactions/confirm/:product_id' => 'transactions#confirm', :as => :confirm_transaction + + +end diff --git a/billing/leap_web_billing.gemspec b/billing/leap_web_billing.gemspec new file mode 100644 index 0000000..17f7f92 --- /dev/null +++ b/billing/leap_web_billing.gemspec @@ -0,0 +1,22 @@ +$:.push File.expand_path("../lib", __FILE__) + +require File.expand_path('../../lib/leap_web/version.rb', __FILE__) + +# Describe your gem and declare its dependencies: +Gem::Specification.new do |s| + s.name = "leap_web_billing" + s.version = LeapWeb::VERSION + s.authors = ["Jessib"] + s.email = ["jessib@leap.se"] + s.homepage = "http://www.leap.se" + s.summary = "Billing for LeapWeb" + s.description = "Billing System for a Leap provider" + + s.files = Dir["{app,config,db,lib}/**/*"] + ["Rakefile", "Readme.md"] + s.test_files = Dir["test/**/*"] + + s.add_dependency "leap_web_core", LeapWeb::VERSION + # s.add_dependency "braintree-rails", "~> 0.4.5" + s.add_dependency "braintree" + #s.add_dependency "carmen-rails" +end diff --git a/billing/lib/braintree_test_app.rb b/billing/lib/braintree_test_app.rb new file mode 100644 index 0000000..41c327d --- /dev/null +++ b/billing/lib/braintree_test_app.rb @@ -0,0 +1,36 @@ +# RackTest assumes all requests to be local. +# Braintree requests need to go out to a different server though. +# So we use a middleware to catch these and send them out again. + +class BraintreeTestApp + def initialize(app) + @app = app + end + + def call(env) + @env = env + config = Braintree::Configuration.instantiate + if request.path =~ /\/merchants\/#{config.merchant_id}\/transparent_redirect_requests$/ + #proxy post to braintree + uri = URI.parse(config.protocol + "://" + config.server + ":" + + config.port.to_s + request.path) + http = Net::HTTP.new(uri.host, uri.port) + res = http.post(uri.path, request.body.read) + + if res.code == "303" + header_hash = res.header.to_hash + header_hash["location"].first.gsub!("http://localhost:3000/", "http://www.example.com/") + [303, {"location" => header_hash["location"].first}, ""] + else + raise "unexpected response from Braintree: expected a 303" + end + else + @app.call(env) + end + end + + def request + @request = Rack::Request.new(@env) + end +end + diff --git a/billing/lib/leap_web_billing.rb b/billing/lib/leap_web_billing.rb new file mode 100644 index 0000000..288d846 --- /dev/null +++ b/billing/lib/leap_web_billing.rb @@ -0,0 +1,4 @@ +require "leap_web_billing/engine" + +module LeapWebBilling +end diff --git a/billing/lib/leap_web_billing/engine.rb b/billing/lib/leap_web_billing/engine.rb new file mode 100644 index 0000000..6d76add --- /dev/null +++ b/billing/lib/leap_web_billing/engine.rb @@ -0,0 +1,13 @@ +# thou shall require all your dependencies in an engine. +require "leap_web_core" +require "leap_web_core/ui_dependencies" + +#require "braintree-rails" +require "braintree" +#require "carmen-rails" + +module LeapWebBilling + class Engine < ::Rails::Engine + + end +end diff --git a/billing/script/rails b/billing/script/rails new file mode 100755 index 0000000..8bd9c0a --- /dev/null +++ b/billing/script/rails @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +# This command will automatically be run when you run "rails" with Rails 3 gems installed from the root of your application. + +ENGINE_ROOT = File.expand_path('../..', __FILE__) +ENGINE_PATH = File.expand_path('../../lib/leap_web_billing/engine', __FILE__) + +require 'rails/all' +require 'rails/engine/commands' diff --git a/billing/test/factories.rb b/billing/test/factories.rb new file mode 100644 index 0000000..87543b2 --- /dev/null +++ b/billing/test/factories.rb @@ -0,0 +1,25 @@ +FactoryGirl.define do + + TEST_CC_NUMBER = %w(4111 1111 1111 1111).join + + factory :customer do + user + + factory :customer_with_payment_info do + braintree_customer + end + end + + factory :braintree_customer, class: Braintree::Customer do + first_name 'Big' + last_name 'Spender' + credit_card number: TEST_CC_NUMBER, expiration_date: '04/2016' + initialize_with { Braintree::Customer.create(attributes).customer } + skip_create + + factory :broken_customer do + credit_card number: '123456', expiration_date: '04/2016' + end + end + +end diff --git a/billing/test/functional/customer_controller_test.rb b/billing/test/functional/customer_controller_test.rb new file mode 100644 index 0000000..9bf2b5e --- /dev/null +++ b/billing/test/functional/customer_controller_test.rb @@ -0,0 +1,121 @@ +require 'test_helper' +require 'fake_braintree' + +class CustomerControllerTest < ActionController::TestCase + + test "new assigns redirect url" do + login + get :new + + assert_response :success + assert assigns(:tr_data) + tr_data = Braintree::Util.parse_query_string(assigns(:tr_data)) + assert_equal confirm_customer_url, tr_data[:redirect_url] + end + + test "new requires login" do + get :new + + assert_response :redirect + assert_redirected_to login_path + end + + test "edit uses params[:id]" do + customer = FactoryGirl.create :customer_with_payment_info + login customer.user + get :edit, id: customer.id + + assert_response :success + assert assigns(:tr_data) + tr_data = Braintree::Util.parse_query_string(assigns(:tr_data)) + assert_equal customer.braintree_customer_id, tr_data[:customer_id] + assert_equal confirm_customer_url, tr_data[:redirect_url] + end + + test "confirm user creation" do + login + Braintree::TransparentRedirect.expects(:confirm).returns(success_response) + # to_confirm = prepare_confirmation :create_customer_data, + # customer: FactoryGirl.attributes_for(:braintree_customer), + # redirect_url: confirm_customer_url + + assert_difference("Customer.count") do + post :confirm, braintree: :query + end + + assert_response :success + assert result = assigns(:result) + assert result.success? + assert result.customer.id + end + + test "customer update" do + customer = FactoryGirl.create :customer_with_payment_info + login customer.user + Braintree::TransparentRedirect.expects(:confirm). + returns(success_response(customer)) + + assert_no_difference("Customer.count") do + post :confirm, query: :from_braintree + end + + assert_response :success + assert result = assigns(:result) + assert result.success? + assert_equal customer.braintree_customer, result.customer + end + + test "failed user creation" do + skip "can't get user creation to fail" + login + FakeBraintree.decline_all_cards! + to_confirm = prepare_confirmation :create_customer_data, + customer: FactoryGirl.attributes_for(:broken_customer), + redirect_url: confirm_customer_url + post :confirm, to_confirm + + FakeBraintree.clear! + assert_response :success + assert result = assigns(:result) + assert !result.success? + end + + test "failed user creation with stubbing" do + login + Braintree::TransparentRedirect.expects(:confirm).returns(failure_response) + post :confirm, bla: :blub + + assert_response :success + assert_template :new + end + + test "failed user update with stubbing" do + customer = FactoryGirl.create :customer_with_payment_info + login customer.user + Braintree::TransparentRedirect.expects(:confirm).returns(failure_response) + post :confirm, bla: :blub + + assert_response :success + assert_template :edit + end + + def failure_response + stub success?: false, + errors: stub(for: nil, size: 0), + params: {} + end + + def success_response(customer = nil) + stub success?: true, + customer: braintree_customer(customer) + end + + def braintree_customer(customer) + if customer + customer.braintree_customer + else + FactoryGirl.build :braintree_customer + end + end + +end diff --git a/billing/test/functional/customers_controller_test.rb b/billing/test/functional/customers_controller_test.rb new file mode 100644 index 0000000..58b6155 --- /dev/null +++ b/billing/test/functional/customers_controller_test.rb @@ -0,0 +1,59 @@ +require 'test_helper' +require 'fake_braintree' + +class CustomersControllerTest < ActionController::TestCase + tests CustomerController + + setup do + @user = FactoryGirl.create :user + @other_user = FactoryGirl.create :user + FakeBraintree.clear! + FakeBraintree.verify_all_cards! + testid = 'testid' + FakeBraintree::Customer.new({:credit_cards => [{:number=>"5105105105105100", :expiration_date=>"05/2013"}]}, {:id => testid, :merchant_id => Braintree::Configuration.merchant_id}) + # any reason to call the create instance method on the FakeBraintree::Customer ? + @customer = Customer.new(:user_id => @other_user.id) + @customer.braintree_customer_id = testid + @customer.save + + end + + teardown do + @user.destroy + @other_user.destroy + @customer.destroy + end + + test "no access if not logged in" do + get :new + assert_access_denied(true, false) + get :show, :id => @customer.braintree_customer_id + assert_access_denied(true, false) + get :edit, :id => @customer.braintree_customer_id + assert_access_denied(true, false) + end + + + test "should get new if logged in and not customer" do + login @user + get :new + assert_not_nil assigns(:tr_data) + assert_response :success + end + + test "new should direct edit if user is already a customer" do + login @other_user + get :new + assert_response :redirect + assert_equal edit_customer_url(@customer), response.header['Location'] + end + + + test "show" do + login @other_user + # Below will fail, as when we go to fetch the customer data, Braintree::Customer.find(params[:id]) won't find the customer as it is a FakeBraintree customer. + #get :show, :id => @customer.braintree_customer_id + + end + +end diff --git a/billing/test/functional/payments_controller_test.rb b/billing/test/functional/payments_controller_test.rb new file mode 100644 index 0000000..8f3bfe7 --- /dev/null +++ b/billing/test/functional/payments_controller_test.rb @@ -0,0 +1,64 @@ +require 'test_helper' +require 'fake_braintree' + +class PaymentsControllerTest < ActionController::TestCase + + test "payment when unauthorized" do + get :new + assert_not_nil assigns(:tr_data) + assert_response :success + end + + test "authenticated user must create account before making payment" do + login + get :new + assert_response :redirect + assert_equal new_customer_url, response.header['Location'] + end + + test "payment when authenticated as customer" do + customer = FactoryGirl.create :customer_with_payment_info + login customer.user + get :new + assert_not_nil assigns(:tr_data) + assert_response :success + end + + test "successful confirmation renders confirm" do + Braintree::TransparentRedirect.expects(:confirm).returns(success_response) + get :confirm + + assert_response :success + assert_template :confirm + end + + test "failed confirmation renders new" do + Braintree::TransparentRedirect.expects(:confirm).returns(failure_response) + get :confirm + + assert_response :success + assert_not_nil assigns(:tr_data) + assert_template :new + end + + def failure_response + stub success?: false, + errors: stub(for: nil, size: 0), + params: {}, + transaction: stub(status: nil) + end + + def success_response + stub success?: true, + transaction: stub_transaction + end + + # that's what you get when not following the law of demeter... + def stub_transaction + stub amount: "100.00", + id: "ASDF", + customer_details: FactoryGirl.build(:braintree_customer), + credit_card_details: FactoryGirl.build(:braintree_customer).credit_cards.first + end + +end diff --git a/billing/test/integration/customer_creation_test.rb b/billing/test/integration/customer_creation_test.rb new file mode 100644 index 0000000..3ab2e4f --- /dev/null +++ b/billing/test/integration/customer_creation_test.rb @@ -0,0 +1,63 @@ +require 'test_helper' +require 'fake_braintree' +require 'capybara/rails' + +class CustomerCreationTest < ActionDispatch::IntegrationTest + include Warden::Test::Helpers + include Capybara::DSL + + setup do + Warden.test_mode! + @user = FactoryGirl.create(:user) + login_as @user + end + + teardown do + Warden.test_reset! + end + + # Let's test both steps together with capybara + # + # This test is nice and clean but also a bit fragil: + # RackTest assumes all requests to be local. So we need + # BraintreeTestApp for the braintree transparent redirect to work. + test "create customer with braintree" do + visit '/customer/new' + assert_difference("Customer.count") do + click_button 'Save Payment Info' + end + + assert customer = Customer.find_by_user_id(@user.id) + assert customer.braintree_customer + end + + # We only test the confirmation here. + # The request to Braintree is triggered outside of rails + test "successfully confirms customer creation" do + response = post_transparent_redirect :create_customer_data, + customer: FactoryGirl.attributes_for(:braintree_customer), + redirect_url: confirm_customer_url + + assert_difference("Customer.count") do + post response['Location'] + end + + assert_equal 200, status + assert customer = Customer.find_by_user_id(@user.id) + assert customer.braintree_customer + end + + def post_transparent_redirect(type, data) + params = data.dup + params[:tr_data] = Braintree::TransparentRedirect.send(type, params) + post_transparent_redirect_params(params) + end + + def post_transparent_redirect_params(params) + uri = URI.parse(Braintree::TransparentRedirect.url) + Net::HTTP.start(uri.host, uri.port) do |http| + http.post(uri.path, Rack::Utils.build_nested_query(params)) + end + end + +end diff --git a/billing/test/test_helper.rb b/billing/test/test_helper.rb new file mode 100644 index 0000000..1e26a31 --- /dev/null +++ b/billing/test/test_helper.rb @@ -0,0 +1,15 @@ +# Configure Rails Environment +ENV["RAILS_ENV"] = "test" + +require File.expand_path("../dummy/config/environment.rb", __FILE__) +require "rails/test_help" + +Rails.backtrace_cleaner.remove_silencers! + +# Load support files +Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } + +# Load fixtures from the engine +if ActiveSupport::TestCase.method_defined?(:fixture_path=) + ActiveSupport::TestCase.fixture_path = File.expand_path("../fixtures", __FILE__) +end diff --git a/billing/test/unit/customer_test.rb b/billing/test/unit/customer_test.rb new file mode 100644 index 0000000..2ea0b5e --- /dev/null +++ b/billing/test/unit/customer_test.rb @@ -0,0 +1,36 @@ +require 'test_helper' + +class CustomerTest < ActiveSupport::TestCase + + setup do + @customer = FactoryGirl.build(:customer) + end + + test "test set of attributes should be valid" do + @customer.valid? + assert_equal Hash.new, @customer.errors.messages + end + + test "customer belongs to user" do + assert_equal User, @customer.user.class + end + + test "user validation" do + @customer.user = nil + assert !@customer.valid? + end + + test "has no payment info" do + assert !@customer.braintree_customer_id + assert !@customer.has_payment_info? + end + + test "with no braintree data" do + assert_equal @customer, @customer.with_braintree_data! + end + + test "without default credit card" do + assert_nil @customer.default_credit_card + end + +end diff --git a/billing/test/unit/customer_with_payment_info_test.rb b/billing/test/unit/customer_with_payment_info_test.rb new file mode 100644 index 0000000..ca89e65 --- /dev/null +++ b/billing/test/unit/customer_with_payment_info_test.rb @@ -0,0 +1,37 @@ +require 'test_helper' +require 'fake_braintree' + +class CustomerWithPaymentInfoTest < ActiveSupport::TestCase + + setup do + @customer = FactoryGirl.build(:customer_with_payment_info) + end + + test "has payment_info" do + assert @customer.braintree_customer_id + assert @customer.has_payment_info? + end + + test "constructs customer with braintree data" do + @customer.with_braintree_data! + assert_equal 'Big', @customer.first_name + assert_equal 'Spender', @customer.last_name + assert_equal 1, @customer.credit_cards.size + assert_equal Hash.new, @customer.custom_fields + end + + test "can access braintree_customer after reload" do + @customer.save + @customer = Customer.find_by_user_id(@customer.user_id) + @customer.with_braintree_data! + assert_equal 'Big', @customer.first_name + assert_equal 'Spender', @customer.last_name + assert_equal 1, @customer.credit_cards.size + assert_equal Hash.new, @customer.custom_fields + end + + test "sets default_credit_card" do + @customer.with_braintree_data! + assert_equal @customer.credit_cards.first, @customer.default_credit_card + end +end |