From 5c400acad7d06f8e6a5145bc8a1734255f03332b Mon Sep 17 00:00:00 2001 From: Ryan Bigg Date: Fri, 8 Feb 2013 13:43:00 +1100 Subject: [PATCH] Use jquery.payment for frontend + backend checkout form --- backend/CHANGELOG.md | 6 +- .../javascripts/admin/checkouts/edit.js | 12 + .../payments/source_forms/_gateway.html.erb | 5 +- core/app/models/spree/credit_card.rb | 48 +- core/spec/models/spree/credit_card_spec.rb | 52 +- core/spec/models/spree/payment_spec.rb | 12 +- .../assets/javascripts/jquery.payment.js | 497 ++++++++++++++++++ frontend/CHANGELOG.md | 4 + .../javascripts/store/checkout.js.coffee | 9 + .../spree/checkout/payment/_gateway.html.erb | 8 +- 10 files changed, 565 insertions(+), 88 deletions(-) create mode 100644 core/vendor/assets/javascripts/jquery.payment.js diff --git a/backend/CHANGELOG.md b/backend/CHANGELOG.md index a9d936dc826..2fddac2a418 100644 --- a/backend/CHANGELOG.md +++ b/backend/CHANGELOG.md @@ -29,4 +29,8 @@ * Fixed issue where selecting an existing user in the customer details step would not associate them with an order. - *Ryan Bigg and dan-ding" \ No newline at end of file + *Ryan Bigg and dan-ding* + +* We now use [jQuery.payment](https://stripe.com/blog/jquery-payment) (from Stripe) to provide slightly better formatting on credit card number, expiry and CVV fields. + + *Ryan Bigg* \ No newline at end of file diff --git a/backend/app/assets/javascripts/admin/checkouts/edit.js b/backend/app/assets/javascripts/admin/checkouts/edit.js index 01341ba97c9..5e07b57039c 100644 --- a/backend/app/assets/javascripts/admin/checkouts/edit.js +++ b/backend/app/assets/javascripts/admin/checkouts/edit.js @@ -1,8 +1,20 @@ +//= require_self +//= require jquery.payment $(document).ready(function() { if ($('#customer_autocomplete_template').length > 0) { window.customerTemplate = Handlebars.compile($('#customer_autocomplete_template').text()); } + if ($("#card_number").is("*")) { + $("#card_number").payment('formatCardNumber') + $("#card_expiry").payment('formatCardExpiry') + $("#card_code").payment('formatCardCVC') + + $("#card_number").change(function() { + $("#cc_type").val($.payment.cardType(this.value)) + }) + } + formatCustomerResult = function(customer) { return customerTemplate({ customer: customer, diff --git a/backend/app/views/spree/admin/payments/source_forms/_gateway.html.erb b/backend/app/views/spree/admin/payments/source_forms/_gateway.html.erb index 2474790259a..ea08aa73719 100644 --- a/backend/app/views/spree/admin/payments/source_forms/_gateway.html.erb +++ b/backend/app/views/spree/admin/payments/source_forms/_gateway.html.erb @@ -29,9 +29,8 @@
- <%= label_tag 'card_month', raw(Spree.t(:expiration) + content_tag(:span, ' *', :class => 'required')) %>
- <%= select_month(Date.today, { :prefix => param_prefix, :field_name => 'month', :use_month_numbers => true }, :class => 'required select2', :id => 'card_month') %> - <%= select_year(Date.today, { :prefix => param_prefix, :field_name => 'year', :start_year => Date.today.year, :end_year => Date.today.year + 15 }, :class => 'required select2', :id => 'card_year') %> + <%= label_tag 'card_month', raw(t(:expiration) + content_tag(:span, ' *', :class => 'required')) %>
+ <%= text_field_tag "#{param_prefix}[expiry]", '', :id => 'card_expiry', :class => "required", :placeholder => "MM / YY" %>
diff --git a/core/app/models/spree/credit_card.rb b/core/app/models/spree/credit_card.rb index cefbefe5faa..8820f299d20 100644 --- a/core/app/models/spree/credit_card.rb +++ b/core/app/models/spree/credit_card.rb @@ -3,7 +3,6 @@ class CreditCard < ActiveRecord::Base has_many :payments, as: :source before_save :set_last_digits - after_validation :set_card_type attr_accessor :number, :verification_value @@ -14,36 +13,19 @@ class CreditCard < ActiveRecord::Base scope :with_payment_profile, -> { where('gateway_customer_profile_id IS NOT NULL') } - def set_last_digits - number.to_s.gsub!(/\s/,'') - verification_value.to_s.gsub!(/\s/,'') - self.last_digits ||= number.to_s.length <= 4 ? number : number.to_s.slice(-4..-1) + def expiry=(expiry) + self[:month], self[:year] = expiry.split(" / ") + self[:year] = "20" + self[:year] end - # cheap hack to get to the type? method from deep within ActiveMerchant - # without stomping on potentially existing methods in CreditCard - class CardDetector - class << self - include ActiveMerchant::Billing::CreditCardMethods::ClassMethods - end + def number=(num) + @number = num.gsub(/[^0-9]/, '') rescue nil end - # Some payment gateways, such as USA EPay, only support an ActiveMerchant::Billing::CreditCard - # object, rather than an object *like* that. So we need to convert it. - def to_active_merchant - ActiveMerchant::Billing::CreditCard.new( - :number => number, - :month => month, - :year => year, - :verification_value => verification_value, - :first_name => first_name, - :last_name => last_name - ) - end - - # sets self.cc_type while we still have the card number - def set_card_type - self.cc_type ||= CardDetector.brand?(number) + def set_last_digits + number.to_s.gsub!(/\s/,'') + verification_value.to_s.gsub!(/\s/,'') + self.last_digits ||= number.to_s.length <= 4 ? number : number.to_s.slice(-4..-1) end def name? @@ -94,9 +76,15 @@ def has_payment_profile? gateway_customer_profile_id.present? end - def spree_cc_type - return 'visa' if Rails.env.development? - cc_type + def to_active_merchant + ActiveMerchant::Billing::CreditCard.new( + :number => number, + :month => month, + :year => year, + :verification_value => verification_value, + :first_name => first_name, + :last_name => last_name + ) end private diff --git a/core/spec/models/spree/credit_card_spec.rb b/core/spec/models/spree/credit_card_spec.rb index 2d855d70449..f7b02a1cb27 100644 --- a/core/spec/models/spree/credit_card_spec.rb +++ b/core/spec/models/spree/credit_card_spec.rb @@ -1,8 +1,7 @@ require 'spec_helper' describe Spree::CreditCard do - - let(:valid_credit_card_attributes) { { number: '4111111111111111', verification_value: '123', month: 12, year: 2014 } } + let(:valid_credit_card_attributes) { {:number => '4111111111111111', :verification_value => '123', :expiry => "12 / 14"} } def self.payment_states Spree::Payment.state_machine.states.keys @@ -136,49 +135,18 @@ def stub_rails_env(environment) end end - context "#spree_cc_type" do - before { credit_card.attributes = valid_credit_card_attributes } - - context "in development mode" do - before do - stub_rails_env("production") - end - - it "should return visa" do - credit_card.save - credit_card.spree_cc_type.should == 'visa' - end - end - - context "in production mode" do - before { stub_rails_env("production") } - - it "should return the actual cc_type for a valid number" do - credit_card.number = '378282246310005' - credit_card.save - credit_card.spree_cc_type.should == 'american_express' - end - end - end - - context "#set_card_type" do - before :each do - stub_rails_env("production") - credit_card.attributes = valid_credit_card_attributes - end + context "#number=" do + it "should strip non-numeric characters from card input" do + credit_card.number = "6011000990139424" + credit_card.number.should == "6011000990139424" - it "stores the credit card type after validation" do - credit_card.number = '6011000990139424' - credit_card.save - credit_card.spree_cc_type.should == 'discover' + credit_card.number = " 6011-0009-9013-9424 " + credit_card.number.should == "6011000990139424" end - it "does not overwrite the credit card type when loaded and saved" do - credit_card.number = '5105105105105100' - credit_card.save - credit_card.number = 'XXXXXXXXXXXX5100' - credit_card.save - credit_card.spree_cc_type.should == 'master' + it "should not raise an exception on non-string input" do + credit_card.number = Hash.new + credit_card.number.should be_nil end end diff --git a/core/spec/models/spree/payment_spec.rb b/core/spec/models/spree/payment_spec.rb index 0d715aa7bfe..30bbb6830ed 100644 --- a/core/spec/models/spree/payment_spec.rb +++ b/core/spec/models/spree/payment_spec.rb @@ -557,8 +557,7 @@ it "should build the payment's source" do params = { :amount => 100, :payment_method => gateway, :source_attributes => { - :year => 1.month.from_now.year, - :month =>1.month.from_now.month, + :expiry =>"1 / 99", :number => '1234567890123', :verification_value => '123' } @@ -570,13 +569,8 @@ end it "errors when payment source not valid" do - params = { - :amount => 100, - :payment_method => gateway, - :source_attributes => { - :year => "2012", :month =>"1" - } - } + params = { :amount => 100, :payment_method => gateway, + :source_attributes => {:expiry => "1 / 12" }} payment = Spree::Payment.new(params) payment.should_not be_valid diff --git a/core/vendor/assets/javascripts/jquery.payment.js b/core/vendor/assets/javascripts/jquery.payment.js new file mode 100644 index 00000000000..5bc7ef0adb6 --- /dev/null +++ b/core/vendor/assets/javascripts/jquery.payment.js @@ -0,0 +1,497 @@ +// Generated by CoffeeScript 1.4.0 +(function() { + var $, cardFromNumber, cardFromType, cards, defaultFormat, formatBackCardNumber, formatBackExpiry, formatCardNumber, formatExpiry, formatForwardExpiry, formatForwardSlash, hasTextSelected, luhnCheck, reFormatCardNumber, restrictCVC, restrictCardNumber, restrictExpiry, restrictNumeric, setCardType, + __slice = [].slice, + __indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }, + _this = this; + + $ = jQuery; + + $.payment = {}; + + $.payment.fn = {}; + + $.fn.payment = function() { + var args, method; + method = arguments[0], args = 2 <= arguments.length ? __slice.call(arguments, 1) : []; + return $.payment.fn[method].apply(this, args); + }; + + defaultFormat = /(\d{1,4})/g; + + cards = [ + { + type: 'maestro', + pattern: /^(5018|5020|5038|6304|6759|676[1-3])/, + format: defaultFormat, + length: [12, 13, 14, 15, 16, 17, 18, 19], + cvcLength: [3], + luhn: true + }, { + type: 'dinersclub', + pattern: /^(36|38|30[0-5])/, + format: defaultFormat, + length: [14], + cvcLength: [3], + luhn: true + }, { + type: 'laser', + pattern: /^(6706|6771|6709)/, + format: defaultFormat, + length: [16, 17, 18, 19], + cvcLength: [3], + luhn: true + }, { + type: 'jcb', + pattern: /^35/, + format: defaultFormat, + length: [16], + cvcLength: [3], + luhn: true + }, { + type: 'unionpay', + pattern: /^62/, + format: defaultFormat, + length: [16, 17, 18, 19], + cvcLength: [3], + luhn: false + }, { + type: 'discover', + pattern: /^(6011|65|64[4-9]|622)/, + format: defaultFormat, + length: [16], + cvcLength: [3], + luhn: true + }, { + type: 'mastercard', + pattern: /^5[1-5]/, + format: defaultFormat, + length: [16], + cvcLength: [3], + luhn: true + }, { + type: 'amex', + pattern: /^3[47]/, + format: /(\d{1,4})(\d{1,6})?(\d{1,5})?/, + length: [15], + cvcLength: [3, 4], + luhn: true + }, { + type: 'visa', + pattern: /^4/, + format: defaultFormat, + length: [13, 14, 15, 16], + cvcLength: [3], + luhn: true + } + ]; + + cardFromNumber = function(num) { + var card, _i, _len; + num = (num + '').replace(/\D/g, ''); + for (_i = 0, _len = cards.length; _i < _len; _i++) { + card = cards[_i]; + if (card.pattern.test(num)) { + return card; + } + } + }; + + cardFromType = function(type) { + var card, _i, _len; + for (_i = 0, _len = cards.length; _i < _len; _i++) { + card = cards[_i]; + if (card.type === type) { + return card; + } + } + }; + + luhnCheck = function(num) { + var digit, digits, odd, sum, _i, _len; + odd = true; + sum = 0; + digits = (num + '').split('').reverse(); + for (_i = 0, _len = digits.length; _i < _len; _i++) { + digit = digits[_i]; + digit = parseInt(digit, 10); + if ((odd = !odd)) { + digit *= 2; + } + if (digit > 9) { + digit -= 9; + } + sum += digit; + } + return sum % 10 === 0; + }; + + hasTextSelected = function($target) { + var _ref; + if (($target.prop('selectionStart') != null) && $target.prop('selectionStart') !== $target.prop('selectionEnd')) { + return true; + } + if (typeof document !== "undefined" && document !== null ? (_ref = document.selection) != null ? typeof _ref.createRange === "function" ? _ref.createRange().text : void 0 : void 0 : void 0) { + return true; + } + return false; + }; + + reFormatCardNumber = function(e) { + var _this = this; + return setTimeout(function() { + var $target, value; + $target = $(e.currentTarget); + value = $target.val(); + value = $.payment.formatCardNumber(value); + return $target.val(value); + }); + }; + + formatCardNumber = function(e) { + var $target, card, digit, length, re, upperLength, value; + digit = String.fromCharCode(e.which); + if (!/^\d+$/.test(digit)) { + return; + } + $target = $(e.currentTarget); + value = $target.val(); + card = cardFromNumber(value + digit); + length = (value.replace(/\D/g, '') + digit).length; + upperLength = 16; + if (card) { + upperLength = card.length[card.length.length - 1]; + } + if (length >= upperLength) { + return; + } + if (($target.prop('selectionStart') != null) && $target.prop('selectionStart') !== value.length) { + return; + } + if (card && card.type === 'amex') { + re = /^(\d{4}|\d{4}\s\d{6})$/; + } else { + re = /(?:^|\s)(\d{4})$/; + } + if (re.test(value)) { + e.preventDefault(); + return $target.val(value + ' ' + digit); + } else if (re.test(value + digit)) { + e.preventDefault(); + return $target.val(value + digit + ' '); + } + }; + + formatBackCardNumber = function(e) { + var $target, value; + $target = $(e.currentTarget); + value = $target.val(); + if (e.meta) { + return; + } + if (e.which !== 8) { + return; + } + if (($target.prop('selectionStart') != null) && $target.prop('selectionStart') !== value.length) { + return; + } + if (/\d\s$/.test(value)) { + e.preventDefault(); + return $target.val(value.replace(/\d\s$/, '')); + } else if (/\s\d?$/.test(value)) { + e.preventDefault(); + return $target.val(value.replace(/\s\d?$/, '')); + } + }; + + formatExpiry = function(e) { + var $target, digit, val; + digit = String.fromCharCode(e.which); + if (!/^\d+$/.test(digit)) { + return; + } + $target = $(e.currentTarget); + val = $target.val() + digit; + if (/^\d$/.test(val) && (val !== '0' && val !== '1')) { + e.preventDefault(); + return $target.val("0" + val + " / "); + } else if (/^\d\d$/.test(val)) { + e.preventDefault(); + return $target.val("" + val + " / "); + } + }; + + formatForwardExpiry = function(e) { + var $target, digit, val; + digit = String.fromCharCode(e.which); + if (!/^\d+$/.test(digit)) { + return; + } + $target = $(e.currentTarget); + val = $target.val(); + if (/^\d\d$/.test(val)) { + return $target.val("" + val + " / "); + } + }; + + formatForwardSlash = function(e) { + var $target, slash, val; + slash = String.fromCharCode(e.which); + if (slash !== '/') { + return; + } + $target = $(e.currentTarget); + val = $target.val(); + if (/^\d$/.test(val) && val !== '0') { + return $target.val("0" + val + " / "); + } + }; + + formatBackExpiry = function(e) { + var $target, value; + if (e.meta) { + return; + } + $target = $(e.currentTarget); + value = $target.val(); + if (e.which !== 8) { + return; + } + if (($target.prop('selectionStart') != null) && $target.prop('selectionStart') !== value.length) { + return; + } + if (/\d(\s|\/)+$/.test(value)) { + e.preventDefault(); + return $target.val(value.replace(/\d(\s|\/)*$/, '')); + } else if (/\s\/\s?\d?$/.test(value)) { + e.preventDefault(); + return $target.val(value.replace(/\s\/\s?\d?$/, '')); + } + }; + + restrictNumeric = function(e) { + var input; + if (e.metaKey || e.ctrlKey) { + return true; + } + if (e.which === 32) { + return false; + } + if (e.which === 0) { + return true; + } + if (e.which < 33) { + return true; + } + input = String.fromCharCode(e.which); + return !!/[\d\s]/.test(input); + }; + + restrictCardNumber = function(e) { + var $target, card, digit, value; + $target = $(e.currentTarget); + digit = String.fromCharCode(e.which); + if (!/^\d+$/.test(digit)) { + return; + } + if (hasTextSelected($target)) { + return; + } + value = ($target.val() + digit).replace(/\D/g, ''); + card = cardFromNumber(value); + if (card) { + return value.length <= card.length[card.length.length - 1]; + } else { + return value.length <= 16; + } + }; + + restrictExpiry = function(e) { + var $target, digit, value; + $target = $(e.currentTarget); + digit = String.fromCharCode(e.which); + if (!/^\d+$/.test(digit)) { + return; + } + if (hasTextSelected($target)) { + return; + } + value = $target.val() + digit; + value = value.replace(/\D/g, ''); + if (value.length > 6) { + return false; + } + }; + + restrictCVC = function(e) { + var $target, digit, val; + $target = $(e.currentTarget); + digit = String.fromCharCode(e.which); + if (!/^\d+$/.test(digit)) { + return; + } + val = $target.val() + digit; + return val.length <= 4; + }; + + setCardType = function(e) { + var $target, allTypes, card, cardType, val; + $target = $(e.currentTarget); + val = $target.val(); + cardType = $.payment.cardType(val) || 'unknown'; + if (!$target.hasClass(cardType)) { + allTypes = (function() { + var _i, _len, _results; + _results = []; + for (_i = 0, _len = cards.length; _i < _len; _i++) { + card = cards[_i]; + _results.push(card.type); + } + return _results; + })(); + $target.removeClass('unknown'); + $target.removeClass(allTypes.join(' ')); + $target.addClass(cardType); + $target.toggleClass('identified', cardType !== 'unknown'); + return $target.trigger('payment.cardType', cardType); + } + }; + + $.payment.fn.formatCardCVC = function() { + this.payment('restrictNumeric'); + this.on('keypress', restrictCVC); + return this; + }; + + $.payment.fn.formatCardExpiry = function() { + this.payment('restrictNumeric'); + this.on('keypress', restrictExpiry); + this.on('keypress', formatExpiry); + this.on('keypress', formatForwardSlash); + this.on('keypress', formatForwardExpiry); + this.on('keydown', formatBackExpiry); + return this; + }; + + $.payment.fn.formatCardNumber = function() { + this.payment('restrictNumeric'); + this.on('keypress', restrictCardNumber); + this.on('keypress', formatCardNumber); + this.on('keydown', formatBackCardNumber); + this.on('keyup', setCardType); + this.on('paste', reFormatCardNumber); + return this; + }; + + $.payment.fn.restrictNumeric = function() { + this.on('keypress', restrictNumeric); + return this; + }; + + $.payment.fn.cardExpiryVal = function() { + return $.payment.cardExpiryVal($(this).val()); + }; + + $.payment.cardExpiryVal = function(value) { + var month, prefix, year, _ref; + value = value.replace(/\s/g, ''); + _ref = value.split('/', 2), month = _ref[0], year = _ref[1]; + if ((year != null ? year.length : void 0) === 2 && /^\d+$/.test(year)) { + prefix = (new Date).getFullYear(); + prefix = prefix.toString().slice(0, 2); + year = prefix + year; + } + month = parseInt(month, 10); + year = parseInt(year, 10); + return { + month: month, + year: year + }; + }; + + $.payment.validateCardNumber = function(num) { + var card, _ref; + num = (num + '').replace(/\s+|-/g, ''); + if (!/^\d+$/.test(num)) { + return false; + } + card = cardFromNumber(num); + if (!card) { + return false; + } + return (_ref = num.length, __indexOf.call(card.length, _ref) >= 0) && (card.luhn === false || luhnCheck(num)); + }; + + $.payment.validateCardExpiry = function(month, year) { + var currentTime, expiry, prefix, _ref; + if (typeof month === 'object' && 'month' in month) { + _ref = month, month = _ref.month, year = _ref.year; + } + if (!(month && year)) { + return false; + } + month = $.trim(month); + year = $.trim(year); + if (!/^\d+$/.test(month)) { + return false; + } + if (!/^\d+$/.test(year)) { + return false; + } + if (!(parseInt(month, 10) <= 12)) { + return false; + } + if (year.length === 2) { + prefix = (new Date).getFullYear(); + prefix = prefix.toString().slice(0, 2); + year = prefix + year; + } + expiry = new Date(year, month); + currentTime = new Date; + expiry.setMonth(expiry.getMonth() - 1); + expiry.setMonth(expiry.getMonth() + 1, 1); + return expiry > currentTime; + }; + + $.payment.validateCardCVC = function(cvc, type) { + var _ref, _ref1; + cvc = $.trim(cvc); + if (!/^\d+$/.test(cvc)) { + return false; + } + if (type) { + return _ref = cvc.length, __indexOf.call((_ref1 = cardFromType(type)) != null ? _ref1.cvcLength : void 0, _ref) >= 0; + } else { + return cvc.length >= 3 && cvc.length <= 4; + } + }; + + $.payment.cardType = function(num) { + var _ref; + if (!num) { + return null; + } + return ((_ref = cardFromNumber(num)) != null ? _ref.type : void 0) || null; + }; + + $.payment.formatCardNumber = function(num) { + var card, groups, upperLength, _ref; + card = cardFromNumber(num); + if (!card) { + return num; + } + upperLength = card.length[card.length.length - 1]; + num = num.replace(/\D/g, ''); + num = num.slice(0, +upperLength + 1 || 9e9); + if (card.format.global) { + return (_ref = num.match(card.format)) != null ? _ref.join(' ') : void 0; + } else { + groups = card.format.exec(num); + if (groups != null) { + groups.shift(); + } + return groups != null ? groups.join(' ') : void 0; + } + }; + +}).call(this); \ No newline at end of file diff --git a/frontend/CHANGELOG.md b/frontend/CHANGELOG.md index 1abad6336c0..f7d8c393398 100644 --- a/frontend/CHANGELOG.md +++ b/frontend/CHANGELOG.md @@ -8,3 +8,7 @@ * Switch to new Google Analytics analytics.js SDK from ga.js SDK for custom dimensions & metrics. *Jeff Dutil* + +* We now use [jQuery.payment](https://stripe.com/blog/jquery-payment) (from Stripe) to provide slightly better formatting on credit card number, expiry and CVV fields. + + *Ryan Bigg* \ No newline at end of file diff --git a/frontend/app/assets/javascripts/store/checkout.js.coffee b/frontend/app/assets/javascripts/store/checkout.js.coffee index 6a6ac1943cd..b1246201189 100644 --- a/frontend/app/assets/javascripts/store/checkout.js.coffee +++ b/frontend/app/assets/javascripts/store/checkout.js.coffee @@ -1,9 +1,18 @@ +//= require jquery.payment + Spree.disableSaveOnClick = -> ($ 'form.edit_order').submit -> ($ this).find(':submit, :image').attr('disabled', true).removeClass('primary').addClass 'disabled' Spree.ready ($) -> Spree.Checkout = {} + $("#card_number").payment('formatCardNumber') + $("#card_expiry").payment('formatCardExpiry') + $("#card_code").payment('formatCardCVC') + + $("#card_number").change -> + $("#cc_type").val($.payment.cardType(@value)) + if ($ '#checkout_form_address').is('*') ($ '#checkout_form_address').validate() diff --git a/frontend/app/views/spree/checkout/payment/_gateway.html.erb b/frontend/app/views/spree/checkout/payment/_gateway.html.erb index 67042a056d3..a36f6eb093c 100644 --- a/frontend/app/views/spree/checkout/payment/_gateway.html.erb +++ b/frontend/app/views/spree/checkout/payment/_gateway.html.erb @@ -13,14 +13,16 @@

- <%= label_tag "card_month", Spree.t(:expiration) %>*
- <%= select_month(Date.today, { :prefix => param_prefix, :field_name => 'month', :use_month_numbers => true }, :class => 'required', :id => "card_month") %> - <%= select_year(Date.today, { :prefix => param_prefix, :field_name => 'year', :start_year => Date.today.year, :end_year => Date.today.year + 15 }, :class => 'required', :id => "card_year") %> + <%= label_tag "card_month", t(:expiration) %>*
+ <%= text_field_tag "#{param_prefix}[expiry]", '', :id => 'card_expiry', :class => "required", :placeholder => "MM / YY" %>

<%= label_tag "card_code", Spree.t(:card_code) %>*
<%= text_field_tag "#{param_prefix}[verification_value]", '', options_hash.merge(:id => 'card_code', :class => 'required', :size => 5) %> <%= link_to "(#{Spree.t(:what_is_this)})", spree.content_path('cvv'), :target => '_blank', "data-hook" => "cvv_link", :id => "cvv_link" %>

+ +<%= hidden_field_tag "#{param_prefix}[cc_type]", '', :id => "cc_type" %> + <%= hidden_field param_prefix, 'first_name', :value => @order.billing_firstname %> <%= hidden_field param_prefix, 'last_name', :value => @order.billing_lastname %>