VRFS-3965 - manage payment features for students

This commit is contained in:
Seth Call 2016-05-11 16:03:55 -05:00
parent 15d968dff5
commit 309ebb4e5a
28 changed files with 791 additions and 77 deletions

View File

@ -345,4 +345,5 @@ lessons.sql
lessons_unread_messages.sql
track_school_signups.sql
add_test_drive_types.sql
updated_subjects.sql
updated_subjects.sql
update_payment_history.sql

View File

@ -0,0 +1 @@
ALTER TABLE charges ADD COLUMN user_id VARCHAR(64) REFERENCES users(id) NOT NULL;

View File

@ -1,6 +1,8 @@
module JamRuby
class Charge < ActiveRecord::Base
belongs_to :user, class_name: "JamRuby::User"
validates :sent_billing_notices, inclusion: {in: [true, false]}
def max_retries

View File

@ -61,11 +61,11 @@ module JamRuby
validate :validate_lesson_booking_slots
validate :validate_lesson_length
validate :validate_payment_style
validate :validate_uncollectables
validate :validate_accepted, :if => :accepting
validate :validate_canceled, :if => :canceling
before_save :before_save
before_validation :before_validation
after_create :after_create
@ -603,7 +603,7 @@ module JamRuby
#end
elsif is_test_drive?
if user.has_requested_test_drive?(teacher) && !user.admin
errors.add(:user, "has a requested TestDrive with this teacher")
errors.add(:user, "have a requested TestDrive with this teacher")
end
if !user.has_test_drives? && !user.can_buy_test_drive?
errors.add(:user, "have no remaining test drives")
@ -651,6 +651,11 @@ module JamRuby
end
end
def validate_uncollectables
if user.uncollectables.count > 0
errors.add(:user, 'have unpaid lessons.')
end
end
def self.book_free(user, teacher, lesson_booking_slots, description)
self.book(user, teacher, LessonBooking::LESSON_TYPE_FREE, lesson_booking_slots, false, 30, PAYMENT_STYLE_ELSEWHERE, description)

View File

@ -33,11 +33,14 @@ module JamRuby
end
def create_charge
self.lesson_payment_charge = LessonPaymentCharge.new
lesson_payment_charge.amount_in_cents = 0
lesson_payment_charge.fee_in_cents = 0
lesson_payment_charge.lesson_package_purchase = self
lesson_payment_charge.save!
if self.lesson_booking.is_monthly_payment?
self.lesson_payment_charge = LessonPaymentCharge.new
lesson_payment_charge.user = user
lesson_payment_charge.amount_in_cents = 0
lesson_payment_charge.fee_in_cents = 0
lesson_payment_charge.lesson_package_purchase = self
lesson_payment_charge.save!
end
end
def add_test_drives
@ -94,14 +97,17 @@ module JamRuby
(price * 100).to_i
end
def description(lesson_booking)
lesson_package_type.description(lesson_booking)
def description(lesson_booking, time = false)
lesson_package_type.description(lesson_booking, time)
end
def stripe_description(lesson_booking)
description(lesson_booking)
end
def timed_description
"Lessons for the month of #{self.month_name} with #{self.lesson_booking.student.name}"
end
def month_name
if recurring
@ -116,6 +122,7 @@ module JamRuby
end
def bill_monthly(force = false)
lesson_payment_charge.charge(force)

View File

@ -9,7 +9,7 @@ module JamRuby
end
def charged_user
@charged_user ||= target.student
user
end
def resolve_target
@ -31,6 +31,10 @@ module JamRuby
charged_user
end
def teacher
target.teacher
end
def is_lesson?
!lesson_session.nil?
end
@ -85,5 +89,13 @@ module JamRuby
end
end
end
def description
target.timed_description
end
def expected_price_in_cents
target.lesson_booking.distribution_price_in_cents(target)
end
end
end

View File

@ -5,13 +5,13 @@ module JamRuby
include HtmlSanitize
html_sanitize strict: [:cancel_message]
attr_accessor :accepting, :creating, :countering, :countered_slot, :countered_lesson, :canceling
attr_accessor :accepting, :creating, :countering, :countered_slot, :countered_lesson, :canceling, :assigned_student
@@log = Logging.logger[LessonSession]
delegate :sent_billing_notices, :last_billing_attempt_at, :billing_attempts, :billing_should_retry, :billed_at, :billing_error_detail, :billing_error_reason, :is_card_declined?, :is_card_expired?, :last_billed_at_date, :sent_billing_notices, to: :lesson_payment_charge, allow_nil: true
delegate :is_test_drive?, :is_single_free?, :is_normal?, :approved_before?, :is_active?, :recurring, to: :lesson_booking
delegate :is_test_drive?, :is_single_free?, :is_normal?, :approved_before?, :is_active?, :recurring, :is_monthly_payment?, to: :lesson_booking
delegate :pretty_scheduled_start, to: :music_session
@ -75,8 +75,9 @@ module JamRuby
scope :past_cancel_window, -> { joins(:music_session).where('music_sessions.scheduled_start > ?', 24.hours.from_now) }
def create_charge
if !is_test_drive?
if !is_test_drive? && !is_monthly_payment?
self.lesson_payment_charge = LessonPaymentCharge.new
lesson_payment_charge.user = @assigned_student
lesson_payment_charge.amount_in_cents = 0
lesson_payment_charge.fee_in_cents = 0
lesson_payment_charge.lesson_session = self
@ -432,6 +433,7 @@ module JamRuby
lesson_session.teacher = booking.teacher
lesson_session.status = booking.status
lesson_session.slot = booking.default_slot
lesson_session.assigned_student = booking.student
if booking.is_test_drive?
lesson_session.lesson_package_purchase = booking.student.most_recent_test_drive_purchase
end
@ -711,6 +713,19 @@ module JamRuby
lesson_booking.lesson_package_type.description(lesson_booking)
end
def timed_description
if is_test_drive?
"TestDrive session with #{self.lesson_booking.student.name} on #{self.scheduled_start.to_date.strftime('%B %d, %Y')}"
else
if self.lesson_booking.is_monthly_payment?
"Monthly Lesson with #{self.lesson_booking.student.name} on #{self.scheduled_start.to_date.strftime('%B %d, %Y')}"
else
"Lesson with #{self.lesson_booking.student.name} on #{self.scheduled_start.to_date.strftime('%B %d, %Y')}"
end
end
end
def stripe_description(lesson_booking)
description(lesson_booking)
end

View File

@ -5,6 +5,7 @@ module JamRuby
belongs_to :sale
belongs_to :recurly_transaction_web_hook
belongs_to :charge
def self.index(user, params = {})
@ -14,7 +15,7 @@ module JamRuby
limit = limit.to_i
query = PaymentHistory.limit(limit)
.includes(sale: [:sale_line_items], recurly_transaction_web_hook:[])
.includes(sale: [:sale_line_items], recurly_transaction_web_hook:[], charge:[])
.where(user_id: user.id)
.where("transaction_type = 'sale' OR transaction_type = 'refund' OR transaction_type = 'void'")
.order('created_at DESC')

View File

@ -104,17 +104,9 @@ module JamRuby
def description
if lesson_session
if lesson_session.lesson_booking.is_test_drive?
"TestDrive session with #{lesson_session.lesson_booking.student.name} on #{lesson_session.scheduled_start.to_date.strftime('%B %d, %Y')}"
elsif lesson_session.lesson_booking.is_normal?
if lesson_session.lesson_booking.is_weekly_payment? || lesson_session.lesson_booking.is_monthly_payment?
raise "Should not be here"
else
"Lesson with #{lesson_session.lesson_booking.student.name} on #{lesson_session.scheduled_start.to_date.strftime('%B %d, %Y')}"
end
end
lesson_session.timed_description
else
"Lessons for the month of #{lesson_package_purchase.month_name} with #{lesson_package_purchase.lesson_booking.student.name}"
lesson_package_purchase.description
end
end
end

View File

@ -80,6 +80,7 @@ module JamRuby
if payment.teacher_payment_charge.nil?
charge = TeacherPaymentCharge.new
charge.user = teacher
charge.amount_in_cents = payment.amount_in_cents
charge.fee_in_cents = payment.fee_in_cents
charge.teacher_payment = payment

View File

@ -1957,6 +1957,7 @@ module JamRuby
def card_approved(token, zip, booking_id)
approved_booking = nil
found_uncollectables = nil
User.transaction do
self.stripe_token = token if token
self.stripe_zip_code = zip if zip
@ -1970,9 +1971,16 @@ module JamRuby
approved_booking.card_approved
end
end
if uncollectables.count > 0
found_uncollectables = uncollectables
uncollectables.update_all(billing_should_retry: true)
else
found_uncollectables = nil
end
end
end
approved_booking
[approved_booking, found_uncollectables]
end
def update_name(name)
@ -2002,6 +2010,7 @@ module JamRuby
intent = nil
purchase = nil
lesson_package_type = nil
uncollectables = nil
User.transaction do
if params[:name].present?
@ -2010,7 +2019,7 @@ module JamRuby
end
end
booking = card_approved(params[:token], params[:zip], params[:booking_id])
booking, uncollectables = card_approved(params[:token], params[:zip], params[:booking_id])
if params[:test_drive]
self.reload
if booking
@ -2035,7 +2044,7 @@ module JamRuby
end
{lesson: booking, test_drive: test_drive, purchase: purchase, lesson_package_type: lesson_package_type}
{lesson: booking, test_drive: test_drive, purchase: purchase, lesson_package_type: lesson_package_type, uncollectables: uncollectables}
end
def requested_test_drive(teacher = nil)
@ -2095,6 +2104,10 @@ module JamRuby
total_test_drives - remaining_test_drives
end
def uncollectables(limit = 10)
LessonPaymentCharge.where(user_id:self.id).order(:created_at).where('billing_attempts > 0').where(billed: false).limit(limit)
end
def has_rated_teacher(teacher)
if teacher.is_a?(JamRuby::User)
teacher = teacher.teacher

View File

@ -1010,6 +1010,13 @@ FactoryGirl.define do
factory :teacher_payment_charge, parent: :charge, class: 'JamRuby::TeacherPaymentCharge' do
type 'JamRuby::TeacherPaymentCharge'
association :user, factory: :user
end
factory :lesson_payment_charge, parent: :charge, class: 'JamRuby::LessonPaymentCharge' do
type 'JamRuby::LessonPaymentCharge'
association :user, factory: :user
end

View File

@ -842,6 +842,45 @@ describe User do
end
end
describe "uncollectables" do
let(:user) {FactoryGirl.create(:user)}
let(:teacher) {FactoryGirl.create(:teacher_user)}
it "empty" do
user.uncollectables.count.should eql 0
end
it "one" do
lesson_session = normal_lesson(user, teacher)
lesson_session.lesson_payment_charge.user.should eql user
lesson_session.lesson_payment_charge.billing_attempts = 1
lesson_session.lesson_payment_charge.save!
uncollectables = user.uncollectables
uncollectables.count.should eql 1
uncollectable = uncollectables[0]
uncollectable.description.should_not be_nil
uncollectable.expected_price_in_cents.should eql 3000
uncollectable.is_card_declined?.should be_false
end
it "for monthly" do
lesson_session = monthly_lesson(user, teacher)
lesson_session.booked_price.should eql 30.00
LessonBooking.hourly_check
lesson_session.lesson_payment_charge.should be_nil
purchases=LessonPackagePurchase.where(user_id: user.id)
purchases.count.should eql 1
purchases[0].lesson_payment_charge.billing_attempts = 1
purchases[0].lesson_payment_charge.save!
uncollectables = user.uncollectables
uncollectables.count.should eql 1
uncollectable = uncollectables[0]
uncollectable.description.should_not be_nil
uncollectable.expected_price_in_cents.should eql 3000
uncollectable.is_card_declined?.should be_false
end
end
=begin
describe "update avatar" do

View File

@ -74,6 +74,41 @@ def normal_lesson(user, teacher, slots = nil)
lesson.reload
lesson.slot.should eql slots[0]
lesson.status.should eql LessonSession::STATUS_APPROVED
lesson.music_session.should_not be_nil
lesson
end
def monthly_lesson(user, teacher, slots = nil)
if slots.nil?
slots = []
slots << FactoryGirl.build(:lesson_booking_slot_recurring)
slots << FactoryGirl.build(:lesson_booking_slot_recurring)
end
if user.stored_credit_card == false
user.stored_credit_card = true
user.save!
end
booking = LessonBooking.book_normal(user, teacher, slots, "Hey I've heard of you before.", true, LessonBooking::PAYMENT_STYLE_MONTHLY, 60)
# puts "NORMAL BOOKING #{booking.errors.inspect}"
booking.errors.any?.should be_false
lesson = booking.lesson_sessions[0]
booking.card_presumed_ok.should be_true
#if user.most_recent_test_drive_purchase.nil?
# LessonPackagePurchase.create(user, booking, LessonPackageType.test_drive_4)
#end
lesson.accept({message: 'Yeah I got this', slot: slots[0]})
lesson.errors.any?.should be_false
lesson.reload
lesson.slot.should eql slots[0]
lesson.status.should eql LessonSession::STATUS_APPROVED
lesson.music_session.should_not be_nil
lesson
end

View File

@ -2220,6 +2220,16 @@
});
}
function getUncollectables(options) {
options = options || {}
return $.ajax({
type: "GET",
url: "/api/lesson_sessions/uncollectable",
dataType: "json",
contentType: 'application/json'
});
}
function getLesson(options) {
options = options || {}
@ -2672,6 +2682,7 @@
this.counterLessonBooking = counterLessonBooking;
this.submitStripe = submitStripe;
this.getLessonSessions = getLessonSessions;
this.getUncollectables = getUncollectables;
this.getLesson = getLesson;
this.getLessonAnalysis = getLessonAnalysis;
this.updateLessonSessionUnreadMessages = updateLessonSessionUnreadMessages;

View File

@ -10,7 +10,7 @@ profileUtils = context.JK.ProfileUtils
@AccountPaymentHistoryScreen = React.createClass({
mixins: [
#ICheckMixin,
ICheckMixin,
Reflux.listenTo(AppStore, "onAppInit"),
Reflux.listenTo(UserStore, "onUserChanged")
]
@ -23,21 +23,62 @@ profileUtils = context.JK.ProfileUtils
TILE_PAYMENTS_TO_JAMKAZAM: 'payments to jamkazam'
TILE_PAYMENT_METHOD: 'payment method'
STUDENT_TILES: ['payments to jamkazam', 'payment method']
STUDENT_TILES: ['payment method', 'payments to jamkazam']
TEACHER_TILES: ['payments to jamkazam', 'payments to you']
onAppInit: (@app) ->
@app.bindScreen('account/paymentHistory', {beforeShow: @beforeShow, afterShow: @afterShow, beforeHide: @beforeHide})
onUserChanged: (userState) ->
@setState({user: userState?.user})
if !@shouldShowNameSet
@shouldShowNameSet = true
if userState?.user?
username = userState.user.name
first_name = userState.user.first_name
last_name = userState.user.last_name
shouldShowName = !username? || username.trim() == '' || username.toLowerCase().indexOf('anonymous') > -1
else
shouldShowName = @state.shouldShowName
@setState({user: userState?.user, shouldShowName: shouldShowName})
componentDidMount: () ->
#@checkboxes = [{selector: 'input.slot-decision', stateKey: 'slot-decision'}]
@checkboxes = [{selector: 'input.billing-address-in-us', stateKey: 'billingInUS'}]
@root = $(@getDOMNode())
@endOfList = @root.find('.end-of-payments-list')
@contentBodyScroller = @root
#@iCheckify()
@root = $(@getDOMNode())
@iCheckify()
componentDidUpdate: (prevProps, prevState) ->
@iCheckify()
$expiration = @root.find('input.expiration')
if !$expiration.data('payment-applied')
$expiration.payment('formatCardExpiry').data('payment-applied', true)
$cardNumber = @root.find("input.card-number")
if !$cardNumber.data('payment-applied')
$cardNumber.payment('formatCardNumber').data('payment-applied', true)
$cvv = @root.find("input.cvv")
if !$cvv.data('payment-applied')
$cvv.payment('formatCardCVC').data('payment-applied', true)
if @currentNext() == null
@contentBodyScroller.off('scroll')
if @state[@getCurrentPageName()] == 1 and @getCurrentList().length == 0
@endOfList.show()
logger.debug("PaymentHistoryScreen: empty search")
else if @state[@getCurrentPageName()] > 0
logger.debug("end of search")
@endOfList.show()
else
@registerInfiniteScroll(@contentBodyScroller)
if @activeTile(prevState.selected) != @activeTile() && @getCurrentList().length == 0
@refresh()
registerInfiniteScroll:() ->
$scroller = @contentBodyScroller
@ -59,24 +100,6 @@ profileUtils = context.JK.ProfileUtils
@incrementCurrentPage()
@refresh()
componentDidUpdate: (prevProps, prevState) ->
#@iCheckify()
if @currentNext() == null
@contentBodyScroller.off('scroll')
if @state[@getCurrentPageName()] == 1 and @getCurrentList().length == 0
@endOfList.show()
logger.debug("PaymentHistoryScreen: empty search")
else if @state[@getCurrentPageName()] > 0
logger.debug("end of search")
@endOfList.show()
else
@registerInfiniteScroll(@contentBodyScroller)
if @activeTile(prevState.selected) != @activeTile() && @getCurrentList().length == 0
@refresh()
checkboxChanged: (e) ->
checked = $(e.target).is(':checked')
@ -86,6 +109,7 @@ profileUtils = context.JK.ProfileUtils
beforeHide: (e) ->
@screenVisible = false
@resetErrors()
beforeShow: (e) ->
@ -93,7 +117,15 @@ profileUtils = context.JK.ProfileUtils
@clearResults()
@screenVisible = true
@refresh()
@getUncollectables()
resetErrors: () ->
@setState({ccError: null, cvvError: null, expiryError: null, billingInUSError: null, zipCodeError: null, nameError: null})
checkboxChanged: (e) ->
checked = $(e.target).is(':checked')
@setState({billingInUS: checked})
refresh: () ->
@buildQuery()
@ -116,6 +148,11 @@ profileUtils = context.JK.ProfileUtils
.done(@teacherDistributionsDone)
.fail(@teacherDistributionsFail)
getUncollectables: () ->
rest.getUncollectables({})
.done(@uncollectablesDone)
.fail(@uncollectablesFail)
salesHistoryDone:(response) ->
@refreshing = false
this.setState({salesNext: response.next, sales: this.state.sales.concat(response.entries)})
@ -132,9 +169,14 @@ profileUtils = context.JK.ProfileUtils
@refreshing = false
@app.notifyServerError jqXHR, 'Payments to You Unavailable'
uncollectablesDone: (response) ->
this.setState({uncollectables: response})
uncollectablesFail: (jqXHR) ->
@app.notifyServerError jqXHR, 'Unable to fetch uncollectable info'
clearResults:() ->
this.setState({salesCurrentPage: 0, sales: [], distributionsCurrentPage: 0, distributions: [], salesNext: null, distributionsNext: null})
this.setState({salesCurrentPage: 0, sales: [], distributionsCurrentPage: 0, distributions: [], salesNext: null, distributionsNext: null, updating: false})
buildQuery:(page = @getCurrentPage()) ->
@currentQuery = this.defaultQuery(page)
@ -182,6 +224,10 @@ profileUtils = context.JK.ProfileUtils
else
null
onClick: (e) ->
e.preventDefault()
context.location.href = '/client#/account'
getInitialState: () ->
{
user: null,
@ -192,7 +238,11 @@ profileUtils = context.JK.ProfileUtils
distributionsNext: null
sales: [],
distributions: []
selected: 'payments to jamkazam'
selected: 'payments to jamkazam',
updating: false,
billingInUS: true,
userWantsUpdateCC: false,
uncollectables: []
}
onCancel: (e) ->
@ -272,17 +322,147 @@ profileUtils = context.JK.ProfileUtils
<a className="btn-next-pager" href="/api/sales?page=1">Next</a>
<div className="end-of-payments-list end-of-list">No more payment history</div>
<div className="input-aligner">
<a className="back button-grey">BACK</a>
<a className="back button-grey" onClick={this.onBack}>BACK</a>
</div>
<br className="clearall" />
</div>`
paymentMethod: () ->
disabled = @state.updating || @reuseStoredCard()
submitClassNames = {'button-orange': true, 'purchase-btn': true, disabled: disabled && @state.updating}
updateCardClassNames = {'button-grey': true, 'update-btn': true, disabled: disabled && @state.updating}
backClassNames = {'button-grey': true, disabled: disabled && @state.updating}
cardNumberFieldClasses = {field: true, "card-number": true, error: @state.ccError}
expirationFieldClasses = {field: true, "expiration": true, error: @state.expiryError}
cvvFieldClasses = {field: true, "card-number": true, error: @state.cvvError}
inUSClasses = {field: true, "billing-in-us": true, error: @state.billingInUSError}
zipCodeClasses = {field: true, "zip-code": true, error: @state.zipCodeError}
nameClasses= {field: true, "name": true, error: @state.nameError}
formClasses= {stored: @reuseStoredCard()}
leftColumnClasses = {column: true, 'column-left': true, stored: @reuseStoredCard()}
rightColumnClasses = {column: true, 'column-right': true, stored: @reuseStoredCard()}
if @state.uncollectables.length > 0
uncollectable = @state.uncollectables[0]
uncollectableMessage = `<div className="uncollectable-msg">A charge for your music lesson with {uncollectable.teacher.name} failed. Please update your credit card information immediately so that we can pay the instructor. If you have called your credit card provider and believe there should be no problem with your card, please email us at <a href="mailto:support@jamkazam.com">support@jamkazam.com</a> so that we can figure out what's gone wrong. Thank you!</div>`
if @state.user?['has_stored_credit_card?'] && @state.uncollectables.length == 0
if @state.userWantsUpdateCC
header = 'Please update your billing address and payment information below.'
updateCardAction = `<a className={classNames(updateCardClassNames)} onClick={this.onLockPaymentInfo}>NEVERMIND</a>`
actions = `<div className="actions">
<a className={classNames(backClassNames)} onClick={this.onBack}>BACK</a>
{updateCardAction}
<a className={classNames(submitClassNames)} onClick={this.onSubmit}>SUBMIT CARD INFORMATION</a>
</div>`
else
header = 'You have already entered a credit card in JamKazam.'
updateCardAction = `<a className={classNames(updateCardClassNames)} onClick={this.onUnlockPaymentInfo}>I'D LIKE TO UPDATE MY PAYMENT INFO</a>`
actions = `<div className="actions">
<a className={classNames(backClassNames)} onClick={this.onBack}>BACK</a>
{updateCardAction}
</div>`
else
header = 'Please enter your billing address and payment information below.'
actions = `<div className="actions">
<a className={classNames(backClassNames)} onClick={this.onBack}>BACK</a><a
className={classNames(submitClassNames)} onClick={this.onSubmit}>SUBMIT CARD INFORMATION</a>
</div>`
if @state.shouldShowName && @state.user?.name?
username = @state.user?.name
nameField =
`<div className={classNames(nameClasses)}>
<label>Name:</label>
<input id="set-user-on-card" disabled={disabled} type="text" name="name" className="name" defaultValue={username}></input>
</div>`
`<div>
<div className={classNames(leftColumnClasses)}>
{uncollectableMessage}
<div className="paymethod-header">{header}</div>
<form autoComplete="on" onSubmit={this.onSubmit} className={classNames(formClasses)}>
{nameField}
<div className={classNames(cardNumberFieldClasses)}>
<label>Card Number:</label>
<input placeholder="1234 5678 9123 4567" type="tel" autoComplete="cc-number" disabled={disabled}
type="text" name="card-number" className="card-number"></input>
</div>
<div className={classNames(expirationFieldClasses)}>
<label>Expiration Date:</label>
<input placeholder="MM / YY" autoComplete="cc-expiry" disabled={disabled} type="text" name="expiration"
className="expiration"></input>
</div>
<div className={classNames(cvvFieldClasses)}>
<label>CVV:</label>
<input autoComplete="off" disabled={disabled} type="text" name="cvv" className="cvv"></input>
</div>
<div className={classNames(zipCodeClasses)}>
<label>Zip Code</label>
<input autoComplete="off" disabled={disabled || !this.state.billingInUS} type="text" name="zip"
className="zip"></input>
</div>
<div className={classNames(inUSClasses)}>
<label>Billing Address<br/>is in the U.S.</label>
<input type="checkbox" name="billing-address-in-us" className="billing-address-in-us"
value={this.state.billingInUS}/>
</div>
<input style={{'display':'none'}} type="submit" name="submit"/>
</form>
{actions}
</div>
<br className="clearall"/>
</div>`
paymentsToJamKazam: () ->
rows = []
uncollectables = []
for uncollectable in @state.uncollectables
date = context.JK.formatDate(uncollectable.last_billed_at_date, true)
paymentMethod = 'Credit Card'
amt = uncollectable.expected_price_in_cents
displayAmount = ' $' + (amt/100).toFixed(2)
if uncollectable['is_card_declined?']
reason = 'card declined'
else if uncollectable['is_card_expired?']
reason = 'card expired'
else
reason = 'charge fail'
row =
`<tr>
<td>{date}</td>
<td className="capitalize">{paymentMethod}</td>
<td>{uncollectable.description}</td>
<td className="capitalize">{reason}</td>
<td>{displayAmount}</td>
</tr>`
uncollectables.push(row)
if uncollectables.length > 0
uncollectableTable = `
<div className="uncollectables">
<div className="uncollectable-msg">You have unpaid lessons, which are listed immediately below. <a onClick={this.selectionMade.bind(this, this.TILE_PAYMENT_METHOD)}>Click here</a> to update your credit card info.</div>
<div className="table-header">Unpaid Lessons</div>
<table className="payment-table unpaid">
<thead>
<tr>
<th>CHARGED AT</th>
<th>METHOD</th>
<th>DESCRIPTION</th>
<th>REASON</th>
<th>AMOUNT</th>
</tr>
</thead>
<tbody>
{uncollectables}
</tbody>
</table>
<div className="table-header second">Payments</div>
</div>`
for paymentHistory in @getCurrentList()
paymentMethod = 'Credit Card'
if paymentHistory.sale?
@ -317,6 +497,7 @@ profileUtils = context.JK.ProfileUtils
rows.push(row)
`<div>
{uncollectableTable}
<table className="payment-table">
<thead>
<tr>
@ -342,13 +523,14 @@ profileUtils = context.JK.ProfileUtils
selectionMade: (selection, e) ->
e.preventDefault()
@getUncollectables()
@setState({selected: selection})
activeTile: (selected = this.state.selected) ->
if selected?
selected
else
@tiles()[0]
@tiles()[-1]
createTileLink: (i, tile) ->
if this.state.selected?
@ -455,4 +637,153 @@ profileUtils = context.JK.ProfileUtils
handled = true
@setState({updateErrors: errors})
onSubmit: (e) ->
@resetErrors()
e.preventDefault()
if !window.Stripe?
@app.layout.notify({
title: 'Payment System Not Loaded',
text: "Please refresh this page and try to enter your info again. Sorry for the inconvenience!"
})
else
ccNumber = @root.find('input.card-number').val()
expiration = @root.find('input.expiration').val()
cvv = @root.find('input.cvv').val()
inUS = @root.find('input.billing-address-in-us').is(':checked')
zip = @root.find('input.zip').val()
error = false
if @state.shouldShowName
name = @root.find('#set-user-on-card').val()
if name.indexOf('Anonymous') > -1
@setState({nameError: true})
error = true
if !$.payment.validateCardNumber(ccNumber)
@setState({ccError: true})
error = true
bits = expiration.split('/')
if bits.length == 2
month = bits[0].trim();
year = bits[1].trim()
month = new Number(month)
year = new Number(year)
if year < 2000
year += 2000
if !$.payment.validateCardExpiry(month, year)
@setState({expiryError: true})
error = true
else
@setState({expiryError: true})
error = true
cardType = $.payment.cardType(ccNumber)
if !$.payment.validateCardCVC(cvv, cardType)
@setState({cvvError: true})
error = true
if inUS && (!zip? || zip == '')
@setState({zipCodeError: true})
if error
return
data = {
number: ccNumber,
cvc: cvv,
exp_month: month,
exp_year: year,
}
@setState({updating: true})
window.Stripe.card.createToken(data, (status, response) => (@stripeResponseHandler(status, response)));
stripeResponseHandler: (status, response) ->
console.log("stripe response", JSON.stringify(response))
if response.error
@setState({updating: false})
if response.error.code == "invalid_number"
@setState({ccError: true, cvvError: null, expiryError: null})
else if response.error.code == "invalid_cvc"
@setState({ccError: null, cvvError: true, expiryError: null})
else if response.error.code == "invalid_expiry_year" || response.error.code == "invalid_expiry_month"
@setState({ccError: null, cvvError: null, expiryError: true})
#@setState({userWantsUpdateCC: false})
#window.UserActions.refresh()
@storeCC(response.id)
storeCC: (token) ->
if this.state.billingInUS
zip = @root.find('input.zip').val()
data = {
token: token,
zip: zip,
test_drive: false,
normal: false
}
if @state.shouldShowName
data.name = @root.find('#set-user-on-card').val()
@setState({updating: true})
rest.submitStripe(data).done((response) => @stripeSubmitted(response)).fail((jqXHR) => @stripeSubmitFailure(jqXHR))
stripeSubmitted: (response) ->
@setState({updating: false})
logger.debug("stripe submitted: " + JSON.stringify(response))
@setState({userWantsUpdateCC: false})
#if @state.shouldShowName
window.UserActions.refresh()
if response.uncollectables
context.JK.Banner.showAlert('Credit Card Updated', 'Than you. Your credit card info has been updated.<br/><br/>We will try to bill any unpaid lessons within the next hour, and an email will be sent at that time.')
else
@app.layout.notify({title: 'Credit Card Updated', text: 'Your credit card info has been updated.'})
stripeSubmitFailure: (jqXHR) ->
@setState({updating: false})
handled = false
if jqXHR.status == 422
errors = JSON.parse(jqXHR.responseText)
if errors.errors.name?
@setState({name: errors.errors.name[0]})
handled = true
else if errors.errors.user?
@app.layout.notify({title: "Can't Update Credit Card", text: "You " + errors.errors.user[0] + '.' })
handled = true
if !handled
@app.notifyServerError(jqXHR, 'Credit Card Not Stored')
onUnlockPaymentInfo: (e) ->
e.preventDefault()
@setState({userWantsUpdateCC: true})
onLockPaymentInfo: (e) ->
e.preventDefault()
@setState({userWantsUpdateCC: false})
reuseStoredCard: () ->
!@state.userWantsUpdateCC && @state.user?['has_stored_credit_card?'] && @state.uncollectables.length == 0
})

View File

@ -336,6 +336,7 @@ UserStore = context.UserStore
window.location = "/client#/teachers/search"
stripeSubmitFailure: (jqXHR) ->
@setState({updating: false})
handled = false
if jqXHR.status == 422
errors = JSON.parse(jqXHR.responseText)
@ -369,6 +370,7 @@ UserStore = context.UserStore
return booked_price.toFixed(2)
else
return '??'
render: () ->
disabled = @state.updating || @reuseStoredCard()

View File

@ -764,6 +764,10 @@
// returns Fri May 20, 2013
context.JK.formatDate = function (dateString, suppressDay) {
if (!dateString) {
return 'N/A'
}
var date = new Date(dateString);
return (suppressDay ? '' : (days[date.getDay()] + ' ')) + months[date.getMonth()] + ' ' + context.JK.padString(date.getDate(), 2) + ', ' + date.getFullYear();
}

View File

@ -8,6 +8,17 @@
overflow:auto;
}
.payment-table.unpaid {
margin-bottom:30px;
}
.table-header {
margin:0 0 10px;
color:white;
font-weight:bold;
&.second {
margin:20px 0 10px;
}
}
.content-body {
padding-top:29px;
height:100%;
@ -115,6 +126,52 @@
@include border-box_sizing;
width: 100%;
}
label {
display:inline-block;
}
select {
display:inline-block;
}
form {
&.stored {
display:none;
}
}
input {
display:inline-block;
width: calc(100% - 150px);
@include border_box_sizing;
max-width:200px;
}
.field {
position:relative;
display:block;
margin-top:15px;
margin-bottom:25px;
label {
width:150px;
}
}
.uncollectable-msg {
background-color:black;
color:white;
padding:20px;
margin:20px 0;
}
.paymethod-header {
margin:20px 0;
}
.column-left {
margin:20px 0 20px 21px;
}
.actions {
margin-left:-5px;
}
}

View File

@ -1,7 +1,7 @@
class ApiLessonSessionsController < ApiController
before_filter :api_signed_in_user
before_filter :lookup_lesson, except: [:index]
before_filter :lookup_lesson, except: [:index, :uncollectable]
before_filter :is_teacher, only: [:accept]
before_filter :is_student, only: []
respond_to :json
@ -111,6 +111,9 @@ class ApiLessonSessionsController < ApiController
render :json => {}, :status => 200
end
def uncollectable
@lesson_payment_charges = current_user.uncollectables
end
private

View File

@ -15,6 +15,7 @@ class ApiStripeController < ApiController
@test_drive = data[:test_drive]
@normal = data[:normal]
@lesson_package_type = data[:lesson_package_type]
@uncollectables = data[:uncollectables]
end
end

View File

@ -0,0 +1,7 @@
object @lesson_payment_charges
attributes :id, :description, :expected_price_in_cents, :is_card_declined?, :is_card_expired?, :last_billed_at_date
child(:teacher => :teacher) {
attributes :name
}

View File

@ -26,4 +26,9 @@ if @lesson_package_type
end
end
if @uncollectables
node :uncollectables do |lesson|
true
end
end

View File

@ -691,6 +691,7 @@ SampleApp::Application.routes.draw do
match '/lesson_bookings/unprocessed' => 'api_lesson_bookings#unprocessed', :via => :get
match '/lesson_bookings/unprocessed_or_intent' => 'api_lesson_bookings#unprocessed_or_intent', :via => :get
match '/lesson_sessions/uncollectable' => 'api_lesson_sessions#uncollectable', :via => :get
match '/lesson_sessions/:id' => 'api_lesson_sessions#show', :via => :get
match '/lesson_sessions/:id/update_unread_messages' => 'api_lesson_sessions#update_unread_messages', :via => :post
match '/lesson_sessions/:id/start_time' => 'api_lesson_sessions#start_time', :via => :post

View File

@ -980,8 +980,13 @@ FactoryGirl.define do
factory :teacher_payment_charge, parent: :charge, class: 'JamRuby::TeacherPaymentCharge' do
type 'JamRuby::TeacherPaymentCharge'
association :user, factory: :user
end
factory :lesson_payment_charge, parent: :charge, class: 'JamRuby::LessonPaymentCharge' do
type 'JamRuby::LessonPaymentCharge'
association :user, factory: :user
end
factory :teacher_payment, class: 'JamRuby::TeacherPayment' do
association :teacher, factory: :teacher_user

View File

@ -0,0 +1,108 @@
require 'spec_helper'
describe "Account Payment", :js => true, :type => :feature, :capybara_feature => true do
subject { page }
let(:user) { FactoryGirl.create(:user, traditional_band: true,paid_sessions: true, paid_sessions_hourly_rate: 1, paid_sessions_daily_rate:1 ) }
let(:jam_track) {FactoryGirl.create(:jam_track)}
before(:each) do
JamTrackRight.delete_all
JamTrack.delete_all
AffiliateQuarterlyPayment.delete_all
AffiliateMonthlyPayment.delete_all
AffiliateTrafficTotal.delete_all
UserMailer.deliveries.clear
emulate_client
sign_in_poltergeist user
visit "/client#/account"
find('div.account-mid.identity')
end
describe "payment history" do
it "show 1 sale" do
sale = Sale.create_jam_track_sale(user)
shopping_cart = ShoppingCart.create(user, jam_track)
sale_line_item = SaleLineItem.create_from_shopping_cart(sale, shopping_cart, nil, 'some_adjustment_uuid', nil)
visit "/client#/account"
find('.account-mid.payments', text: 'You have made 1 purchase.')
find("#account-payment-history-link").trigger(:click)
find('.account-header', text: 'payment history:')
find('table tr td', text: '$0.00') # 1st purchase is free
find('.profile-tile.student a', text: 'payment method').trigger(:click)
fill_in 'card-number', with: '4111111111111111'
fill_in 'expiration', with: '11/2016'
fill_in 'cvv', with: '111'
fill_in 'zip', with: '78759'
find('.purchase-btn').trigger(:click)
find('a.update-btn', text: "I'D LIKE TO UPDATE MY PAYMENT INFO").trigger(:click)
user.reload
user.stripe_customer_id.should_not be_nil
user.stripe_token.should_not be_nil
original_token = user.stripe_token
fill_in 'card-number', with: '4111111111111111'
fill_in 'expiration', with: '11/2016'
fill_in 'cvv', with: '111'
fill_in 'zip', with: '78759'
find('.purchase-btn').trigger(:click)
find('a.update-btn', text: "I'D LIKE TO UPDATE MY PAYMENT INFO").trigger(:click)
user.reload
original_token.should_not eql user.stripe_token
end
end
it "handles unpaid lessons" do
teacher = FactoryGirl.create(:teacher_user)
lesson_session = normal_lesson(user, teacher)
lesson_session.lesson_payment_charge.user.should eql user
lesson_session.lesson_payment_charge.billing_attempts = 1
lesson_session.lesson_payment_charge.save!
uncollectables = user.uncollectables
uncollectables.count.should eql 1
visit "/client#/account"
find('.account-mid.payments', text: 'You have made no purchases.')
sleep 2
find("#account-payment-history-link").trigger(:click)
find('.account-header', text: 'payment history:')
find('.uncollectable-msg', text: 'You have unpaid lessons')
find('.uncollectable-msg a').trigger(:click)
fill_in 'card-number', with: '4111111111111111'
fill_in 'expiration', with: '11/2016'
fill_in 'cvv', with: '111'
fill_in 'zip', with: '78759'
find('.purchase-btn').trigger(:click)
find('#banner .dialog-inner', text: 'Your credit card info has been updated')
# dismiss banner
find('a.button-orange', text:'CLOSE').trigger(:click)
user.reload
user.stripe_customer_id.should_not be_nil
user.stripe_token.should_not be_nil
end
end

View File

@ -148,27 +148,6 @@ describe "Account", :js => true, :type => :feature, :capybara_feature => true do
end
end
describe "payment history" do
it "show 1 sale" do
sale = Sale.create_jam_track_sale(user)
shopping_cart = ShoppingCart.create(user, jam_track)
sale_line_item = SaleLineItem.create_from_shopping_cart(sale, shopping_cart, nil, 'some_adjustment_uuid', nil)
visit "/client#/account"
find('.account-mid.payments', text: 'You have made 1 purchase.')
find("#account-payment-history-link").trigger(:click)
find('h2', text: 'payment history:')
find('table tr td', text: '$0.00') # 1st purchase is free
end
end
describe "sessions" do
before(:each) do

View File

@ -115,3 +115,72 @@ def testdrive_lesson(user, teacher, slots = nil)
lesson
end
def normal_lesson(user, teacher, slots = nil)
if slots.nil?
slots = []
slots << FactoryGirl.build(:lesson_booking_slot_single)
slots << FactoryGirl.build(:lesson_booking_slot_single)
end
if user.stored_credit_card == false
user.stored_credit_card = true
user.save!
end
booking = LessonBooking.book_normal(user, teacher, slots, "Hey I've heard of you before.", false, LessonBooking::PAYMENT_STYLE_SINGLE, 60)
# puts "NORMAL BOOKING #{booking.errors.inspect}"
booking.errors.any?.should be_false
lesson = booking.lesson_sessions[0]
booking.card_presumed_ok.should be_true
#if user.most_recent_test_drive_purchase.nil?
# LessonPackagePurchase.create(user, booking, LessonPackageType.test_drive_4)
#end
lesson.accept({message: 'Yeah I got this', slot: slots[0]})
lesson.errors.any?.should be_false
lesson.reload
lesson.slot.should eql slots[0]
lesson.status.should eql LessonSession::STATUS_APPROVED
lesson.music_session.should_not be_nil
lesson
end
def monthly_lesson(user, teacher, slots = nil)
if slots.nil?
slots = []
slots << FactoryGirl.build(:lesson_booking_slot_recurring)
slots << FactoryGirl.build(:lesson_booking_slot_recurring)
end
if user.stored_credit_card == false
user.stored_credit_card = true
user.save!
end
booking = LessonBooking.book_normal(user, teacher, slots, "Hey I've heard of you before.", true, LessonBooking::PAYMENT_STYLE_MONTHLY, 60)
# puts "NORMAL BOOKING #{booking.errors.inspect}"
booking.errors.any?.should be_false
lesson = booking.lesson_sessions[0]
booking.card_presumed_ok.should be_true
#if user.most_recent_test_drive_purchase.nil?
# LessonPackagePurchase.create(user, booking, LessonPackageType.test_drive_4)
#end
lesson.accept({message: 'Yeah I got this', slot: slots[0]})
lesson.errors.any?.should be_false
lesson.reload
lesson.slot.should eql slots[0]
lesson.status.should eql LessonSession::STATUS_APPROVED
lesson.music_session.should_not be_nil
lesson
end