From 636692f9921bd695d726695d2d46c91f5a6e56f3 Mon Sep 17 00:00:00 2001 From: Azul Date: Fri, 11 Apr 2014 10:03:19 +0200 Subject: move engines into engines directory Also renamed help to support so it's harder to confuse it with documentation --- engines/billing/Gemfile | 23 ++ engines/billing/README.md | 49 ++++ engines/billing/Rakefile | 40 +++ .../app/controllers/billing_admin_controller.rb | 29 +++ .../app/controllers/billing_base_controller.rb | 22 ++ .../app/controllers/credit_card_info_controller.rb | 35 +++ .../billing/app/controllers/customer_controller.rb | 64 +++++ .../billing/app/controllers/payments_controller.rb | 34 +++ .../app/controllers/subscriptions_controller.rb | 63 +++++ engines/billing/app/helpers/billing_helper.rb | 51 ++++ .../billing/app/helpers/braintree_form_helper.rb | 64 +++++ engines/billing/app/helpers/braintree_helper.rb | 5 + engines/billing/app/models/customer.rb | 58 +++++ .../billing/app/views/billing_admin/show.html.haml | 7 + .../app/views/credit_card_info/confirm.html.haml | 5 + .../app/views/credit_card_info/edit.html.haml | 17 ++ .../app/views/customer/_customer_data.html.haml | 16 ++ .../app/views/customer/_transaction.html.haml | 0 .../billing/app/views/customer/confirm.html.haml | 14 ++ engines/billing/app/views/customer/edit.html.haml | 23 ++ engines/billing/app/views/customer/new.html.haml | 24 ++ engines/billing/app/views/customer/show.html.haml | 27 ++ .../views/payments/_non_customer_fields.html.haml | 16 ++ .../views/payments/_transaction_details.html.haml | 15 ++ .../billing/app/views/payments/confirm.html.haml | 26 ++ engines/billing/app/views/payments/index.html.haml | 5 + engines/billing/app/views/payments/new.html.haml | 17 ++ .../subscriptions/_subscription_details.html.haml | 26 ++ .../app/views/subscriptions/create.html.haml | 9 + .../app/views/subscriptions/destroy.html.haml | 7 + .../app/views/subscriptions/index.html.haml | 8 + .../billing/app/views/subscriptions/new.html.haml | 15 ++ .../billing/app/views/subscriptions/show.html.haml | 6 + engines/billing/config/initializers/braintree.rb | 23 ++ engines/billing/config/locales/en.yml | 11 + engines/billing/config/routes.rb | 25 ++ engines/billing/leap_web_billing.gemspec | 21 ++ engines/billing/lib/braintree_test_app.rb | 36 +++ engines/billing/lib/leap_web_billing.rb | 4 + engines/billing/lib/leap_web_billing/engine.rb | 10 + engines/billing/script/rails | 8 + engines/billing/test/broken/admin_customer_test.rb | 31 +++ .../billing/test/broken/customer_creation_test.rb | 84 +++++++ engines/billing/test/broken/subscription_test.rb | 49 ++++ engines/billing/test/factories.rb | 25 ++ .../test/functional/customer_controller_test.rb | 124 ++++++++++ .../test/functional/customers_controller_test.rb | 61 +++++ .../test/functional/payments_controller_test.rb | 50 ++++ .../functional/subscriptions_controller_test.rb | 16 ++ .../test/support/braintree_integration_test.rb | 18 ++ .../billing/test/support/customer_test_helper.rb | 11 + engines/billing/test/test_helper.rb | 15 ++ engines/billing/test/unit/customer_test.rb | 38 +++ .../test/unit/customer_with_payment_info_test.rb | 40 +++ engines/support/Gemfile | 15 ++ engines/support/README.md | 1 + engines/support/Rakefile | 44 ++++ engines/support/app/assets/javascripts/tickets.js | 4 + .../support/app/controllers/tickets_controller.rb | 153 ++++++++++++ .../app/designs/ticket/by_includes_post_by.js | 13 + .../ticket/by_includes_post_by_and_created_at.js | 12 + ..._includes_post_by_and_is_open_and_created_at.js | 12 + ..._includes_post_by_and_is_open_and_updated_at.js | 12 + .../ticket/by_includes_post_by_and_updated_at.js | 12 + .../app/helpers/auto_tickets_path_helper.rb | 53 ++++ engines/support/app/helpers/tickets_helper.rb | 76 ++++++ .../app/models/account_extension/tickets.rb | 13 + engines/support/app/models/ticket.rb | 105 ++++++++ engines/support/app/models/ticket_comment.rb | 43 ++++ engines/support/app/models/ticket_selection.rb | 71 ++++++ .../support/app/views/tickets/_comment.html.haml | 20 ++ .../support/app/views/tickets/_edit_form.html.haml | 48 ++++ .../app/views/tickets/_new_comment_form.html.haml | 13 + engines/support/app/views/tickets/_tabs.html.haml | 23 ++ .../support/app/views/tickets/_ticket.html.haml | 6 + engines/support/app/views/tickets/index.html.haml | 19 ++ engines/support/app/views/tickets/new.html.haml | 30 +++ engines/support/app/views/tickets/show.html.haml | 12 + .../config/initializers/account_lifecycle.rb | 3 + engines/support/config/locales/en.yml | 22 ++ engines/support/config/routes.rb | 8 + engines/support/leap_web_help.gemspec | 18 ++ engines/support/lib/leap_web_help.rb | 4 + engines/support/lib/leap_web_help/engine.rb | 4 + engines/support/lib/tasks/leap_web_help_tasks.rake | 4 + engines/support/script/rails | 8 + engines/support/test/factories.rb | 18 ++ .../test/functional/tickets_controller_test.rb | 273 +++++++++++++++++++++ .../support/test/integration/navigation_test.rb | 9 + engines/support/test/leap_web_help_test.rb | 7 + engines/support/test/test_helper.rb | 15 ++ .../support/test/unit/account_extension_test.rb | 12 + engines/support/test/unit/ticket_comment_test.rb | 59 +++++ engines/support/test/unit/ticket_test.rb | 88 +++++++ 94 files changed, 2882 insertions(+) create mode 100644 engines/billing/Gemfile create mode 100644 engines/billing/README.md create mode 100644 engines/billing/Rakefile create mode 100644 engines/billing/app/controllers/billing_admin_controller.rb create mode 100644 engines/billing/app/controllers/billing_base_controller.rb create mode 100644 engines/billing/app/controllers/credit_card_info_controller.rb create mode 100644 engines/billing/app/controllers/customer_controller.rb create mode 100644 engines/billing/app/controllers/payments_controller.rb create mode 100644 engines/billing/app/controllers/subscriptions_controller.rb create mode 100644 engines/billing/app/helpers/billing_helper.rb create mode 100644 engines/billing/app/helpers/braintree_form_helper.rb create mode 100644 engines/billing/app/helpers/braintree_helper.rb create mode 100644 engines/billing/app/models/customer.rb create mode 100644 engines/billing/app/views/billing_admin/show.html.haml create mode 100644 engines/billing/app/views/credit_card_info/confirm.html.haml create mode 100644 engines/billing/app/views/credit_card_info/edit.html.haml create mode 100644 engines/billing/app/views/customer/_customer_data.html.haml create mode 100644 engines/billing/app/views/customer/_transaction.html.haml create mode 100644 engines/billing/app/views/customer/confirm.html.haml create mode 100644 engines/billing/app/views/customer/edit.html.haml create mode 100644 engines/billing/app/views/customer/new.html.haml create mode 100644 engines/billing/app/views/customer/show.html.haml create mode 100644 engines/billing/app/views/payments/_non_customer_fields.html.haml create mode 100644 engines/billing/app/views/payments/_transaction_details.html.haml create mode 100644 engines/billing/app/views/payments/confirm.html.haml create mode 100644 engines/billing/app/views/payments/index.html.haml create mode 100644 engines/billing/app/views/payments/new.html.haml create mode 100644 engines/billing/app/views/subscriptions/_subscription_details.html.haml create mode 100644 engines/billing/app/views/subscriptions/create.html.haml create mode 100644 engines/billing/app/views/subscriptions/destroy.html.haml create mode 100644 engines/billing/app/views/subscriptions/index.html.haml create mode 100644 engines/billing/app/views/subscriptions/new.html.haml create mode 100644 engines/billing/app/views/subscriptions/show.html.haml create mode 100644 engines/billing/config/initializers/braintree.rb create mode 100644 engines/billing/config/locales/en.yml create mode 100644 engines/billing/config/routes.rb create mode 100644 engines/billing/leap_web_billing.gemspec create mode 100644 engines/billing/lib/braintree_test_app.rb create mode 100644 engines/billing/lib/leap_web_billing.rb create mode 100644 engines/billing/lib/leap_web_billing/engine.rb create mode 100755 engines/billing/script/rails create mode 100644 engines/billing/test/broken/admin_customer_test.rb create mode 100644 engines/billing/test/broken/customer_creation_test.rb create mode 100644 engines/billing/test/broken/subscription_test.rb create mode 100644 engines/billing/test/factories.rb create mode 100644 engines/billing/test/functional/customer_controller_test.rb create mode 100644 engines/billing/test/functional/customers_controller_test.rb create mode 100644 engines/billing/test/functional/payments_controller_test.rb create mode 100644 engines/billing/test/functional/subscriptions_controller_test.rb create mode 100644 engines/billing/test/support/braintree_integration_test.rb create mode 100644 engines/billing/test/support/customer_test_helper.rb create mode 100644 engines/billing/test/test_helper.rb create mode 100644 engines/billing/test/unit/customer_test.rb create mode 100644 engines/billing/test/unit/customer_with_payment_info_test.rb create mode 100644 engines/support/Gemfile create mode 100644 engines/support/README.md create mode 100644 engines/support/Rakefile create mode 100644 engines/support/app/assets/javascripts/tickets.js create mode 100644 engines/support/app/controllers/tickets_controller.rb create mode 100644 engines/support/app/designs/ticket/by_includes_post_by.js create mode 100644 engines/support/app/designs/ticket/by_includes_post_by_and_created_at.js create mode 100644 engines/support/app/designs/ticket/by_includes_post_by_and_is_open_and_created_at.js create mode 100644 engines/support/app/designs/ticket/by_includes_post_by_and_is_open_and_updated_at.js create mode 100644 engines/support/app/designs/ticket/by_includes_post_by_and_updated_at.js create mode 100644 engines/support/app/helpers/auto_tickets_path_helper.rb create mode 100644 engines/support/app/helpers/tickets_helper.rb create mode 100644 engines/support/app/models/account_extension/tickets.rb create mode 100644 engines/support/app/models/ticket.rb create mode 100644 engines/support/app/models/ticket_comment.rb create mode 100644 engines/support/app/models/ticket_selection.rb create mode 100644 engines/support/app/views/tickets/_comment.html.haml create mode 100644 engines/support/app/views/tickets/_edit_form.html.haml create mode 100644 engines/support/app/views/tickets/_new_comment_form.html.haml create mode 100644 engines/support/app/views/tickets/_tabs.html.haml create mode 100644 engines/support/app/views/tickets/_ticket.html.haml create mode 100644 engines/support/app/views/tickets/index.html.haml create mode 100644 engines/support/app/views/tickets/new.html.haml create mode 100644 engines/support/app/views/tickets/show.html.haml create mode 100644 engines/support/config/initializers/account_lifecycle.rb create mode 100644 engines/support/config/locales/en.yml create mode 100644 engines/support/config/routes.rb create mode 100644 engines/support/leap_web_help.gemspec create mode 100644 engines/support/lib/leap_web_help.rb create mode 100644 engines/support/lib/leap_web_help/engine.rb create mode 100644 engines/support/lib/tasks/leap_web_help_tasks.rake create mode 100755 engines/support/script/rails create mode 100644 engines/support/test/factories.rb create mode 100644 engines/support/test/functional/tickets_controller_test.rb create mode 100644 engines/support/test/integration/navigation_test.rb create mode 100644 engines/support/test/leap_web_help_test.rb create mode 100644 engines/support/test/test_helper.rb create mode 100644 engines/support/test/unit/account_extension_test.rb create mode 100644 engines/support/test/unit/ticket_comment_test.rb create mode 100644 engines/support/test/unit/ticket_test.rb (limited to 'engines') diff --git a/engines/billing/Gemfile b/engines/billing/Gemfile new file mode 100644 index 0000000..30e9669 --- /dev/null +++ b/engines/billing/Gemfile @@ -0,0 +1,23 @@ +source "https://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/engines/billing/README.md b/engines/billing/README.md new file mode 100644 index 0000000..3ef6153 --- /dev/null +++ b/engines/billing/README.md @@ -0,0 +1,49 @@ +Billing Engine +==================== + +Currently, this engine support billing via Braintree. More backends to come later. + +Configuration +---------------------------------- + +Start with a sandbox account, which you can get here: https://www.braintreepayments.com/get-started + +Once you have registered for the sandbox, logging in will show you three important variables you will need to configure: + +* merchantId +* publicKey +* privatekey + +To configure the billing engine, edit `config/config.yaml` like so: + + production: (or "development", as you prefer) + billing: + braintree: + environment: sandbox + merchant_id: Ohp2aijaaqu6oJ4w + public_key: ahnar0UwLahwe6Ce + private_key: aemie2Geohgah2EaOad9DeeruW4Iegh4 + +If deploying via puppet, the same data in webapp.json would like this: + + "billing": { + "braintree": { + "environment": "sandbox", + "merchant_id": "Ohp2aijaaqu6oJ4w", + "public_key": "ahnar0UwLahwe6Ce", + "private_key": "aemie2Geohgah2EaOad9DeeruW4Iegh4" + } + } + +Now, you should be able to add charges to your own sandbox when you run the webapp. + +The acceptable values for `billing.braintree.environment` are: `development`, `qa`, `sandbox`, or `production`. + +Plans +-------------------------------- + +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/engines/billing/Rakefile b/engines/billing/Rakefile new file mode 100644 index 0000000..52929c4 --- /dev/null +++ b/engines/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/engines/billing/app/controllers/billing_admin_controller.rb b/engines/billing/app/controllers/billing_admin_controller.rb new file mode 100644 index 0000000..e11d4ee --- /dev/null +++ b/engines/billing/app/controllers/billing_admin_controller.rb @@ -0,0 +1,29 @@ +class BillingAdminController < BillingBaseController + before_filter :require_admin + + def show + + br_atleast_90_days = Braintree::Subscription.search do |search| + search.days_past_due >= 90 + end + @past_due_atleast_90_days = braintree_resource_collection_to_array(br_atleast_90_days) + + br_all_past_due = Braintree::Subscription.search do |search| + search.status.is Braintree::Subscription::Status::PastDue + #cannot search by balance. + end + @all_past_due = braintree_resource_collection_to_array(br_all_past_due) + + end + + private + + def braintree_resource_collection_to_array(braintree_resource_collection) + array = [] + braintree_resource_collection.each do |object| + array << object + end + array + end + +end diff --git a/engines/billing/app/controllers/billing_base_controller.rb b/engines/billing/app/controllers/billing_base_controller.rb new file mode 100644 index 0000000..0453677 --- /dev/null +++ b/engines/billing/app/controllers/billing_base_controller.rb @@ -0,0 +1,22 @@ +class BillingBaseController < ApplicationController + before_filter :assign_user + + helper 'billing' + + # required for navigation to work. + def assign_user + if params[:user_id] + @user = User.find(params[:user_id]) + elsif params[:action] == "confirm"# confirms will come back with different ID set, so check for this first + # This is only for cases where an admin cannot apply action for customer, but should be all confirms + @user = current_user + elsif params[:id] + @user = User.find(params[:id]) + else + # TODO + # hacky, what are cases where @user hasn't yet been set? certainly some cases with subscriptions and payments + @user = current_user + end + end + +end diff --git a/engines/billing/app/controllers/credit_card_info_controller.rb b/engines/billing/app/controllers/credit_card_info_controller.rb new file mode 100644 index 0000000..fbaa6f1 --- /dev/null +++ b/engines/billing/app/controllers/credit_card_info_controller.rb @@ -0,0 +1,35 @@ +class CreditCardInfoController < ApplicationController + before_filter :require_login, :set_user + + def edit + @credit_card = Braintree::CreditCard.find(params[:id]) + customer = Customer.find_by_user_id(@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 + # this assumes anybody, even an admin, will not access for another user. + @user = current_user + end + +end diff --git a/engines/billing/app/controllers/customer_controller.rb b/engines/billing/app/controllers/customer_controller.rb new file mode 100644 index 0000000..6cbcb44 --- /dev/null +++ b/engines/billing/app/controllers/customer_controller.rb @@ -0,0 +1,64 @@ +class CustomerController < BillingBaseController + before_filter :require_login, :fetch_customer + + def show + if @customer + @customer.with_braintree_data! + @default_cc = @customer.default_credit_card + @active_subscription = @customer.subscriptions + @transactions = @customer.braintree_customer.transactions + end + end + + def new + if @customer.has_payment_info? + redirect_to edit_customer_path(@user), :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 + access_denied unless @user == current_user # admins cannot do this for others + @tr_data = Braintree::TransparentRedirect. + create_customer_data(:redirect_url => confirm_customer_url) + end + + def fetch_edit_transparent_redirect_data + access_denied unless @user == current_user # admins cannot do this for others + @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 fetch_customer + @customer = Customer.find_by_user_id(@user.id) + if @user == current_user + @customer ||= Customer.new(user: @user) + end + access_denied unless (@customer and (@customer.user == current_user)) or admin? + end +end diff --git a/engines/billing/app/controllers/payments_controller.rb b/engines/billing/app/controllers/payments_controller.rb new file mode 100644 index 0000000..fce6570 --- /dev/null +++ b/engines/billing/app/controllers/payments_controller.rb @@ -0,0 +1,34 @@ +class PaymentsController < BillingBaseController + before_filter :require_login, :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 + access_denied unless admin? or (@user == current_user) + customer = Customer.find_by_user_id(@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 + @tr_data = Braintree::TransparentRedirect.transaction_data redirect_url: confirm_payment_url, + transaction: { type: "sale", options: {submit_for_settlement: true } } + end + +end diff --git a/engines/billing/app/controllers/subscriptions_controller.rb b/engines/billing/app/controllers/subscriptions_controller.rb new file mode 100644 index 0000000..f066b3c --- /dev/null +++ b/engines/billing/app/controllers/subscriptions_controller.rb @@ -0,0 +1,63 @@ +class SubscriptionsController < BillingBaseController + before_filter :require_login + before_filter :fetch_subscription, :only => [:show, :destroy] + before_filter :confirm_cancel_subscription, :only => [:destroy] + before_filter :confirm_self_or_admin, :only => [:index] + before_filter :confirm_no_pending_active_pastdue_subscription, :only => [:new, :create] + # for now, admins cannot create or destroy subscriptions for others: + before_filter :confirm_self, :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] ) + #if you want to test pastdue, can add :price => '2001', :trial_period => true,:trial_duration => 1,:trial_duration_unit => "day" and then wait a day + end + + def destroy + @result = Braintree::Subscription.cancel params[:id] + end + + def index + customer = Customer.find_by_user_id(@user.id) + @subscriptions = customer.subscriptions(nil, false) + end + + private + + def fetch_subscription + @subscription = Braintree::Subscription.find params[:id] + @credit_card = Braintree::CreditCard.find @subscription.payment_method_token + @subscription_customer_id = @credit_card.customer_id + current_user_customer = Customer.find_by_user_id(current_user.id) + access_denied unless admin? or (current_user_customer and current_user_customer.braintree_customer_id == @subscription_customer_id) + + end + + def confirm_cancel_subscription + access_denied unless view_context.allow_cancel_subscription(@subscription) + end + + def confirm_no_pending_active_pastdue_subscription + @customer = Customer.find_by_user_id(@user.id) + if subscription = @customer.subscriptions # will return pending, active or pastdue subscription, if it exists + redirect_to user_subscription_path(@user, subscription.id), :notice => 'You already have a subscription' + end + end + + def confirm_self + @user == current_user + end + + def confirm_self_or_admin + access_denied unless confirm_self or admin? + end + +end diff --git a/engines/billing/app/helpers/billing_helper.rb b/engines/billing/app/helpers/billing_helper.rb new file mode 100644 index 0000000..b9e5e2e --- /dev/null +++ b/engines/billing/app/helpers/billing_helper.rb @@ -0,0 +1,51 @@ +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 billing_top_link(user) + # for admins, top link will show special admin information, which has link to show their own customer information + if (admin? and user == current_user) + billing_admin_path + else + show_or_new_customer_link(user) + end + end + + def show_or_new_customer_link(user) + # Link to show if user is admin viewing another user, or user is already a customer. + # Otherwise link to create a new customer. + if (admin? and (user != current_user)) or ((customer = Customer.find_by_user_id(user.id)) and customer.has_payment_info?) + show_customer_path(user) + else + new_customer_path + end + end + + # a bit strange to put here, but we don't have a subscription model + def user_for_subscription(subscription) + + if (transaction = subscription.transactions.first) + # much quicker, but will only work if there is already a transaction associated with subscription (should generally be) + braintree_customer_id = transaction.customer_details.id + else + credit_card = Braintree::CreditCard.find(subscription.payment_method_token) + braintree_customer_id = credit_card.customer_id + end + + customer = Customer.find_by_braintree_customer_id(braintree_customer_id) + user = User.find(customer.user_id) + + end + + def allow_cancel_subscription(subscription) + ['Active', 'Pending'].include? subscription.status or (admin? and subscription.status == 'Past Due') + end + +end diff --git a/engines/billing/app/helpers/braintree_form_helper.rb b/engines/billing/app/helpers/braintree_form_helper.rb new file mode 100644 index 0000000..cb322fa --- /dev/null +++ b/engines/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/engines/billing/app/helpers/braintree_helper.rb b/engines/billing/app/helpers/braintree_helper.rb new file mode 100644 index 0000000..2d18b6c --- /dev/null +++ b/engines/billing/app/helpers/braintree_helper.rb @@ -0,0 +1,5 @@ +module BraintreeHelper + + +end + diff --git a/engines/billing/app/models/customer.rb b/engines/billing/app/models/customer.rb new file mode 100644 index 0000000..1acc7a5 --- /dev/null +++ b/engines/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_pending_active_pastdue=true) + self.with_braintree_data! + return unless has_payment_info? + + subscriptions = [] + self.default_credit_card.subscriptions.each do |sub| + if only_pending_active_pastdue and ['Pending', 'Active','Past Due'].include? sub.status + return sub + else + subscriptions << sub + end + end + only_pending_active_pastdue ? nil : subscriptions + end + +end diff --git a/engines/billing/app/views/billing_admin/show.html.haml b/engines/billing/app/views/billing_admin/show.html.haml new file mode 100644 index 0000000..0382cf0 --- /dev/null +++ b/engines/billing/app/views/billing_admin/show.html.haml @@ -0,0 +1,7 @@ +%legend= t(:more_than_90_days_past_due) += render(:partial => "subscriptions/subscription_details", :collection => @past_due_atleast_90_days, :as => 'subscription', :locals => {:show_user => true}) || t(:none) +%legend= t(:all_past_due) += render(:partial => "subscriptions/subscription_details", :collection => @all_past_due, :as => 'subscription', :locals => {:show_user => true}) || t(:none) + +%legend= t(:your_settings) += link_to 'view own billing settings', show_or_new_customer_link(current_user) \ No newline at end of file diff --git a/engines/billing/app/views/credit_card_info/confirm.html.haml b/engines/billing/app/views/credit_card_info/confirm.html.haml new file mode 100644 index 0000000..9dd8176 --- /dev/null +++ b/engines/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/engines/billing/app/views/credit_card_info/edit.html.haml b/engines/billing/app/views/credit_card_info/edit.html.haml new file mode 100644 index 0000000..bd86a4c --- /dev/null +++ b/engines/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(@user.id), :class => :btn diff --git a/engines/billing/app/views/customer/_customer_data.html.haml b/engines/billing/app/views/customer/_customer_data.html.haml new file mode 100644 index 0000000..e9df040 --- /dev/null +++ b/engines/billing/app/views/customer/_customer_data.html.haml @@ -0,0 +1,16 @@ +%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 + - if current_user == @user + = link_to t(:edit_saved_data), edit_customer_path(@user.id), :class => :btn diff --git a/engines/billing/app/views/customer/_transaction.html.haml b/engines/billing/app/views/customer/_transaction.html.haml new file mode 100644 index 0000000..e69de29 diff --git a/engines/billing/app/views/customer/confirm.html.haml b/engines/billing/app/views/customer/confirm.html.haml new file mode 100644 index 0000000..877a8ac --- /dev/null +++ b/engines/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(@user.id) += link_to 'View Customer Info', show_customer_path(@user.id), :class=> :btn \ No newline at end of file diff --git a/engines/billing/app/views/customer/edit.html.haml b/engines/billing/app/views/customer/edit.html.haml new file mode 100644 index 0000000..e882d53 --- /dev/null +++ b/engines/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(@user), :class=> :btn diff --git a/engines/billing/app/views/customer/new.html.haml b/engines/billing/app/views/customer/new.html.haml new file mode 100644 index 0000000..e1f5ba9 --- /dev/null +++ b/engines/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/engines/billing/app/views/customer/show.html.haml b/engines/billing/app/views/customer/show.html.haml new file mode 100644 index 0000000..ec1779c --- /dev/null +++ b/engines/billing/app/views/customer/show.html.haml @@ -0,0 +1,27 @@ +- if admin? and !@customer + = t(:no_saved_customer) +- else + = render :partial => '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), user_payments_path(@user) + %legend= t(:subscriptions) + - if @active_subscription + = render :partial => "subscriptions/subscription_details", :locals => {:subscription => @active_subscription} + - else + %p + = t(:no_relevant_subscription) + - if current_user == @user + %p + .form-actions + = link_to t(:subscribe_to_plan), new_subscription_path, :class => :btn + %p + = link_to t(:all_subscriptions), user_subscriptions_path(@user) + +.form-actions + = link_to t(:make_donation), new_payment_path, :class => 'btn btn-primary' diff --git a/engines/billing/app/views/payments/_non_customer_fields.html.haml b/engines/billing/app/views/payments/_non_customer_fields.html.haml new file mode 100644 index 0000000..77cfe95 --- /dev/null +++ b/engines/billing/app/views/payments/_non_customer_fields.html.haml @@ -0,0 +1,16 @@ += field_set_tag "Personal Information" 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/engines/billing/app/views/payments/_transaction_details.html.haml b/engines/billing/app/views/payments/_transaction_details.html.haml new file mode 100644 index 0000000..85e4f6a --- /dev/null +++ b/engines/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 # should not have any of these + Not paid as part of subscription \ No newline at end of file diff --git a/engines/billing/app/views/payments/confirm.html.haml b/engines/billing/app/views/payments/confirm.html.haml new file mode 100644 index 0000000..45af3c9 --- /dev/null +++ b/engines/billing/app/views/payments/confirm.html.haml @@ -0,0 +1,26 @@ +%h1 Payment Result +%div Thank you for your donation. +%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 \ No newline at end of file diff --git a/engines/billing/app/views/payments/index.html.haml b/engines/billing/app/views/payments/index.html.haml new file mode 100644 index 0000000..7a89917 --- /dev/null +++ b/engines/billing/app/views/payments/index.html.haml @@ -0,0 +1,5 @@ +%h2=t :transaction_history +- if (@transactions.count == 0) + = t(:no_transaction_history) +- @transactions.each do |t| + = render :partial => "transaction_details", :locals => {:transaction => t} \ No newline at end of file diff --git a/engines/billing/app/views/payments/new.html.haml b/engines/billing/app/views/payments/new.html.haml new file mode 100644 index 0000000..e9a8273 --- /dev/null +++ b/engines/billing/app/views/payments/new.html.haml @@ -0,0 +1,17 @@ +%h1 + = t(:Donation) +- if logged_in? + = t(:donation_not_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 != 'success' + %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 + = render :partial => 'non_customer_fields', :locals => {:f => f} + = hidden_field_tag :tr_data, @tr_data + = f.submit "Submit Donation", :class => 'btn btn-primary' diff --git a/engines/billing/app/views/subscriptions/_subscription_details.html.haml b/engines/billing/app/views/subscriptions/_subscription_details.html.haml new file mode 100644 index 0000000..6145c95 --- /dev/null +++ b/engines/billing/app/views/subscriptions/_subscription_details.html.haml @@ -0,0 +1,26 @@ +%p + - if local_assigns[:show_user] + User: + - user_to_show = user_for_subscription(subscription) + = link_to user_to_show.login, user_overview_path(user_to_show) + ID: + = link_to subscription.id, user_subscription_path(@user, subscription.id) + Balance: + - color = (subscription.balance > 0) ? "red" : "" + %font{:color => color} + = 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: + = number_to_currency(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/engines/billing/app/views/subscriptions/create.html.haml b/engines/billing/app/views/subscriptions/create.html.haml new file mode 100644 index 0000000..2b6c5e9 --- /dev/null +++ b/engines/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/engines/billing/app/views/subscriptions/destroy.html.haml b/engines/billing/app/views/subscriptions/destroy.html.haml new file mode 100644 index 0000000..44b4333 --- /dev/null +++ b/engines/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(@user), :class=> :btn \ No newline at end of file diff --git a/engines/billing/app/views/subscriptions/index.html.haml b/engines/billing/app/views/subscriptions/index.html.haml new file mode 100644 index 0000000..3d4e8fd --- /dev/null +++ b/engines/billing/app/views/subscriptions/index.html.haml @@ -0,0 +1,8 @@ +%h2=t :all_subscriptions +- pending_active_pastdue = false +- @subscriptions.each do |s| + - if ['Pending', 'Active','Past Due'].include? s.status + - pending_active_pastdue = true + = render :partial => "subscription_details", :locals => {:subscription => s} +- if !pending_active_pastdue and @user == current_user + = link_to 'subscribe to plan', new_subscription_path, :class => :btn \ No newline at end of file diff --git a/engines/billing/app/views/subscriptions/new.html.haml b/engines/billing/app/views/subscriptions/new.html.haml new file mode 100644 index 0000000..4183458 --- /dev/null +++ b/engines/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/engines/billing/app/views/subscriptions/show.html.haml b/engines/billing/app/views/subscriptions/show.html.haml new file mode 100644 index 0000000..2699db9 --- /dev/null +++ b/engines/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), user_subscription_path(@user, @subscription.id), :confirm => t(:are_you_sure), :method => :delete, :class => 'btn btn-danger' if allow_cancel_subscription(@subscription) diff --git a/engines/billing/config/initializers/braintree.rb b/engines/billing/config/initializers/braintree.rb new file mode 100644 index 0000000..3d87f4c --- /dev/null +++ b/engines/billing/config/initializers/braintree.rb @@ -0,0 +1,23 @@ +# +# set logger +# +if APP_CONFIG[:logfile].blank? + require 'syslog/logger' + Braintree::Configuration.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new('webapp')) +else + Braintree::Configuration.logger = Logger.new('log/braintree.log') +end + +# +# You can set these per environment in config/config.yml: +# +# Environment must be one of: :development, :qa, :sandbox, :production +# +if billing = APP_CONFIG[:billing] + if braintree = billing[:braintree] + Braintree::Configuration.environment = braintree[:environment].downcase.to_sym + Braintree::Configuration.merchant_id = braintree[:merchant_id] + Braintree::Configuration.public_key = braintree[:public_key] + Braintree::Configuration.private_key = braintree[:private_key] + end +end diff --git a/engines/billing/config/locales/en.yml b/engines/billing/config/locales/en.yml new file mode 100644 index 0000000..1300958 --- /dev/null +++ b/engines/billing/config/locales/en.yml @@ -0,0 +1,11 @@ +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" + donation_not_payment: "Note: This is a donation, and will not be applied towards your account." + no_relevant_subscription: "No subscription which is Active, Pending, or Past Due" + plan: "Plan" + description: "Description" + cost: "Cost" + free: "Free" \ No newline at end of file diff --git a/engines/billing/config/routes.rb b/engines/billing/config/routes.rb new file mode 100644 index 0000000..7263dff --- /dev/null +++ b/engines/billing/config/routes.rb @@ -0,0 +1,25 @@ +Rails.application.routes.draw do + + scope "(:locale)", :locale => MATCH_LOCALE do + match 'payments/new' => 'payments#new', :as => :new_payment + match 'payments/confirm' => 'payments#confirm', :as => :confirm_payment + resources :users do + resources :payments, :only => [:index] + resources :subscriptions, :only => [:index, :show, :destroy] + end + + 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, :update] # index, show & destroy are within users path + match 'billing_admin' => 'billing_admin#show', :as => :billing_admin + + #match 'transactions/:product_id/new' => 'transactions#new', :as => :new_transaction + #match 'transactions/confirm/:product_id' => 'transactions#confirm', :as => :confirm_transaction + end + +end diff --git a/engines/billing/leap_web_billing.gemspec b/engines/billing/leap_web_billing.gemspec new file mode 100644 index 0000000..c6ac3f3 --- /dev/null +++ b/engines/billing/leap_web_billing.gemspec @@ -0,0 +1,21 @@ +$:.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 "braintree-rails", "~> 0.4.5" + s.add_dependency "braintree" + #s.add_dependency "carmen-rails" +end diff --git a/engines/billing/lib/braintree_test_app.rb b/engines/billing/lib/braintree_test_app.rb new file mode 100644 index 0000000..41c327d --- /dev/null +++ b/engines/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/engines/billing/lib/leap_web_billing.rb b/engines/billing/lib/leap_web_billing.rb new file mode 100644 index 0000000..288d846 --- /dev/null +++ b/engines/billing/lib/leap_web_billing.rb @@ -0,0 +1,4 @@ +require "leap_web_billing/engine" + +module LeapWebBilling +end diff --git a/engines/billing/lib/leap_web_billing/engine.rb b/engines/billing/lib/leap_web_billing/engine.rb new file mode 100644 index 0000000..ab574f2 --- /dev/null +++ b/engines/billing/lib/leap_web_billing/engine.rb @@ -0,0 +1,10 @@ +# thou shall require all your dependencies in an engine. +#require "braintree-rails" +require "braintree" +#require "carmen-rails" + +module LeapWebBilling + class Engine < ::Rails::Engine + + end +end diff --git a/engines/billing/script/rails b/engines/billing/script/rails new file mode 100755 index 0000000..8bd9c0a --- /dev/null +++ b/engines/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/engines/billing/test/broken/admin_customer_test.rb b/engines/billing/test/broken/admin_customer_test.rb new file mode 100644 index 0000000..df92a0d --- /dev/null +++ b/engines/billing/test/broken/admin_customer_test.rb @@ -0,0 +1,31 @@ +require 'test_helper' +require 'fake_braintree' + +class AdminCustomerTest < BraintreeIntegrationTest + + setup do + @admin = User.find_by_login('admin') || FactoryGirl.create(:user, login: 'admin') + @user = FactoryGirl.create(:user) + end + + teardown do + @user.destroy if @user + @admin.destroy if @admin + end + + test "check non customer as admin" do + login_as @admin + visit '/' + click_link 'Users' + click_link @user.login + click_link 'Billing Settings' + assert page.has_content? @user.email_address + assert page.has_content? 'No Saved Customer' + end + + test "check customer as admin" do + skip "cannot check customer as admin" + # it would be good to have a test where an admin tries to view the 'Billing Settings' for another user. + # However, partially due to limitations of FakeBraintree, this doesn't seem pursuing at this time. + end +end diff --git a/engines/billing/test/broken/customer_creation_test.rb b/engines/billing/test/broken/customer_creation_test.rb new file mode 100644 index 0000000..90319a9 --- /dev/null +++ b/engines/billing/test/broken/customer_creation_test.rb @@ -0,0 +1,84 @@ +require 'test_helper' +require 'fake_braintree' + +class CustomerCreationTest < BraintreeIntegrationTest + + setup do + @user = FactoryGirl.create(:user) + login_as @user + end + + teardown do + @user.destroy + end + + # Let's test both steps together with capybara + # + # This test is nice and clean but also a bit fragile: + # RackTest assumes all requests to be local. So we need + # BraintreeTestApp for the braintree transparent redirect to work. + # + # this mystifies me why this works. when i type the click_button line (and the + # customer.braintree_customer line) in the debugger, it gives a timeout, + # but it works fine embedded in the test. + test "create customer with braintree" do + visit '/' + click_link 'Billing Settings' + # i am a bit unclear why this works, as it seems there will be validation errors + assert_difference("Customer.count") do + click_button 'Save Payment Info' # this gives me a timeout + 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 + # In skippped test below, we see this works even if the attributes are + # for a broken customer + 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 + + + test "failed customer creation" do + skip "cannot get customer creation to fail" + + FakeBraintree.decline_all_cards! + + response = post_transparent_redirect :create_customer_data, + customer: FactoryGirl.attributes_for(:broken_customer), + redirect_url: confirm_customer_url + + assert FakeBraintree.decline_all_cards? + assert_no_difference("Customer.count") do + post response['Location'] #this gives me a timeout when run alone + end + assert_nil Customer.find_by_user_id(@user.id) + + 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/engines/billing/test/broken/subscription_test.rb b/engines/billing/test/broken/subscription_test.rb new file mode 100644 index 0000000..cd010bd --- /dev/null +++ b/engines/billing/test/broken/subscription_test.rb @@ -0,0 +1,49 @@ +require 'test_helper' +require 'fake_braintree' + +class SubscriptionTest < BraintreeIntegrationTest + include CustomerTestHelper + include StubRecordHelper + + setup do + @admin = User.find_by_login('admin') || FactoryGirl.create(:user, login: 'admin') + @customer = stub_customer + @braintree_customer = @customer.braintree_customer + response = Braintree::Subscription.create plan_id: '5', + payment_method_token: @braintree_customer.credit_cards.first.token, + price: '10' + @subscription = response.subscription + end + + teardown do + @admin.destroy + end + + test "admin can see all subscriptions for another" do + login_as @admin + @customer.stubs(:subscriptions).returns([@subscription]) + @subscription.stubs(:balance).returns 0 + visit user_subscriptions_path(@customer.user_id, :locale => nil) + assert page.has_content?("Subscriptions") + assert page.has_content?("Status: Active") + end + + # test "user cannot see all subscriptions for other user" do + #end + + #test "admin cannot add subscription for another" do + #end + + #test "authenticated user can cancel own subscription" do + #end + + #test "user cannot add subscription if they have active one" do + #end + + #test "user can view own subscriptions" + #end + + #test "admin can view another user's subscriptions" do + #end + +end diff --git a/engines/billing/test/factories.rb b/engines/billing/test/factories.rb new file mode 100644 index 0000000..87543b2 --- /dev/null +++ b/engines/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/engines/billing/test/functional/customer_controller_test.rb b/engines/billing/test/functional/customer_controller_test.rb new file mode 100644 index 0000000..d943e23 --- /dev/null +++ b/engines/billing/test/functional/customer_controller_test.rb @@ -0,0 +1,124 @@ +require 'test_helper' +require 'fake_braintree' + +class CustomerControllerTest < ActionController::TestCase + include CustomerTestHelper + + 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 = stub_customer + login customer.user + get :edit, id: customer.user.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 customer 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 = stub_customer + customer.expects(:save) + 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 customer creation" do + skip "can't get customer creation to fail" + login + FakeBraintree.decline_all_cards! + # what is prepare_confirmation ?? this method isn't found + 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 customer 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 customer update with stubbing" do + customer = stub_customer + 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/engines/billing/test/functional/customers_controller_test.rb b/engines/billing/test/functional/customers_controller_test.rb new file mode 100644 index 0000000..46c33c9 --- /dev/null +++ b/engines/billing/test/functional/customers_controller_test.rb @@ -0,0 +1,61 @@ +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' + #this wasn't actually being used + #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.user), response.header['Location'] + end + + + test "show" do + skip "show customer" + 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/engines/billing/test/functional/payments_controller_test.rb b/engines/billing/test/functional/payments_controller_test.rb new file mode 100644 index 0000000..90b7582 --- /dev/null +++ b/engines/billing/test/functional/payments_controller_test.rb @@ -0,0 +1,50 @@ +require 'test_helper' +require 'fake_braintree' + +class PaymentsControllerTest < ActionController::TestCase + include CustomerTestHelper + + test "payment when unauthorized" do + 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/engines/billing/test/functional/subscriptions_controller_test.rb b/engines/billing/test/functional/subscriptions_controller_test.rb new file mode 100644 index 0000000..a6a1057 --- /dev/null +++ b/engines/billing/test/functional/subscriptions_controller_test.rb @@ -0,0 +1,16 @@ +require 'test_helper' +require 'fake_braintree' + +class SubscriptionsControllerTest < ActionController::TestCase + include CustomerTestHelper + + test "destroy cancels subscription" do + customer = stub_customer + login customer.user + result = Braintree::Subscription.create plan_id: 'my_plan', + payment_method_token: customer.braintree_customer.credit_cards.first.token + subscription = result.subscription + delete :destroy, id: subscription.id, user_id: customer.user.id + assert_equal "Canceled", Braintree::Subscription.find(subscription.id).status + end +end diff --git a/engines/billing/test/support/braintree_integration_test.rb b/engines/billing/test/support/braintree_integration_test.rb new file mode 100644 index 0000000..976c5a2 --- /dev/null +++ b/engines/billing/test/support/braintree_integration_test.rb @@ -0,0 +1,18 @@ +require 'capybara/rails' +# require 'fake_braintree' - messes up other integration tests +require 'braintree_test_app' + +class BraintreeIntegrationTest < BrowserIntegrationTest + include Warden::Test::Helpers + + setup do + Warden.test_mode! + Rails.application.config.middleware.use BraintreeTestApp + end + + teardown do + Warden.test_reset! + Rails.application.config.middleware.delete "BraintreeTestApp" + end + +end diff --git a/engines/billing/test/support/customer_test_helper.rb b/engines/billing/test/support/customer_test_helper.rb new file mode 100644 index 0000000..adac00a --- /dev/null +++ b/engines/billing/test/support/customer_test_helper.rb @@ -0,0 +1,11 @@ +module CustomerTestHelper + + def stub_customer(user = nil) + user ||= find_record :user + customer = stub_record :customer_with_payment_info, + user: user, + user_id: user.id + Customer.stubs(:find_by_user_id).with(user.id).returns(customer) + return customer + end +end diff --git a/engines/billing/test/test_helper.rb b/engines/billing/test/test_helper.rb new file mode 100644 index 0000000..7ad3869 --- /dev/null +++ b/engines/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/engines/billing/test/unit/customer_test.rb b/engines/billing/test/unit/customer_test.rb new file mode 100644 index 0000000..6156f87 --- /dev/null +++ b/engines/billing/test/unit/customer_test.rb @@ -0,0 +1,38 @@ +require 'test_helper' + +class CustomerTest < ActiveSupport::TestCase + include StubRecordHelper + + setup do + @user = find_record :user + @customer = FactoryGirl.build(:customer, user: @user) + 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/engines/billing/test/unit/customer_with_payment_info_test.rb b/engines/billing/test/unit/customer_with_payment_info_test.rb new file mode 100644 index 0000000..0589a59 --- /dev/null +++ b/engines/billing/test/unit/customer_with_payment_info_test.rb @@ -0,0 +1,40 @@ +require 'test_helper' +require 'fake_braintree' + +class CustomerWithPaymentInfoTest < ActiveSupport::TestCase + include StubRecordHelper + + setup do + @user = find_record :user + @customer = FactoryGirl.build(:customer_with_payment_info, user: @user) + 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 + @customer.destroy + end + + test "sets default_credit_card" do + @customer.with_braintree_data! + assert_equal @customer.credit_cards.first, @customer.default_credit_card + end +end diff --git a/engines/support/Gemfile b/engines/support/Gemfile new file mode 100644 index 0000000..ad7d29b --- /dev/null +++ b/engines/support/Gemfile @@ -0,0 +1,15 @@ +source "https://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 leap_web_users.gemspec. +# Bundler will treat runtime dependencies like base dependencies, and +# development dependencies will be added by default to the :development group. +gemspec + +# To use debugger +# gem 'ruby-debug' diff --git a/engines/support/README.md b/engines/support/README.md new file mode 100644 index 0000000..c9573e6 --- /dev/null +++ b/engines/support/README.md @@ -0,0 +1 @@ +Implements a simple, clean, and easy to use help ticketing system. \ No newline at end of file diff --git a/engines/support/Rakefile b/engines/support/Rakefile new file mode 100644 index 0000000..0e73163 --- /dev/null +++ b/engines/support/Rakefile @@ -0,0 +1,44 @@ +#!/usr/bin/env rake + +require 'rake/packagetask' +require 'rubygems/package_task' + +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 = 'LeapWebHelp' + rdoc.options << '--line-numbers' + rdoc.rdoc_files.include('README.rdoc') + rdoc.rdoc_files.include('lib/**/*.rb') +end + +spec = eval(File.read('leap_web_help.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/engines/support/app/assets/javascripts/tickets.js b/engines/support/app/assets/javascripts/tickets.js new file mode 100644 index 0000000..18537aa --- /dev/null +++ b/engines/support/app/assets/javascripts/tickets.js @@ -0,0 +1,4 @@ +//$(document).ready(function () { +// $.fn.editable.defaults.mode = 'inline'; +// $('#subject').editable(); +//}); \ No newline at end of file diff --git a/engines/support/app/controllers/tickets_controller.rb b/engines/support/app/controllers/tickets_controller.rb new file mode 100644 index 0000000..d65ee43 --- /dev/null +++ b/engines/support/app/controllers/tickets_controller.rb @@ -0,0 +1,153 @@ +class TicketsController < ApplicationController + include AutoTicketsPathHelper + + respond_to :html, :json + #has_scope :open, :type => boolean + + before_filter :require_login, :only => [:index] + before_filter :fetch_ticket, :only => [:show, :update, :destroy] # don't now have an edit method + before_filter :fetch_user + before_filter :set_title + + def new + @ticket = Ticket.new + @ticket.comments.build + end + + def create + @ticket = Ticket.new(params[:ticket]) + + @ticket.comments.last.posted_by = (logged_in? ? current_user.id : nil) #protecting posted_by isn't working, so this should protect it. + @ticket.comments.last.private = false unless admin? + @ticket.created_by = current_user.id if logged_in? + @ticket.email = current_user.email_address if logged_in? and current_user.email_address + + if @ticket.save + flash[:notice] = t(:thing_was_successfully_created, :thing => t(:ticket)) + end + + # cannot set this until ticket has been saved, as @ticket.id will not be set + if !logged_in? and flash[:notice] + flash[:notice] += " " + t(:access_ticket_text, :full_url => ticket_url(@ticket.id)) + end + respond_with(@ticket, :location => auto_ticket_path(@ticket)) + end + + def show + @comment = TicketComment.new + if !@ticket + redirect_to auto_tickets_path, :alert => t(:no_such_thing, :thing => t(:ticket)) + return + end + end + + def update + if params[:commit] == 'close' + @ticket.is_open = false + @ticket.save + redirect_to_tickets + elsif params[:commit] == 'open' + @ticket.is_open = true + @ticket.save + redirect_to auto_ticket_path(@ticket) + else + @ticket.attributes = cleanup_ticket_params(params[:ticket]) + + if params[:commit] == 'reply_and_close' + @ticket.close + end + + if @ticket.comments_changed? + @ticket.comments.last.posted_by = (current_user ? current_user.id : nil) + @ticket.comments.last.private = false unless admin? + end + + if @ticket.changed? and @ticket.save + flash[:notice] = t(:changes_saved) + redirect_to_tickets + else + flash[:error] = @ticket.errors.full_messages.join(". ") if @ticket.changed? + redirect_to auto_ticket_path(@ticket) + end + end + end + + def index + @all_tickets = Ticket.search(search_options(params)) + @tickets = @all_tickets.page(params[:page]).per(APP_CONFIG[:pagination_size]) + end + + def destroy + # should we allow non-admins to delete their own tickets? i don't think necessary. + @ticket.destroy if admin? + redirect_to auto_tickets_path + end + + protected + + def set_title + @title = t(:tickets) + end + + private + + # + # redirects to ticket index, if appropriate. + # otherwise, just redirects to @ticket + # + def redirect_to_tickets + if logged_in? + if params[:commit] == t(:reply_and_close) + redirect_to auto_tickets_path + else + redirect_to auto_ticket_path(@ticket) + end + else + # if we are not logged in, there is no index to view + redirect_to auto_ticket_path(@ticket) + end + end + + # + # unset comments hash if no new comment was typed + # + def cleanup_ticket_params(ticket) + if ticket && ticket[:comments_attributes] + if ticket[:comments_attributes].values.first[:body].blank? + ticket[:comments_attributes] = nil + end + end + return ticket + end + + def ticket_access? + @ticket and (admin? or !@ticket.created_by or (current_user and current_user.id == @ticket.created_by)) + end + + def fetch_ticket + @ticket = Ticket.find(params[:id]) + if !@ticket and admin? + redirect_to auto_tickets_path, :alert => t(:no_such_thing, :thing => 'ticket') + return + end + access_denied unless ticket_access? + end + + def fetch_user + if params[:user_id] + @user = User.find(params[:user_id]) + end + end + + # + # clean up params for ticket search + # + def search_options(params) + params.merge( + :admin_status => params[:user_id] ? 'mine' : 'all', + :user_id => @user ? @user.id : current_user.id, + :is_admin => admin? + ) + end + +end diff --git a/engines/support/app/designs/ticket/by_includes_post_by.js b/engines/support/app/designs/ticket/by_includes_post_by.js new file mode 100644 index 0000000..2eeac89 --- /dev/null +++ b/engines/support/app/designs/ticket/by_includes_post_by.js @@ -0,0 +1,13 @@ +// TODO: This view is only used in tests--should we keep it? +function(doc) { + var arr = {} + if (doc['type'] == 'Ticket' && doc.comments) { + doc.comments.forEach(function(comment){ + if (comment.posted_by && !arr[comment.posted_by]) { + //don't add duplicates + arr[comment.posted_by] = true; + emit(comment.posted_by, 1); + } + }); + } +} diff --git a/engines/support/app/designs/ticket/by_includes_post_by_and_created_at.js b/engines/support/app/designs/ticket/by_includes_post_by_and_created_at.js new file mode 100644 index 0000000..72169b0 --- /dev/null +++ b/engines/support/app/designs/ticket/by_includes_post_by_and_created_at.js @@ -0,0 +1,12 @@ +function(doc) { + var arr = {} + if (doc['type'] == 'Ticket' && doc.comments) { + doc.comments.forEach(function(comment){ + if (comment.posted_by && !arr[comment.posted_by]) { + //don't add duplicates + arr[comment.posted_by] = true; + emit([comment.posted_by, doc.created_at], 1); + } + }); + } +} diff --git a/engines/support/app/designs/ticket/by_includes_post_by_and_is_open_and_created_at.js b/engines/support/app/designs/ticket/by_includes_post_by_and_is_open_and_created_at.js new file mode 100644 index 0000000..33dfe0b --- /dev/null +++ b/engines/support/app/designs/ticket/by_includes_post_by_and_is_open_and_created_at.js @@ -0,0 +1,12 @@ +function(doc) { + var arr = {} + if (doc['type'] == 'Ticket' && doc.comments) { + doc.comments.forEach(function(comment){ + if (comment.posted_by && !arr[comment.posted_by]) { + //don't add duplicates + arr[comment.posted_by] = true; + emit([comment.posted_by, doc.is_open, doc.created_at], 1); + } + }); + } +} diff --git a/engines/support/app/designs/ticket/by_includes_post_by_and_is_open_and_updated_at.js b/engines/support/app/designs/ticket/by_includes_post_by_and_is_open_and_updated_at.js new file mode 100644 index 0000000..3bd2a74 --- /dev/null +++ b/engines/support/app/designs/ticket/by_includes_post_by_and_is_open_and_updated_at.js @@ -0,0 +1,12 @@ +function(doc) { + var arr = {} + if (doc['type'] == 'Ticket' && doc.comments) { + doc.comments.forEach(function(comment){ + if (comment.posted_by && !arr[comment.posted_by]) { + //don't add duplicates + arr[comment.posted_by] = true; + emit([comment.posted_by, doc.is_open, doc.updated_at], 1); + } + }); + } +} diff --git a/engines/support/app/designs/ticket/by_includes_post_by_and_updated_at.js b/engines/support/app/designs/ticket/by_includes_post_by_and_updated_at.js new file mode 100644 index 0000000..2b4304f --- /dev/null +++ b/engines/support/app/designs/ticket/by_includes_post_by_and_updated_at.js @@ -0,0 +1,12 @@ +function(doc) { + var arr = {} + if (doc['type'] == 'Ticket' && doc.comments) { + doc.comments.forEach(function(comment){ + if (comment.posted_by && !arr[comment.posted_by]) { + //don't add duplicates + arr[comment.posted_by] = true; + emit([comment.posted_by, doc.updated_at], 1); + } + }); + } +} diff --git a/engines/support/app/helpers/auto_tickets_path_helper.rb b/engines/support/app/helpers/auto_tickets_path_helper.rb new file mode 100644 index 0000000..93f3cb9 --- /dev/null +++ b/engines/support/app/helpers/auto_tickets_path_helper.rb @@ -0,0 +1,53 @@ +# +# These "auto" forms of the normal ticket path route helpers allow us to do two things automatically: +# +# (1) include the user in the path if appropriate. +# (2) retain the sort params, if appropriate. +# +# Tickets views with a user_id are limited to that user. For admins, they don't need a user_id for any ticket action. +# +# This is available both to the views and the tickets_controller. +# +module AutoTicketsPathHelper + + protected + + def auto_tickets_path(options={}) + return unless options.class == Hash + options = ticket_view_options.merge options + if @user + user_tickets_path(@user, options) + else + tickets_path(options) + end + end + + def auto_ticket_path(ticket, options={}) + options = ticket_view_options.merge options + if @user + user_ticket_path(@user, ticket, options) + else + ticket_path(ticket, options) + end + end + + def auto_new_ticket_path(options={}) + return unless options.class == Hash + options = ticket_view_options.merge options + if @user + new_user_ticket_path(@user, options) + else + new_ticket_path(options) + end + end + + private + + def ticket_view_options + hsh = {} + hsh[:open_status] = params[:open_status] if params[:open_status] && !params[:open_status].empty? + hsh[:sort_order] = params[:sort_order] if params[:sort_order] && !params[:sort_order].empty? + hsh + end + +end \ No newline at end of file diff --git a/engines/support/app/helpers/tickets_helper.rb b/engines/support/app/helpers/tickets_helper.rb new file mode 100644 index 0000000..7af50d6 --- /dev/null +++ b/engines/support/app/helpers/tickets_helper.rb @@ -0,0 +1,76 @@ +module TicketsHelper + # + # FORM HELPERS + # + + # + # hidden fields that should be added to ever ticket form. + # these are use for proper redirection after successful actions. + # + def hidden_ticket_fields + haml_concat hidden_field_tag('open_status', params[:open_status]) + haml_concat hidden_field_tag('sort_order', params[:sort_order]) + haml_concat hidden_field_tag('user_id', params[:user_id]) + "" + end + + # + # PARAM HELPERS + # + + def search_status + if action?(:index) + params[:open_status] || 'open' + else + nil + end + end + + def search_order + params[:sort_order] || 'updated_at_desc' + end + + # + # LINK HELPERS + # + + def link_to_status(new_status) + if new_status == "open" + label = t(:open_tickets) + elsif new_status == "closed" + label = t(:closed_tickets) + elsif new_status == "all" + label = t(:all_tickets) + end + link_to label, auto_tickets_path(:open_status => new_status, :sort_order => search_order) + end + + def link_to_order(order_field) + if search_order.start_with?(order_field) + # link for currently-filtered field. Link to other direction of this field. + if search_order.end_with? 'asc' + direction = 'desc' + icon_direction = 'up' + else + direction = 'asc' + icon_direction = 'down' + end + arrow = content_tag(:i, '', class: 'icon-arrow-'+ icon_direction) + else + # for not-currently-filtered field, don't display an arrow, and link to descending direction + arrow = '' + direction = 'desc' + end + + if order_field == 'updated' + label = t(:updated) + elsif order_field == 'created' + label = t(:created) + end + + link_to auto_tickets_path(:sort_order => order_field + '_at_' + direction, :open_status => search_status) do + arrow + label + end + end + +end diff --git a/engines/support/app/models/account_extension/tickets.rb b/engines/support/app/models/account_extension/tickets.rb new file mode 100644 index 0000000..f898b56 --- /dev/null +++ b/engines/support/app/models/account_extension/tickets.rb @@ -0,0 +1,13 @@ +module AccountExtension::Tickets + extend ActiveSupport::Concern + + def destroy_with_tickets + Ticket.destroy_all_from(self.user) + destroy_without_tickets + end + + included do + alias_method_chain :destroy, :tickets + end + +end diff --git a/engines/support/app/models/ticket.rb b/engines/support/app/models/ticket.rb new file mode 100644 index 0000000..cd22758 --- /dev/null +++ b/engines/support/app/models/ticket.rb @@ -0,0 +1,105 @@ +# +# TODO: thought i should reverse keys for descending, but that didn't work. +# look into whether that should be tweaked, and whether it works okay with +# pagination (seems to now...) +# +# TODO: better validation of email +# +# TODO: don't hardcode strings 'unknown user' and 'unauthenticated user' +# +class Ticket < CouchRest::Model::Base + use_database "tickets" + + property :created_by, String, :protected => true # nil for anonymous tickets, should never be changed + property :regarding_user, String # may be nil or valid username + property :subject, String + property :email, String + property :is_open, TrueClass, :default => true + property :comments, [TicketComment] + + timestamps! + + before_validation :set_email, :set_regarding_user, :on => :create + + design do + view :by_updated_at + view :by_created_at + view :by_created_by + + view :by_is_open_and_created_at + view :by_is_open_and_updated_at + + own_path = Pathname.new(File.dirname(__FILE__)) + load_views(own_path.join('..', 'designs', 'ticket')) + end + + validates :subject, :presence => true + validates :email, :allow_blank => true, :format => /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/ + + def self.search(options = {}) + @selection = TicketSelection.new(options) + @selection.tickets + end + + def self.destroy_all_from(user) + self.by_created_by.key(user.id).each do |ticket| + ticket.destroy + end + end + + def is_creator_validated? + !!created_by + end + + def set_email + self.email = nil if self.email == "" + end + + def set_regarding_user + self.regarding_user = nil if self.regarding_user == "" + end + + def close + self.is_open = false + end + + def reopen + self.is_open = true + end + + def commenters + commenters = [] + self.comments.each do |comment| + if comment.posted_by + if user = User.find(comment.posted_by) + commenters << user.login if user and !commenters.include?(user.login) + else + commenters << 'unknown user' if !commenters.include?('unknown user') + end + else + commenters << 'unauthenticated user' if !commenters.include?('unauthenticated user') + end + end + commenters.join(', ') + end + + # + # update comments. User should be set by controller. + # + def comments_attributes=(attributes) + if attributes + comment = TicketComment.new(attributes.values.first) + comment.posted_at = Time.now + comments << comment + end + end + + def created_by_user + User.find(self.created_by) + end + + def regarding_user_actual_user + User.find_by_login(self.regarding_user) + end + +end diff --git a/engines/support/app/models/ticket_comment.rb b/engines/support/app/models/ticket_comment.rb new file mode 100644 index 0000000..bed5237 --- /dev/null +++ b/engines/support/app/models/ticket_comment.rb @@ -0,0 +1,43 @@ +class TicketComment + include CouchRest::Model::Embeddable + + #belongs_to :ticket #is this best way to do it? will want to access all of a tickets comments, so maybe this isn't the way? + property :posted_by, String#, :protected => true #Integer#this should be current_user if that is set, meaning the user is logged in #cannot have it be protected and set via comments_attributes=. also, if it is protected and we set in the tickets_controller, it gets unset. TODO---is this okay to have it not protected and manually check it? We do not users to be able to set this. + # if the current user is not set, then we could just say the comment comes from an 'unauthenticated user', which would be somebody with the secret URL + property :posted_at, Time#, :protected => true + #property :posted_verified, TrueClass, :protected => true #should be true if current_user is set when the comment is created + property :body, String + property :private, TrueClass # private comments are only viewable by admins #this is checked when set, to make sure it was set by an admin + + # ? timestamps! + validates :body, :presence => true + #before_validation :set_time#, :set_posted_by + + #design do + # view :by_posted_at + # view :by_body + #end + + def is_comment_validated? + !!posted_by + end + + def posted_by_user + User.find(posted_by) if posted_by + end + +=begin + #TODO. + #this is resetting all comments associated with the ticket: + def set_time + self.posted_at = Time.now + end +=end + +=begin + def set_posted_by + self.posted_by = User.current if User.current + end +=end + +end diff --git a/engines/support/app/models/ticket_selection.rb b/engines/support/app/models/ticket_selection.rb new file mode 100644 index 0000000..74d5b78 --- /dev/null +++ b/engines/support/app/models/ticket_selection.rb @@ -0,0 +1,71 @@ +class TicketSelection + + # + # supported options: + # + # user_id: id of the user (uuid string) + # open_status: open | closed | all + # sort_order: updated_at_desc | updated_at_asc | created_at_desc | created_at_asc + # admin_status: mine | all + # is_admin: true | false + # + def initialize(options = {}) + @user_id = options[:user_id].gsub /[^a-z0-9]/, '' + @open_status = allow options[:open_status], 'open', 'closed', 'all' + @sort_order = allow options[:sort_order], 'updated_at_desc', 'updated_at_asc', 'created_at_desc', 'created_at_asc' + @admin_status = allow options[:admin_status], 'mine', 'all' + @is_admin = allow options[:is_admin], false, true + end + + def tickets + Ticket.send(finder_method).startkey(startkey).endkey(endkey).send(order) + end + + protected + + def allow(source, *allowed) + if allowed.include?(source) + source + else + allowed.first + end + end + + def finder_method + method = 'by_' + method += 'includes_post_by_and_' if only_mine? + method += 'is_open_and_' if @open_status != 'all' + method += @sort_order.sub(/_(de|a)sc$/, '') + end + + def startkey + startkeys = [] + startkeys << @user_id if only_mine? + startkeys << (@open_status == 'open') if @open_status != 'all' + startkeys << 0 + startkeys = startkeys.join if startkeys.length == 1 # want string not array if just one thing in array + startkeys + end + + def endkey + endtime = Time.now + 2.days # TODO. this obviously isn't ideal + if self.startkey.is_a?(Array) + endkeys = self.startkey + endkeys.pop + endkeys << endtime + else + endtime + end + end + + def order + # we have defined the ascending method to return the view itself: + (@sort_order.end_with? 'desc') ? 'descending' : 'ascending' + end + + + def only_mine? + !@is_admin || @admin_status == 'mine' + end + +end diff --git a/engines/support/app/views/tickets/_comment.html.haml b/engines/support/app/views/tickets/_comment.html.haml new file mode 100644 index 0000000..778ca13 --- /dev/null +++ b/engines/support/app/views/tickets/_comment.html.haml @@ -0,0 +1,20 @@ +- if admin? or !comment.private # only show comment if user is admin or comment is not private + %tr + %td.user + %div + %strong + - if comment.posted_by_user + = comment.posted_by_user.login + - else + = t(:anonymous) + %div= comment.posted_at.to_s(:short) + - if comment.posted_by_user && comment.posted_by_user.is_admin? + %div + %span.label.label-inverse + = t(:admin) + - if comment.private + %div + %span.label.label-important + = t(:private) + %td.comment + = simple_format(comment.body) \ No newline at end of file diff --git a/engines/support/app/views/tickets/_edit_form.html.haml b/engines/support/app/views/tickets/_edit_form.html.haml new file mode 100644 index 0000000..714f8ff --- /dev/null +++ b/engines/support/app/views/tickets/_edit_form.html.haml @@ -0,0 +1,48 @@ +:ruby + # created by user link + if @ticket.created_by_user + created_by = link_to @ticket.created_by_user.login, @ticket.created_by_user + else + created_by = t(:anonymous) + end + + # regarding user link + if admin? + if @ticket.regarding_user_actual_user + regarding_user_link = link_to @ticket.regarding_user_actual_user.login, @ticket.regarding_user_actual_user + else + regarding_user_link = "(#{t(:unknown)})" + end + else + regarding_user_link = '' + end + += form_for @ticket do |f| + = hidden_ticket_fields + %p.first + - if @ticket.is_open? + %span.label.label-info= t(:open) + - else + %span.label.label-success= t(:closed) + %span.label.label-clear= t(:created_by_on, :user => created_by, :time => @ticket.created_at.to_s(:short)).html_safe + %div= t(:subject) + = f.text_field :subject, :class => 'large full-width' + .row-fluid + .span4 + %div= t(:status) + = f.select :is_open, [[t(:open), "true"], [t(:closed), "false"]] + .span4 + %div= t(:email) + = f.text_field :email + .span4 + %div + = t(:regarding_account) + = regarding_user_link + = f.text_field :regarding_user + = f.button t(:save), :name => 'commit', :class => 'btn', :type => 'submit', :value => 'save' + - if @ticket.is_open? + = f.button t(:close), :name => 'commit', :class => 'btn', :type => 'submit', :value => 'close' + - else + = f.button t(:open), :name => 'commit', :class => 'btn', :type => 'submit', :value => 'open' + - if admin? + = link_to t(:destroy), auto_ticket_path(@ticket), :confirm => t(:are_you_sure), :method => :delete, :class => 'btn' diff --git a/engines/support/app/views/tickets/_new_comment_form.html.haml b/engines/support/app/views/tickets/_new_comment_form.html.haml new file mode 100644 index 0000000..8418e01 --- /dev/null +++ b/engines/support/app/views/tickets/_new_comment_form.html.haml @@ -0,0 +1,13 @@ +-# +-# for posting a new comment to an existing ticket. +-# += simple_form_for @ticket, :html => {:class => 'slim'} do |f| + = hidden_ticket_fields + = f.simple_fields_for :comments, @comment, :wrapper => :none, :html => {:class => 'slim'} do |c| + = c.input :body, :label => false, :as => :text, :input_html => {:class => "full-width", :rows=> 5} + - if admin? + = c.input :private, :as => :boolean, :label => false, :inline_label => true + = f.button :button, t(:post_reply), :name => 'commit', :class => 'btn-primary', :type => 'submit', :value => 'post_reply' + - if logged_in? && @ticket.is_open + = f.button :button, t(:reply_and_close), :name => 'commit', :class => 'btn', :type => 'submit', :value => 'reply_and_close' + = link_to t(:cancel), auto_tickets_path, :class => :btn diff --git a/engines/support/app/views/tickets/_tabs.html.haml b/engines/support/app/views/tickets/_tabs.html.haml new file mode 100644 index 0000000..b7b5d3a --- /dev/null +++ b/engines/support/app/views/tickets/_tabs.html.haml @@ -0,0 +1,23 @@ +-# +-# SORT ORDER TABS +-# +- unless action?(:new) + %ul.nav.nav-pills.pull-right.slim + %li{:class=> ("active" if search_order.start_with? 'created_at')} + = link_to_order('created') + %li{:class=> ("active" if search_order.start_with? 'updated_at')} + = link_to_order('updated') + +-# +-# STATUS FILTER TABS +-# +%ul.nav.nav-tabs + - if logged_in? + %li{:class => ("active" if search_status == 'open')} + = link_to_status 'open' + %li{:class => ("active" if search_status == 'closed')} + = link_to_status 'closed' + %li{:class => ("active" if search_status == 'all')} + = link_to_status 'all' + %li{:class => ("active" if action?(:new))} + = link_to icon(:plus, :black) + t(:new_ticket), auto_new_ticket_path diff --git a/engines/support/app/views/tickets/_ticket.html.haml b/engines/support/app/views/tickets/_ticket.html.haml new file mode 100644 index 0000000..5bc33c8 --- /dev/null +++ b/engines/support/app/views/tickets/_ticket.html.haml @@ -0,0 +1,6 @@ +- url = auto_ticket_path(ticket) +%tr + %td= link_to ticket.subject, url + %td= link_to ticket.created_at.to_s(:short), url + %td= link_to ticket.updated_at.to_s(:short), url + %td= ticket.commenters diff --git a/engines/support/app/views/tickets/index.html.haml b/engines/support/app/views/tickets/index.html.haml new file mode 100644 index 0000000..c02a326 --- /dev/null +++ b/engines/support/app/views/tickets/index.html.haml @@ -0,0 +1,19 @@ +- @show_navigation = !params[:user_id].nil? + += render 'tickets/tabs' + +%table.table.table-striped.table-bordered + %thead + %tr + %th= t(:subject) + %th= t(:created) + %th= t(:updated) + %th= t(:voices) + %tbody + - if @tickets.any? + = render @tickets.all + - else + %tr + %td{:colspan=>4}= t(:none) + += paginate @tickets diff --git a/engines/support/app/views/tickets/new.html.haml b/engines/support/app/views/tickets/new.html.haml new file mode 100644 index 0000000..8f217a5 --- /dev/null +++ b/engines/support/app/views/tickets/new.html.haml @@ -0,0 +1,30 @@ +- @show_navigation = !params[:user_id].nil? + += render 'tickets/tabs' + +- if admin? && @user + - email = @user.email_address + - regarding = @user.login +- elsif logged_in? + - email = current_user.email_address + - regarding = current_user.login + += simple_form_for @ticket, :validate => true, :html => {:class => 'form-horizontal'} do |f| + = hidden_ticket_fields + = f.input :subject + - if logged_in? + = f.input :email, input_html: {value: email} + = f.input :regarding_user, input_html: {value: regarding} + - else + = f.input :email + = f.input :regarding_user + = f.simple_fields_for :comments, @comment do |c| + = c.input :body, :label => t(:description), :as => :text, :input_html => {:class => "full-width", :rows=> 5} + - if admin? + = c.input :private, :as => :boolean, :label => false, :inline_label => true + .form-actions + = f.button :submit, :class => 'btn-primary', :value => t(:create_thing, :thing => t(:ticket)) + - if logged_in? + = link_to t(:cancel), auto_tickets_path, :class => :btn + - else + = link_to t(:cancel), home_path, :class => 'btn' \ No newline at end of file diff --git a/engines/support/app/views/tickets/show.html.haml b/engines/support/app/views/tickets/show.html.haml new file mode 100644 index 0000000..bfdb773 --- /dev/null +++ b/engines/support/app/views/tickets/show.html.haml @@ -0,0 +1,12 @@ +- @show_navigation = !params[:user_id].nil? + +.ticket + = render 'tickets/edit_form' + %table.table.table-striped.table-bordered + %tbody + = render :partial => 'tickets/comment', :collection => @ticket.comments + %tr + %td.user + = logged_in? ? current_user.login : t(:anonymous) + %td.comment + = render 'tickets/new_comment_form' \ No newline at end of file diff --git a/engines/support/config/initializers/account_lifecycle.rb b/engines/support/config/initializers/account_lifecycle.rb new file mode 100644 index 0000000..d9f04c1 --- /dev/null +++ b/engines/support/config/initializers/account_lifecycle.rb @@ -0,0 +1,3 @@ +ActiveSupport.on_load(:account) do + include AccountExtension::Tickets +end diff --git a/engines/support/config/locales/en.yml b/engines/support/config/locales/en.yml new file mode 100644 index 0000000..342adea --- /dev/null +++ b/engines/support/config/locales/en.yml @@ -0,0 +1,22 @@ +en: + access_ticket_text: > + You can later access this ticket at the URL %{full_url}. You might want to bookmark this page to find it again. + Anybody with this URL will be able to access this ticket, so if you are on a shared computer you might want to + remove it from the browser history. + support_tickets: "Support Tickets" + all_tickets: "All Tickets" + my_tickets: "My Tickets" + open_tickets: "Open Tickets" + closed_tickets: "Closed Tickets" + new_ticket: "New Ticket" + tickets: "Tickets" + subject: "Subject" + destroy: "Destroy" + open: "Open" + closed: "Closed" + close: "Close" + post_reply: "Post Reply" + reply_and_close: "Reply and Close" + description: "Description" + ticket: "Ticket" + regarding_account: "Regarding Account" \ No newline at end of file diff --git a/engines/support/config/routes.rb b/engines/support/config/routes.rb new file mode 100644 index 0000000..23e0c11 --- /dev/null +++ b/engines/support/config/routes.rb @@ -0,0 +1,8 @@ +Rails.application.routes.draw do + scope "(:locale)", :locale => MATCH_LOCALE do + resources :tickets, :except => :edit + resources :users do + resources :tickets, :except => :edit + end + end +end diff --git a/engines/support/leap_web_help.gemspec b/engines/support/leap_web_help.gemspec new file mode 100644 index 0000000..41959d6 --- /dev/null +++ b/engines/support/leap_web_help.gemspec @@ -0,0 +1,18 @@ +$:.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_help" + s.version = LeapWeb::VERSION + s.authors = ["Jessib"] + s.email = ["jessib@leap.se"] + s.homepage = "http://www.leap.se" + s.summary = "Help Desk for LeapWeb" + s.description = "Managing Tickets for a Leap provider" + + s.files = Dir["{app,config,db,lib}/**/*"] + ["Rakefile", "README.md"] + s.test_files = Dir["test/**/*"] + +end diff --git a/engines/support/lib/leap_web_help.rb b/engines/support/lib/leap_web_help.rb new file mode 100644 index 0000000..f5b04aa --- /dev/null +++ b/engines/support/lib/leap_web_help.rb @@ -0,0 +1,4 @@ +require "leap_web_help/engine" + +module LeapWebHelp +end diff --git a/engines/support/lib/leap_web_help/engine.rb b/engines/support/lib/leap_web_help/engine.rb new file mode 100644 index 0000000..dfa763f --- /dev/null +++ b/engines/support/lib/leap_web_help/engine.rb @@ -0,0 +1,4 @@ +module LeapWebHelp + class Engine < ::Rails::Engine + end +end diff --git a/engines/support/lib/tasks/leap_web_help_tasks.rake b/engines/support/lib/tasks/leap_web_help_tasks.rake new file mode 100644 index 0000000..1f38982 --- /dev/null +++ b/engines/support/lib/tasks/leap_web_help_tasks.rake @@ -0,0 +1,4 @@ +# desc "Explaining what the task does" +# task :leap_web_help do +# # Task goes here +# end diff --git a/engines/support/script/rails b/engines/support/script/rails new file mode 100755 index 0000000..5676ab9 --- /dev/null +++ b/engines/support/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_help/engine', __FILE__) + +require 'rails/all' +require 'rails/engine/commands' diff --git a/engines/support/test/factories.rb b/engines/support/test/factories.rb new file mode 100644 index 0000000..be04f15 --- /dev/null +++ b/engines/support/test/factories.rb @@ -0,0 +1,18 @@ +FactoryGirl.define do + + factory :ticket do + subject { Faker::Lorem.sentence } + email { Faker::Internet.email } + + factory :ticket_with_comment do + comments_attributes do + { "0" => { "body" => Faker::Lorem.sentences.join(" ") } } + end + end + + factory :ticket_with_creator do + created_by { FactoryGirl.create(:user).id } + end + end + +end diff --git a/engines/support/test/functional/tickets_controller_test.rb b/engines/support/test/functional/tickets_controller_test.rb new file mode 100644 index 0000000..416fb73 --- /dev/null +++ b/engines/support/test/functional/tickets_controller_test.rb @@ -0,0 +1,273 @@ +require 'test_helper' + +class TicketsControllerTest < ActionController::TestCase + + teardown do + # destroy all tickets that were created during the test + Ticket.all.each{|t| t.destroy} + end + + test "should get index if logged in" do + login + get :index + assert_response :success + assert_not_nil assigns(:tickets) + end + + test "no index if not logged in" do + get :index + assert_response :redirect + assert_nil assigns(:tickets) + end + + test "should get new" do + get :new + assert_equal Ticket, assigns(:ticket).class + assert_response :success + end + + test "unauthenticated tickets are visible" do + ticket = find_record :ticket, :created_by => nil + get :show, :id => ticket.id + assert_response :success + end + + test "user tickets are not visible without login" do + user = find_record :user + ticket = find_record :ticket, :created_by => user.id + get :show, :id => ticket.id + assert_response :redirect + assert_redirected_to login_url + end + + test "user tickets are visible to creator" do + user = find_record :user + ticket = find_record :ticket, :created_by => user.id + login user + get :show, :id => ticket.id + assert_response :success + end + + test "other users tickets are not visible" do + other_user = find_record :user + ticket = find_record :ticket, :created_by => other_user.id + login + get :show, :id => ticket.id + assert_response :redirect + assert_redirected_to home_url + end + + test "should create unauthenticated ticket" do + params = {:subject => "unauth ticket test subject", :comments_attributes => {"0" => {"body" =>"body of test ticket"}}} + + assert_difference('Ticket.count') do + post :create, :ticket => params + end + + assert_response :redirect + assert_nil assigns(:ticket).created_by + + assert_equal 1, assigns(:ticket).comments.count + assert_nil assigns(:ticket).comments.first.posted_by + + end + + test "should create authenticated ticket" do + + params = {:subject => "auth ticket test subject", :comments_attributes => {"0" => {"body" =>"body of test ticket"}}} + + login + + assert_difference('Ticket.count') do + post :create, :ticket => params + end + + assert_response :redirect + + assert_not_nil assigns(:ticket).created_by + assert_equal assigns(:ticket).created_by, @current_user.id + assert_equal assigns(:ticket).email, @current_user.email_address + + assert_equal 1, assigns(:ticket).comments.count + assert_not_nil assigns(:ticket).comments.first.posted_by + assert_equal assigns(:ticket).comments.first.posted_by, @current_user.id + end + + test "add comment to unauthenticated ticket" do + ticket = FactoryGirl.create :ticket, :created_by => nil + + assert_difference('Ticket.find(ticket.id).comments.count') do + put :update, :id => ticket.id, + :ticket => {:comments_attributes => {"0" => {"body" =>"NEWER comment"}} } + end + + assert_equal ticket, assigns(:ticket) # still same ticket, with different comments + assert_not_equal ticket.comments, assigns(:ticket).comments # ticket == assigns(:ticket), but they have different comments (which we want) + + end + + + test "add comment to own authenticated ticket" do + + login + ticket = FactoryGirl.create :ticket, :created_by => @current_user.id + + #they should be able to comment if it is their ticket: + assert_difference('Ticket.find(ticket.id).comments.count') do + put :update, :id => ticket.id, + :ticket => {:comments_attributes => {"0" => {"body" =>"NEWER comment"}} } + end + assert_not_equal ticket.comments, assigns(:ticket).comments + assert_not_nil assigns(:ticket).comments.last.posted_by + assert_equal assigns(:ticket).comments.last.posted_by, @current_user.id + + end + + + test "cannot comment if it is not your ticket" do + + other_user = find_record :user + login :is_admin? => false, :email => nil + ticket = FactoryGirl.create :ticket, :created_by => other_user.id + # they should *not* be able to comment if it is not their ticket + put :update, :id => ticket.id, :ticket => {:comments_attributes => {"0" => {"body" =>"not allowed comment"}} } + assert_response :redirect + assert_access_denied + + assert_equal ticket.comments.map(&:body), assigns(:ticket).comments.map(&:body) + + end + + + test "admin add comment to authenticated ticket" do + + other_user = find_record :user + login :is_admin? => true + + ticket = FactoryGirl.create :ticket, :created_by => other_user.id + + #admin should be able to comment: + assert_difference('Ticket.find(ticket.id).comments.count') do + put :update, :id => ticket.id, + :ticket => {:comments_attributes => {"0" => {"body" =>"NEWER comment"}} } + end + assert_not_equal ticket.comments, assigns(:ticket).comments + assert_not_nil assigns(:ticket).comments.last.posted_by + assert_equal assigns(:ticket).comments.last.posted_by, @current_user.id + end + + test "tickets by admin" do + other_user = find_record :user + ticket = FactoryGirl.create :ticket, :created_by => other_user.id + + login :is_admin? => true + + get :index, {:admin_status => "all", :open_status => "open"} + assert assigns(:all_tickets).count > 0 + + # if we close one ticket, the admin should have 1 less open ticket + assert_difference('assigns[:all_tickets].count', -1) do + assigns(:tickets).first.close + assigns(:tickets).first.save + get :index, {:admin_status => "all", :open_status => "open"} + end + end + + + test "admin_status mine vs all" do + testticket = FactoryGirl.create :ticket + user = find_record :user + login :is_admin? => true, :email => nil + + get :index, {:open_status => "open"} + assert assigns(:all_tickets).include?(testticket) + get :index, {:user_id => user.id, :open_status => "open"} + assert !assigns(:all_tickets).include?(testticket) + end + + test "commenting on a ticket adds to tickets that are mine" do + testticket = FactoryGirl.create :ticket + user = find_record :admin_user + login user + get :index, {:user_id => user.id, :open_status => "open"} + assert_difference('assigns[:all_tickets].count') do + put :update, :id => testticket.id, :ticket => {:comments_attributes => {"0" => {"body" =>"NEWER comment"}}} + get :index, {:user_id => user.id, :open_status => "open"} + end + + assert assigns(:all_tickets).include?(assigns(:ticket)) + assert_not_nil assigns(:ticket).comments.last.posted_by + assert_equal assigns(:ticket).comments.last.posted_by, @current_user.id + end + + test "admin ticket ordering" do + tickets = FactoryGirl.create_list :ticket, 2 + + login :is_admin? => true, :email => nil + get :index, {:admin_status => "all", :open_status => "open", :sort_order => 'created_at_desc'} + + # this will consider all tickets, not just those on first page + first_tick = assigns(:all_tickets).all.first + last_tick = assigns(:all_tickets).all.last + assert first_tick.created_at > last_tick.created_at + + # and now reverse order: + get :index, {:admin_status => "all", :open_status => "open", :sort_order => 'created_at_asc'} + + assert_equal first_tick, assigns(:all_tickets).last + assert_equal last_tick, assigns(:all_tickets).first + + assert_not_equal first_tick, assigns(:all_tickets).first + assert_not_equal last_tick, assigns(:all_tickets).last + + end + + test "tickets for regular user" do + login + ticket = FactoryGirl.create :ticket + other_ticket = FactoryGirl.create :ticket + + put :update, :id => ticket.id, + :ticket => {:comments_attributes => {"0" => {"body" =>"NEWER comment"}} } + assert_not_nil assigns(:ticket).comments.last.posted_by + assert_equal assigns(:ticket).comments.last.posted_by, @current_user.id + + get :index, {:open_status => "open"} + assert assigns(:all_tickets).count > 0 + assert assigns(:all_tickets).include?(ticket) + assert !assigns(:all_tickets).include?(other_ticket) + + # user should have one more ticket if a new tick gets a comment by this user + assert_difference('assigns[:all_tickets].count') do + put :update, :id => other_ticket.id, :ticket => {:comments_attributes => {"0" => {"body" =>"NEWER comment"}}} + get :index, {:open_status => "open"} + end + assert assigns(:all_tickets).include?(other_ticket) + + # if we close one ticket, the user should have 1 less open ticket + assert_difference('assigns[:all_tickets].count', -1) do + other_ticket.reload + other_ticket.close + other_ticket.save + get :index, {:open_status => "open"} + end + + number_open_tickets = assigns(:all_tickets).count + + # look at closed tickets: + get :index, {:open_status => "closed"} + assert !assigns(:all_tickets).include?(ticket) + assert assigns(:all_tickets).include?(other_ticket) + number_closed_tickets = assigns(:all_tickets).count + + # all tickets should equal closed + open + get :index, {:open_status => "all"} + assert assigns(:all_tickets).include?(ticket) + assert assigns(:all_tickets).include?(other_ticket) + assert_equal assigns(:all_tickets).count, number_closed_tickets + number_open_tickets + + + end + +end + diff --git a/engines/support/test/integration/navigation_test.rb b/engines/support/test/integration/navigation_test.rb new file mode 100644 index 0000000..eec8c0e --- /dev/null +++ b/engines/support/test/integration/navigation_test.rb @@ -0,0 +1,9 @@ +require 'test_helper' + +class NavigationTest < ActionDispatch::IntegrationTest + + # test "the truth" do + # assert true + # end +end + diff --git a/engines/support/test/leap_web_help_test.rb b/engines/support/test/leap_web_help_test.rb new file mode 100644 index 0000000..d74c087 --- /dev/null +++ b/engines/support/test/leap_web_help_test.rb @@ -0,0 +1,7 @@ +require 'test_helper' + +class LeapWebHelpTest < ActiveSupport::TestCase + test "truth" do + assert_kind_of Module, LeapWebHelp + end +end diff --git a/engines/support/test/test_helper.rb b/engines/support/test/test_helper.rb new file mode 100644 index 0000000..fff9173 --- /dev/null +++ b/engines/support/test/test_helper.rb @@ -0,0 +1,15 @@ +# Configure Rails Environment +ENV["RAILS_ENV"] = "test" + +require File.expand_path('../../../../test/dummy/config/environment', __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/engines/support/test/unit/account_extension_test.rb b/engines/support/test/unit/account_extension_test.rb new file mode 100644 index 0000000..aba162c --- /dev/null +++ b/engines/support/test/unit/account_extension_test.rb @@ -0,0 +1,12 @@ +require 'test_helper' + +class AccountExtensionTest < ActiveSupport::TestCase + + test "destroying an account triggers ticket destruction" do + t = FactoryGirl.create :ticket_with_creator + u = t.created_by_user + Account.new(u).destroy + assert_equal nil, Ticket.find(t.id) + end + +end diff --git a/engines/support/test/unit/ticket_comment_test.rb b/engines/support/test/unit/ticket_comment_test.rb new file mode 100644 index 0000000..fe8cc95 --- /dev/null +++ b/engines/support/test/unit/ticket_comment_test.rb @@ -0,0 +1,59 @@ +require 'test_helper' + +class TicketCommentTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end + +=begin + setup do + @sample_ticket = Ticket.create :title => 'test ticket' + @sample_ticket.save + end +=end + + test "create" do + + comment2 = TicketComment.new :body => "help my email is broken!" + assert comment2.valid? + #assert_not_nil comment2.posted_at #? + #assert_nil comment2.posted_by #if not logged in #TODO + + #comment.ticket = testticket #Ticket.find_by_title("testing") + #assert_equal testticket.title, comment.ticket.title + + #tc.ticket = Ticket.find_by_title("test title") + #tc.ticket.title + end + +=begin + test "create authenticated comment" do + User.current = 4 + comment2 = TicketComment.new :body => "help my email is broken!" + comment2.valid? #save # should not save comment + assert_not_nil comment2.posted_by + end +=end + + test "add comments" do + testticket = Ticket.create :subject => "testing" + assert_equal testticket.comments.count, 0 + comment = TicketComment.new :body => "my email broke" + #assert comment.valid? #validating or saving necessary for setting posted_at + #assert_not_nil comment.posted_at + + testticket.comments << comment + assert_equal testticket.comments.count, 1 + sleep(1) # so first comment has earlier posted_at time + comment2 = TicketComment.new :body => "my email broke" + testticket.comments << comment2 #this should validate comment2 + testticket.valid? + assert_equal testticket.comments.count, 2 + testticket.reload.destroy + # where should posted_at be set? + #assert_not_nil comment.posted_at + #assert_not_nil testticket.comments.last.posted_at + #assert testticket.comments.first.posted_at < testticket.comments.last.posted_at + end + +end diff --git a/engines/support/test/unit/ticket_test.rb b/engines/support/test/unit/ticket_test.rb new file mode 100644 index 0000000..f5e6ea7 --- /dev/null +++ b/engines/support/test/unit/ticket_test.rb @@ -0,0 +1,88 @@ +require 'test_helper' + +class TicketTest < ActiveSupport::TestCase + + test "ticket with default attribs is valid" do + t = FactoryGirl.build :ticket + assert t.valid? + end + + test "ticket without email is valid" do + t = FactoryGirl.build :ticket, email: "" + assert t.valid? + end + + test "ticket validates email format" do + t = FactoryGirl.build :ticket, email: "aswerssfd" + assert !t.valid? + end + + test "ticket open states" do + t = FactoryGirl.build :ticket + assert t.is_open + t.close + assert !t.is_open + t.reopen + assert t.is_open + end + + test "creation validated" do + @sample = Ticket.new + assert !@sample.is_creator_validated? + #p current_user + @sample.created_by = 22 #current_user + assert @sample.is_creator_validated? + end + + test "destroy all tickets from a user" do + t = FactoryGirl.create :ticket_with_creator + u = t.created_by_user + Ticket.destroy_all_from(u) + assert_equal nil, Ticket.find(t.id) + end +=begin +# TODO: do once have current_user stuff in order + test "code if & only if not creator-validated" do + User.current_test = nil + t1 = Ticket.create :subject => 'test title' + assert_not_nil t1.code + assert_nil t1.created_by + + User.current_test = 4 + t2 = Ticket.create :subject => 'test title' + assert_nil t2.code + assert_not_nil t2.created_by + end +=end + + + test "find tickets user commented on" do + + # clear old tickets just in case + # this will cause RestClient::ResourceNotFound errors if there are multiple copies of the same ticket returned + Ticket.by_includes_post_by.key('123').each {|t| t.destroy} + # TODO: the by_includes_post_by view is only used for tests. Maybe we should get rid of it and change the test to including ordering? + + + testticket = Ticket.create :subject => "test retrieving commented tickets" + comment = TicketComment.new :body => "my email broke", :posted_by => "123" + assert_equal 0, testticket.comments.count + assert_equal [], Ticket.by_includes_post_by.key('123').all + + testticket.comments << comment + testticket.save + assert_equal 1, testticket.reload.comments.count + assert_equal [testticket], Ticket.by_includes_post_by.key('123').all + + comment = TicketComment.new :body => "another comment", :posted_by => "123" + testticket.comments << comment + testticket.save + + # this will ensure that the ticket is only included once, even though the user has commented on the ticket twice: + assert_equal [testticket], Ticket.by_includes_post_by.key('123').all + + testticket.destroy + assert_equal [], Ticket.by_includes_post_by.key('123').all; + end + +end -- cgit v1.2.3