mirror of
https://github.com/openfoodfoundation/openfoodnetwork
synced 2026-01-28 21:07:16 +00:00
Refactoring credit cards interface, and backend logic
This commit is contained in:
@@ -1,18 +1,12 @@
|
||||
Darkswarm.controller "CreditCardsCtrl", ($scope, $timeout, CreditCard, savedCreditCards, StripeJS, Dates, Loading) ->
|
||||
Darkswarm.controller "CreditCardsCtrl", ($scope, $timeout, CreditCard, CreditCards, StripeJS, Dates) ->
|
||||
angular.extend(this, new FieldsetMixin($scope))
|
||||
$scope.savedCreditCards = savedCreditCards
|
||||
$scope.savedCreditCards = CreditCards.saved
|
||||
$scope.CreditCard = CreditCard
|
||||
$scope.secrets = CreditCard.secrets
|
||||
$scope.showForm = CreditCard.show
|
||||
$scope.storeCard = CreditCard.requestToken
|
||||
|
||||
$scope.allow_name_change = true
|
||||
$scope.disable_fields = false
|
||||
|
||||
$scope.months = Dates.months
|
||||
$scope.years = Dates.years
|
||||
|
||||
$scope.secrets = CreditCard.secrets
|
||||
$scope.add_card_visible = false
|
||||
|
||||
$scope.storeCard = =>
|
||||
CreditCard.requestToken($scope.secrets)
|
||||
|
||||
$scope.toggle = ->
|
||||
$scope.add_card_visible = !($scope.add_card_visible)
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
Darkswarm.factory 'CreditCard', ($injector, $rootScope, StripeJS, Navigation, $http, RailsFlashLoader, Loading)->
|
||||
Darkswarm.factory 'CreditCard', ($injector, $rootScope, CreditCards, StripeJS, Navigation, $http, RailsFlashLoader, Loading)->
|
||||
new class CreditCard
|
||||
visible: false
|
||||
errors: {}
|
||||
secrets: {}
|
||||
|
||||
requestToken: (secrets) ->
|
||||
secrets.name = @full_name(secrets)
|
||||
StripeJS.requestToken(secrets, @submit, t("saving_credit_card"))
|
||||
requestToken: =>
|
||||
@setFullName()
|
||||
StripeJS.requestToken(@secrets, @submit, t("saving_credit_card"))
|
||||
|
||||
submit: =>
|
||||
params = @process_params()
|
||||
$http.put('/credit_cards/new_from_token', params )
|
||||
.success (data, status) ->
|
||||
$rootScope.$apply ->
|
||||
Loading.clear()
|
||||
Navigation.go '/account'
|
||||
.error (response, status) ->
|
||||
.success (data, status) =>
|
||||
Loading.clear()
|
||||
@reset()
|
||||
CreditCards.add(data)
|
||||
.error (response, status) =>
|
||||
if response.path
|
||||
Navigation.go response.path
|
||||
else
|
||||
@@ -22,8 +23,8 @@ Darkswarm.factory 'CreditCard', ($injector, $rootScope, StripeJS, Navigation, $h
|
||||
@errors = response.errors
|
||||
RailsFlashLoader.loadFlash(response.flash)
|
||||
|
||||
full_name: (secrets) ->
|
||||
secrets.first_name + " " + secrets.last_name
|
||||
setFullName: ->
|
||||
@secrets.name = "#{@secrets.first_name} #{@secrets.last_name}"
|
||||
|
||||
process_params: ->
|
||||
{"exp_month": @secrets.card.exp_month,
|
||||
@@ -31,3 +32,10 @@ Darkswarm.factory 'CreditCard', ($injector, $rootScope, StripeJS, Navigation, $h
|
||||
"last4": @secrets.card.last4,
|
||||
"token": @secrets.token,
|
||||
"cc_type": @secrets.card.brand}
|
||||
|
||||
show: => @visible = true
|
||||
|
||||
reset: =>
|
||||
@visible = false
|
||||
delete @secrets[k] for k, v of @secrets
|
||||
delete @errors[k] for k, v of @errors
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
Darkswarm.factory 'CreditCards', (savedCreditCards)->
|
||||
new class CreditCard
|
||||
saved: savedCreditCards
|
||||
|
||||
add: (card) ->
|
||||
@saved.push card
|
||||
@@ -21,3 +21,6 @@ Darkswarm.factory 'Navigation', ($location, $window) ->
|
||||
$window.location.href = path
|
||||
else
|
||||
$window.location.pathname = path
|
||||
|
||||
reload: ->
|
||||
$window.location.reload()
|
||||
|
||||
@@ -5,8 +5,21 @@
|
||||
color: #4a4a4a;
|
||||
}
|
||||
|
||||
.card {
|
||||
.credit_cards {
|
||||
.saved_cards {
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.new_card {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.4s linear;
|
||||
transition: opacity 0.4s linear;
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.orders {
|
||||
|
||||
@@ -22,7 +22,7 @@ module Spree
|
||||
@credit_card.last_digits = credit_card_params[:last_digits]
|
||||
@credit_card.user_id = @user.id
|
||||
if @credit_card.save
|
||||
render json: @credit_card, status: :ok
|
||||
render json: @credit_card, serializer: ::Api::CreditCardSerializer, status: :ok
|
||||
else
|
||||
render json: "error saving credit card", status: 500
|
||||
end
|
||||
|
||||
@@ -70,11 +70,7 @@ module InjectionHelper
|
||||
end
|
||||
|
||||
def inject_saved_credit_cards
|
||||
if spree_current_user
|
||||
data = spree_current_user.credit_cards
|
||||
else
|
||||
data = nil
|
||||
end
|
||||
data = spree_current_user.try(:credit_cards)
|
||||
inject_json_ams "savedCreditCards", data, Api::CreditCardSerializer
|
||||
end
|
||||
|
||||
|
||||
@@ -10,6 +10,4 @@ Spree::CreditCard.class_eval do
|
||||
def has_payment_profile?
|
||||
gateway_customer_profile_id.present? || gateway_payment_profile_id.present?
|
||||
end
|
||||
|
||||
|
||||
end
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
class Api::CreditCardSerializer < ActiveModel::Serializer
|
||||
attributes :id, :formatted, :delete_link
|
||||
attributes :id, :brand, :number, :expiry, :formatted, :delete_link
|
||||
|
||||
def brand
|
||||
object.cc_type.capitalize
|
||||
end
|
||||
|
||||
def number
|
||||
'x-' + object.last_digits
|
||||
end
|
||||
|
||||
def expiry
|
||||
m = object.month.to_i
|
||||
m = m < 10 ? "0#{m}" : m.to_s
|
||||
"#{m}/#{object.year}"
|
||||
end
|
||||
|
||||
def formatted
|
||||
elements = []
|
||||
elements << object.cc_type.capitalize if object.cc_type
|
||||
if object.last_digits
|
||||
3.times { elements << I18n.t(:card_masked_digit) * 4 }
|
||||
elements << object.last_digits
|
||||
end
|
||||
elements << I18n.t(:card_expiry_abbreviation)
|
||||
elements << object.month.to_s + "/" + object.year.to_s if object.month # TODO: I18n
|
||||
elements.join(" ")
|
||||
"#{brand} #{number} #{I18n.t(:card_expiry_abbreviation)}:#{expiry}"
|
||||
end
|
||||
|
||||
def delete_link
|
||||
|
||||
@@ -2,7 +2,7 @@ module Api
|
||||
class PaymentSerializer < ActiveModel::Serializer
|
||||
attributes :amount, :updated_at, :payment_method, :state
|
||||
def payment_method
|
||||
object.payment_method.name
|
||||
object.payment_method.try(:name)
|
||||
end
|
||||
|
||||
def amount
|
||||
|
||||
@@ -1,56 +1,15 @@
|
||||
%script{ type: "text/ng-template", id: "account/cards.html" }
|
||||
%h3= t(:my_credit_cards)
|
||||
.credit_cards{"ng-controller" => "CreditCardsCtrl"}
|
||||
%h4
|
||||
= t(:saved_cards)
|
||||
.row
|
||||
.card_list.small-12.columns{"ng-repeat" => "card in savedCreditCards"}
|
||||
%p.card
|
||||
%span{"ng-bind" => "card.formatted"}
|
||||
%a{"rel" => "nofollow", "data-method" => "delete", "ng-href" => "{{card.delete_link}}" }
|
||||
Delete
|
||||
.row
|
||||
%h4{"ng-click" => "toggle()"}
|
||||
.columns.small-10.medium-10
|
||||
= t(:add_new_credit_card)
|
||||
.columns.small-2.medium-2.text-right
|
||||
%span.margin-top
|
||||
%i{"ng-class" => "{'ofn-i_005-caret-down' : !add_card_visible, 'ofn-i_006-caret-up' : add_card_visible}"}
|
||||
.row
|
||||
.new_card.small-12.columns{"ng-if" => 'add_card_visible', "ng-class" => "{'closed' : !add_card_visible, 'open' : add_card_visible}"}
|
||||
%form{novalidate: true, "ng-submit" => "storeCard()"}
|
||||
.row
|
||||
.small-6.columns
|
||||
%label
|
||||
= t :first_name
|
||||
-# Changing name not permitted by default (in checkout) - can be enabled by setting an allow_name_change variable in $scope
|
||||
%input{type: :text, "ng-model" => "secrets.first_name","ng-disabled" => "!allow_name_change", "ng-value" => "order.bill_address.firstname"}
|
||||
.small-12.medium-6.columns
|
||||
%span{ ng: { hide: 'savedCreditCards.length > 0' } }
|
||||
|
||||
.saved_cards{ ng: { show: 'savedCreditCards.length > 0' } }
|
||||
%h3= t(:saved_cards)
|
||||
= render 'saved_cards'
|
||||
%button.button.primary{ ng: { click: 'showForm()', hide: 'CreditCard.visible' } }
|
||||
= t(:add_a_card)
|
||||
|
||||
.small-6.columns
|
||||
%label
|
||||
= t :last_name
|
||||
%input{type: :text, "ng-model" => "secrets.last_name", "ng-disabled" => "!allow_name_change", "ng-value" => "order.bill_address.lastname"}
|
||||
|
||||
.small-6.columns
|
||||
%label
|
||||
= t(:card_number)
|
||||
%input{type: :text, "ng-model" => "secrets.card_number", "ng-required" => "!secrets.selected_card", maxlength: 19, autocomplete: "off", "ng-disabled" => "!!secrets.selected_card"}
|
||||
.small-6.columns
|
||||
%label
|
||||
= t(:card_securitycode)
|
||||
%input{type: :text, "ng-model" => "secrets.card_verification_value", "ng-required" => "!secrets.selected_card", autocomplete: "off", "ng-disabled" => "!!secrets.selected_card"}
|
||||
|
||||
.row
|
||||
.small-12.columns
|
||||
%label{for: "secrets.card_month"}
|
||||
= t :card_expiry_date, "ng-disabled" => "!!secrets.selected_card"
|
||||
|
||||
.row
|
||||
.small-6.columns
|
||||
%select{"ng-model" => "secrets.card_month", "ng-options" => "currMonth.value as currMonth.key for currMonth in months", name: "secrets.card_month", "ng-required" => "!secrets.selected_card", "ng-disabled" => "!!secrets.selected_card"}
|
||||
.small-6.columns
|
||||
%select{"ng-model" => "secrets.card_year", "ng-options" => "year for year in years", name: "secrets.card_year", "ng-required" => "!secrets.selected_card", "ng-disabled" => "!!secrets.selected_card"}
|
||||
|
||||
%p
|
||||
%button.button.primary{type: :submit}
|
||||
= t :add_card
|
||||
.small-12.medium-6.columns.new_card{ ng: { class: '{visible: CreditCard.visible}' } }
|
||||
%h3= t(:add_a_new_card)
|
||||
= render 'new_card_form'
|
||||
|
||||
35
app/views/spree/users/_new_card_form.html.haml
Normal file
35
app/views/spree/users/_new_card_form.html.haml
Normal file
@@ -0,0 +1,35 @@
|
||||
%form{ novalidate: true, "ng-submit" => "storeCard()" }
|
||||
.row
|
||||
.small-6.columns
|
||||
%label
|
||||
= t(:first_name)
|
||||
-# Changing name not permitted by default (in checkout) - can be enabled by setting an allow_name_change variable in $scope
|
||||
%input#first_name{type: :text, "ng-model" => "secrets.first_name","ng-disabled" => "!allow_name_change", "ng-value" => "order.bill_address.firstname"}
|
||||
|
||||
.small-6.columns
|
||||
%label
|
||||
= t(:last_name)
|
||||
%input#last_name{type: :text, "ng-model" => "secrets.last_name", "ng-disabled" => "!allow_name_change", "ng-value" => "order.bill_address.lastname"}
|
||||
.row
|
||||
.small-6.columns
|
||||
%label
|
||||
= t(:card_number)
|
||||
%input#card_number{type: :text, "ng-model" => "secrets.card_number", "ng-required" => "!secrets.selected_card", maxlength: 19, autocomplete: "off", "ng-disabled" => "!!secrets.selected_card"}
|
||||
.small-6.columns
|
||||
%label
|
||||
= t(:card_securitycode)
|
||||
%input#security_code{type: :text, "ng-model" => "secrets.card_verification_value", "ng-required" => "!secrets.selected_card", autocomplete: "off", "ng-disabled" => "!!secrets.selected_card"}
|
||||
|
||||
.row
|
||||
.small-12.columns
|
||||
%label{for: "secrets.card_month"}
|
||||
= t(:card_expiry_date)
|
||||
.row
|
||||
.small-6.columns
|
||||
%select#card_month{"ng-model" => "secrets.card_month", "ng-options" => "currMonth.value as currMonth.key for currMonth in months", name: "secrets.card_month", "ng-required" => "!secrets.selected_card", "ng-disabled" => "!!secrets.selected_card"}
|
||||
.small-6.columns
|
||||
%select#card_year{"ng-model" => "secrets.card_year", "ng-options" => "year for year in years", name: "secrets.card_year", "ng-required" => "!secrets.selected_card", "ng-disabled" => "!!secrets.selected_card"}
|
||||
|
||||
%p
|
||||
%button.button.primary{type: :submit}
|
||||
= t(:add_card)
|
||||
13
app/views/spree/users/_saved_cards.html.haml
Normal file
13
app/views/spree/users/_saved_cards.html.haml
Normal file
@@ -0,0 +1,13 @@
|
||||
%table
|
||||
%tr
|
||||
%th= t(:card_type)
|
||||
%th= t(:card_number)
|
||||
%th= t(:card_expiry_date)
|
||||
%th= t(:delete?)
|
||||
%tr.card{ id: "card{{ card.id }}", ng: { repeat: "card in savedCreditCards" } }
|
||||
%td.brand{ ng: { bind: '::card.brand' } }
|
||||
%td.number{ ng: { bind: '::card.number' } }
|
||||
%td.expiry{ ng: { bind: '::card.expiry' } }
|
||||
%td.actions
|
||||
%a{"rel" => "nofollow", "data-method" => "delete", "ng-href" => "{{card.delete_link}}" }
|
||||
Delete
|
||||
@@ -168,7 +168,8 @@ en:
|
||||
my_credit_cards: My credit cards
|
||||
add_new_credit_card: Add new credit card
|
||||
saved_cards: Saved cards
|
||||
add_card: Add card
|
||||
add_a_card: Add a Card
|
||||
add_card: Add Card
|
||||
saving_credit_card: Saving credit card...
|
||||
|
||||
|
||||
|
||||
@@ -4,17 +4,15 @@ require 'support/request/authentication_workflow'
|
||||
describe Spree::CreditCardsController do
|
||||
include AuthenticationWorkflow
|
||||
let(:user) { create_enterprise_user }
|
||||
let(:token) { "tok_234bd2c22" }
|
||||
|
||||
it "Creates a credit card from token + params" do
|
||||
controller.stub(:spree_current_user) { user }
|
||||
controller.stub(:create_customer) {
|
||||
sc = Stripe::Customer.new
|
||||
sc.default_source = "card_1AEEbN2eZvKYlo2CMk6QwrN7"
|
||||
sc.email = nil
|
||||
sc.stub(:id) {"cus_AZNMJzuACN3Sgt"}
|
||||
sc }
|
||||
|
||||
token = "tok_234bd2c22"
|
||||
stub_request(:post, "https://api.stripe.com/v1/customers")
|
||||
.with(:body => { email: user.email, source: token })
|
||||
.to_return(status: 200, body: JSON.generate({ id: "cus_AZNMJ", default_source: "card_1AEEb" }))
|
||||
|
||||
expect{ post :new_from_token, {
|
||||
"exp_month" => 12,
|
||||
"exp_year" => 2020,
|
||||
@@ -23,8 +21,8 @@ describe Spree::CreditCardsController do
|
||||
"cc_type" => "visa"
|
||||
} }.to change(Spree::CreditCard, :count).by(1)
|
||||
|
||||
Spree::CreditCard.last.gateway_payment_profile_id.should eq "card_1AEEbN2eZvKYlo2CMk6QwrN7"
|
||||
Spree::CreditCard.last.gateway_customer_profile_id.should eq "cus_AZNMJzuACN3Sgt"
|
||||
Spree::CreditCard.last.gateway_payment_profile_id.should eq "card_1AEEb"
|
||||
Spree::CreditCard.last.gateway_customer_profile_id.should eq "cus_AZNMJ"
|
||||
Spree::CreditCard.last.user_id.should eq user.id
|
||||
Spree::CreditCard.last.last_digits.should eq "4242"
|
||||
end
|
||||
|
||||
31
spec/features/consumer/account/cards_spec.rb
Normal file
31
spec/features/consumer/account/cards_spec.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
require 'spec_helper'
|
||||
|
||||
feature "Credit Cards", js: true do
|
||||
include AuthenticationWorkflow
|
||||
describe "as a logged in user" do
|
||||
let(:user) { create(:user) }
|
||||
let!(:card) { create(:credit_card, user_id: user.id) }
|
||||
|
||||
before do
|
||||
quick_login_as user
|
||||
end
|
||||
|
||||
it "lists saved cards, shows interface for adding new cards" do
|
||||
visit "/account"
|
||||
|
||||
click_link 'My Credit Cards'
|
||||
|
||||
expect(page).to have_content I18n.t(:saved_cards)
|
||||
|
||||
within(".card#card#{card.id}") do
|
||||
expect(page).to have_content card.cc_type.capitalize
|
||||
expect(page).to have_content card.last_digits
|
||||
end
|
||||
|
||||
click_button I18n.t(:add_a_card)
|
||||
expect(page).to have_field 'first_name'
|
||||
expect(page).to have_field 'card_number'
|
||||
expect(page).to have_field 'card_month'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -171,12 +171,12 @@ feature "As a consumer I want to check out my cart", js: true, retry: 3 do
|
||||
end
|
||||
|
||||
it "disables the input fields when a saved card is selected" do
|
||||
select "Visa XXXX XXXX XXXX 1111 Exp 01/2025", from: "selected_card"
|
||||
select "Visa x-1111 Exp:01/2025", from: "selected_card"
|
||||
page.should have_css "#secrets\\.card_number[disabled]"
|
||||
end
|
||||
|
||||
it "allows use of a saved card" do
|
||||
select "Visa XXXX XXXX XXXX 1111 Exp 01/2025", from: "selected_card"
|
||||
select "Visa x-1111 Exp:01/2025", from: "selected_card"
|
||||
place_order
|
||||
page.should have_content "Your order has been processed successfully"
|
||||
end
|
||||
|
||||
@@ -10,7 +10,7 @@ describe Api::CreditCardSerializer do
|
||||
end
|
||||
|
||||
it "formats an identifying string with the card number masked" do
|
||||
expect(serializer.formatted).to eq "Visa XXXX XXXX XXXX 1111 Exp 12/2013"
|
||||
expect(serializer.formatted).to eq "Visa x-1111 Exp:12/2013"
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user