This commit is contained in:
Seth Call 2016-02-17 15:44:57 -06:00
parent f42b390c50
commit f6d5b520fb
67 changed files with 4872 additions and 330 deletions

View File

@ -19,16 +19,39 @@ CREATE TABLE lesson_package_purchases (
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE lesson_bookings (
id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id VARCHAR(64) REFERENCES users(id) NOT NULL,
lesson_type VARCHAR(64) NOT NULL,
recurring BOOLEAN NOT NULL,
lesson_length INTEGER NOT NULL,
payment_style VARCHAR(64) NOT NULL,
description VARCHAR,
teacher_id VARCHAR(64) REFERENCES users(id) NOT NULL,
card_presumed_ok BOOLEAN NOT NULL DEFAULT FALSE,
sent_notices BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE lesson_sessions (
id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4(),
music_session_id VARCHAR(64) REFERENCES music_sessions(id) NOT NULL,
lesson_type VARCHAR(64) NOT NULL,
teacher_id VARCHAR(64) REFERENCES teachers(id) NOT NULL,
teacher_id VARCHAR(64) REFERENCES users(id) NOT NULL,
lesson_package_purchase_id VARCHAR(64) REFERENCES lesson_package_purchases(id),
lesson_booking_id VARCHAR(64) REFERENCES lesson_bookings(id),
duration INTEGER NOT NULL,
price NUMERIC(8,2) NOT NULL,
teacher_complete BOOLEAN DEFAULT FALSE NOT NULL,
student_complete BOOLEAN DEFAULT FALSE NOT NULL,
student_canceled BOOLEAN DEFAULT FALSE NOT NULL,
teacher_canceled BOOLEAN DEFAULT FALSE NOT NULL,
student_canceled_at TIMESTAMP,
teacher_canceled_at TIMESTAMP,
student_canceled_reason VARCHAR,
teacher_canceled_reason VARCHAR,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@ -42,19 +65,6 @@ INSERT INTO lesson_package_types (id, name, description, package_type, price) VA
INSERT INTO lesson_package_types (id, name, description, package_type, price) VALUES ('single-free', 'Free Lesson', 'A free, single lesson.', 'single-free', 0.00);
INSERT INTO lesson_package_types (id, name, description, package_type, price) VALUES ('test-drive', 'Test Drive', 'Four reduced-price lessons which you can use to find that ideal teacher.', 'test-drive', 49.99);
CREATE TABLE lesson_bookings (
id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id VARCHAR(64) REFERENCES users(id) NOT NULL,
lesson_type VARCHAR(64) NOT NULL,
recurring BOOLEAN NOT NULL,
lesson_length INTEGER NOT NULL,
payment_style VARCHAR(64) NOT NULL,
description VARCHAR,
teacher_id VARCHAR(64) REFERENCES users(id) NOT NULL,
card_presumed_ok BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE lesson_booking_slots (
id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4(),
@ -64,6 +74,7 @@ CREATE TABLE lesson_booking_slots (
day_of_week INTEGER,
hour INTEGER,
minute INTEGER,
timezone VARCHAR NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@ -73,3 +84,7 @@ ALTER TABLE chat_messages ADD COLUMN lesson_booking_id VARCHAR(64) REFERENCES le
ALTER TABLE users ADD COLUMN remaining_free_lessons INTEGER NOT NULL DEFAULT 1;
ALTER TABLE users ADD COLUMN stored_credit_card BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users ADD COLUMN remaining_test_drives INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN stripe_token VARCHAR(200);
ALTER TABLE users ADD COLUMN stripe_customer_id VARCHAR(200);
ALTER TABLE users ADD COLUMN stripe_zip_code VARCHAR(200);
ALTER TABLE sales ADD COLUMN stripe_charge_id VARCHAR(200);

View File

@ -52,6 +52,8 @@ gem 'sanitize'
gem 'influxdb', '0.1.8'
gem 'recurly'
gem 'sendgrid_toolkit', '>= 1.1.1'
gem 'stripe'
gem 'zip-codes'
group :test do
gem 'simplecov', '~> 0.7.1'

View File

@ -21,6 +21,8 @@ require 'rest-client'
require 'zip'
require 'csv'
require 'tzinfo'
require 'stripe'
require 'zip-codes'
require "jam_ruby/constants/limits"
require "jam_ruby/constants/notification_types"

View File

@ -621,6 +621,46 @@
end
end
def student_lesson_request(lesson_booking)
email = lesson_booking.user.email
subject = "You have sent a lesson request to #{lesson_booking.teacher.name}!"
unique_args = {:type => "student_lesson_request"}
@sender = lesson_booking.teacher
@lesson_booking = lesson_booking
sendgrid_category "Notification"
sendgrid_unique_args :type => unique_args[:type]
sendgrid_recipients([email])
sendgrid_substitute('@USERID', [lesson_booking.user.id])
mail(:to => email, :subject => subject) do |format|
format.text
format.html { render :layout => "from_user_mailer" }
end
end
def teacher_lesson_request(lesson_booking)
email = lesson_booking.teacher.email
subject = "You have received a lesson request through JamKazam!"
unique_args = {:type => "teacher_lesson_request"}
@sender = lesson_booking.user
@lesson_booking = lesson_booking
sendgrid_category "Notification"
sendgrid_unique_args :type => unique_args[:type]
sendgrid_recipients([email])
sendgrid_substitute('@USERID', [lesson_booking.teacher.id])
mail(:to => email, :subject => subject) do |format|
format.text
format.html { render :layout => "from_user_mailer" }
end
end
# def send_notification(email, subject, msg, unique_args)
# @body = msg
# sendgrid_category "Notification"

View File

@ -0,0 +1,11 @@
<% provide(:title, "Lesson Request sent to #{@sender.name}") %>
<% provide(:photo_url, @sender.resolved_photo_url) %>
<% content_for :note do %>
<p>You have requested a <%= @lesson_booking.display_type %> lesson. <br /><br/>Click the button below to see your lesson request. You will receive another email when the teacher accepts or reject the request.</p>
<p>
<a href="<%= @lesson_booking.home_url %>" style="margin: 8px 0 0 0;background-color: #ed3618;border: solid 1px #F27861;padding: 3px 10px;font-size: 12px;font-weight: 300;cursor: pointer;color: #FC9;text-decoration: none;line-height: 12px;text-align: center;">VIEW LESSON REQUEST</a>
</p>
<% end %>

View File

@ -0,0 +1,3 @@
You have requested a lesson from <%= @sender.name %>.
To see this lesson request, click here: <%= @lesson_booking.home_url %>

View File

@ -0,0 +1,11 @@
<% provide(:title, "Lesson Request from #{@sender.name}") %>
<% provide(:photo_url, @sender.resolved_photo_url) %>
<% content_for :note do %>
<p>This student has requested to schedule a <%= @lesson_booking.display_type %> lesson. <br /><br/>Click the button below to get more information and to respond to this lesson request. You must respond to this lesson request promptly, or it will be cancelled, thank you!</p>
<p>
<a href="<%= @lesson_booking.home_url %>" style="margin: 8px 0 0 0;background-color: #ed3618;border: solid 1px #F27861;padding: 3px 10px;font-size: 12px;font-weight: 300;cursor: pointer;color: #FC9;text-decoration: none;line-height: 12px;text-align: center;">VIEW LESSON REQUEST</a>
</p>
<% end %>

View File

@ -0,0 +1,3 @@
<%= @sender.name %> has requested a lesson.
To see this lesson request, click here: <%= @lesson_booking.home_url %>

View File

@ -82,8 +82,32 @@ module JamRuby
return if self.ignore # doing any writes in a test environment cause annoying puts to occur
if @client && data && data.length > 0
data['host'] = @host
data['time'] = Time.now.to_i
if data.has_key?('values') || data.has_key?(:values)
@client.write_point(name, data)
data['timestamp'] = Time.now.to_i
tags = data['tags']
key = 'tags' if tags
tags ||= data[:tags]
key = :tags if key.nil?
tags ||= {}
key = :tags if key.nil?
tags['host'] = @host
data[key] = tags
else
tags = {}
values = {}
for k,v in data
if v.is_a?(String)
tags[k] = v
else
values[k] = v
end
end
data = {tags: tags, values: values}
end
@client.write_point(name, data)
end
end

View File

@ -4,6 +4,13 @@ module JamRuby
@@log = Logging.logger[LessonBooking]
STATUS_REQUESTED = 'requested'
STATUS_CANCELED = 'canceled'
STATUS_MISSED = 'missed'
STATUS_COMPLETED = 'completed'
STATUS_TYPES = [STATUS_REQUESTED, STATUS_CANCELED, STATUS_MISSED, STATUS_COMPLETED]
LESSON_TYPE_FREE = 'single-free'
LESSON_TYPE_TEST_DRIVE = 'test-drive'
LESSON_TYPE_PAID = 'paid'
@ -20,11 +27,15 @@ module JamRuby
belongs_to :user, class_name: "JamRuby::User"
belongs_to :teacher, class_name: "JamRuby::User"
has_many :lesson_booking_slots, class_name: "JamRuby::LessonBookingSlot"
has_many :lesson_sessions, class_name: "JamRuby::LessonSession"
validates :user, presence: true
validates :teacher, presence: true
validates :lesson_type, presence: true, inclusion: {in: LESSON_TYPES}
validates :status, presence: true, inclusion: {in: STATUS_TYPES}
validates :recurring, inclusion: {in: [true, false]}
validates :sent_notices, inclusion: {in: [true, false]}
validates :card_presumed_ok, inclusion: {in: [true, false]}
validates :lesson_length, presence: true, inclusion: {in: [30, 45, 60, 90, 120]}
validates :payment_style, inclusion: {in: PAYMENT_STYLES}
validates :description, no_profanity: true, length: {minimum: 10, maximum: 20000}, presence: true
@ -35,6 +46,40 @@ module JamRuby
validate :validate_lesson_length
validate :validate_payment_style
after_create :after_create
def after_create
if card_presumed_ok && !sent_notices
send_notices
end
end
def send_notices
UserMailer.student_lesson_request(self).deliver
UserMailer.teacher_lesson_request(self).deliver
LessonBooking.where(id: id).update_all(sent_notices: true)
end
def display_type
if is_single_free?
"Free"
elsif is_test_drive?
"TestDrive"
elsif is_normal?
"Lesson Purchase"
end
end
# determine the price of this booking based on what the user wants, and the teacher's pricing
def booked_price
if is_single_free?
0
elsif is_test_drive?
LessonPackageType.test_drive.price
elsif is_normal?
teacher.teacher.booking_price(lesson_length, payment_style != PAYMENT_STYLE_MONTHLY)
end
end
def is_single_free?
lesson_type == LESSON_TYPE_FREE
end
@ -47,6 +92,13 @@ module JamRuby
lesson_type == LESSON_TYPE_PAID
end
def card_approved
LessonBooking.where(id: id).update_all(card_presumed_ok: true)
if !sent_notices
send_notices
end
end
def validate_user
if is_single_free?
if !user.has_free_lessons?
@ -124,6 +176,7 @@ module JamRuby
lesson_booking = LessonBooking.new
lesson_booking.user = user
lesson_booking.card_presumed_ok = user.has_stored_credit_card?
lesson_booking.sent_notices = false
lesson_booking.teacher = teacher
lesson_booking.lesson_type = lesson_type
lesson_booking.lesson_booking_slots = lesson_booking_slots
@ -131,6 +184,7 @@ module JamRuby
lesson_booking.lesson_length = lesson_length
lesson_booking.payment_style = payment_style
lesson_booking.description = description
lesson_booking.status = STATUS_REQUESTED
if lesson_booking.save
@ -152,8 +206,15 @@ module JamRuby
end
def self.unprocessed(current_user)
LessonBooking.where(user_id: current_user.id).where(card_presumed_ok: false).first
LessonBooking.where(user_id: current_user.id).where(card_presumed_ok: false)
end
def home_url
APP_CONFIG.external_root_url + "/client#/jamclass"
end
def web_url
APP_CONFIG.external_root_url + "/client#/jamclass/lesson-request/" + id
end
end
end

View File

@ -16,6 +16,7 @@ module JamRuby
validates :day_of_week, numericality: {only_integer: true}, allow_blank: true # 0 = sunday - 6 = saturday
validates :hour, numericality: {only_integer: true}
validates :minute, numericality: {only_integer: true}
validates :timezone, presence: true
validate :validate_slot_type

View File

@ -9,16 +9,40 @@ module JamRuby
end
# who purchased the lesson package?
belongs_to :user, class_name: "JamRuby::User"
belongs_to :user, class_name: "JamRuby::User", :foreign_key => "user_id", inverse_of: :lesson_purchases
belongs_to :lesson_package_type, class_name: "JamRuby::LessonPackageType"
belongs_to :teacher, class_name: "JamRuby::Teacher"
def self.create(user, lesson_package_type)
validates :lesson_package_type, presence: true
validates :price, presence: true
after_save :after_save
def after_save
if self.lesson_package_type.is_test_drive?
new_test_drives = user.remaining_test_drives + 4
User.where(id:user.id).update_all(remaining_test_drives: new_test_drives)
user.remaining_test_drives = user.remaining_test_drives + 4
end
end
def self.create(user, lesson_package_type, lesson_booking)
purchase = LessonPackagePurchase.new
purchase.user = user
purchase.lesson_package_type = lesson_package_type
purchase.price = lesson_package_type.booked_price(lesson_booking)
purchase.save
purchase
end
def price_in_cents
(price * 100).to_i
end
def description(lesson_booking)
lesson_package_type.description(lesson_booking)
end
end
end

View File

@ -34,6 +34,30 @@ module JamRuby
LessonPackageType.find(SINGLE)
end
def booked_price(lesson_booking)
if is_single_free?
0
elsif is_test_drive?
LessonPackageType.test_drive.price
elsif is_normal?
lesson_booking.teacher.teacher.booking_price(lesson_booking.lesson_length, lesson_booking.payment_style == LessonBooking::PAYMENT_STYLE_SINGLE)
end
end
def description(lesson_booking)
if is_single_free?
"Single Free Lesson"
elsif is_test_drive?
"Test Drive"
elsif is_normal?
if recurring
"Recurring #{lesson_booking.payment_style == PAYMENT_STYLE_WEEKLY ? "Weekly" : "Monthly"} #{lesson_booking.lesson_length}m"
else
"Single #{lesson_booking.lesson_length}m lesson"
end
end
end
def is_single_free?
id == SINGLE_FREE
end

View File

@ -10,14 +10,50 @@ module JamRuby
LESSON_TYPES = [LESSON_TYPE_SINGLE, LESSON_TYPE_SINGLE_FREE, LESSON_TYPE_TEST_DRIVE]
belongs_to :music_session, class_name: "JamRuby::MusicSession"
belongs_to :teacher, class_name: "JamRuby::Teacher"
belongs_to :teacher, class_name: "JamRuby::User"
belongs_to :lesson_package_purchase, class_name: "JamRuby::LessonPackagePurchase"
belongs_to :lesson_booking, class_name: "JamRuby::LessonBooking"
validates :duration, presence: true, numericality: {only_integer: true}
validates :lesson_booking, presence: true
validates :lesson_type, inclusion: {in: LESSON_TYPES}
validates :price, presence: true
validates :teacher_complete, inclusion: {in: [true, false]}
validates :student_complete, inclusion: {in: [true, false]}
validates :teacher_cancelled, inclusion: {in: [true, false]}
validates :student_cancelled, inclusion: {in: [true, false]}
def self.index(user, params = {})
limit = params[:per_page]
limit ||= 100
limit = limit.to_i
query = LessonSession.joins(:music_session).joins(music_session: :creator)
query = query.includes([:teacher, :music_session])
query = query.order('music_sessions.scheduled_start DESC')
if params[:as_teacher]
query = query.where('lesson_sessions.teacher_id = ?', user.id)
else
query = query.where('music_sessions.user_id = ?', user.id)
end
current_page = params[:page].nil? ? 1 : params[:page].to_i
next_page = current_page + 1
# will_paginate gem
query = query.paginate(:page => current_page, :per_page => limit)
if query.length == 0 # no more results
{ query: query, next_page: nil}
elsif query.length < limit # no more results
{ query: query, next_page: nil}
else
{ query: query, next_page: next_page }
end
end
end
end

View File

@ -4,6 +4,7 @@ module JamRuby
class Sale < ActiveRecord::Base
JAMTRACK_SALE = 'jamtrack'
LESSON_SALE = 'lesson'
belongs_to :user, class_name: 'JamRuby::User'
has_many :sale_line_items, class_name: 'JamRuby::SaleLineItem'
@ -32,12 +33,12 @@ module JamRuby
# will_paginate gem
query = query.paginate(:page => current_page, :per_page => limit)
if query.length == 0 # no more results
{ query: query, next_page: nil}
elsif query.length < limit # no more results
{ query: query, next_page: nil}
if query.length == 0 # no more results
{query: query, next_page: nil}
elsif query.length < limit # no more results
{query: query, next_page: nil}
else
{ query: query, next_page: next_page }
{query: query, next_page: next_page}
end
end
@ -76,7 +77,7 @@ module JamRuby
def self.validateIOSReceipt(receipt)
# these are all 'in cents' (as painfully named to be very clear), and all expected to be integers
price_info = {subtotal_in_cents:nil, total_in_cents:nil, tax_in_cents:nil, currency: 'USD'}
price_info = {subtotal_in_cents: nil, total_in_cents: nil, tax_in_cents: nil, currency: 'USD'}
# communicate with Apple; populate price_info
@ -180,6 +181,75 @@ module JamRuby
free && non_free
end
def self.purchase_test_drive(current_user)
self.purchase_lesson(current_user, LessonPackageType.test_drive)
end
# this is easy to make generic, but right now, it just purchases lessons
def self.purchase_lesson(current_user, lesson_package_type)
sale = nil
# everything needs to go into a transaction! If anything goes wrong, we need to raise an exception to break it
Sale.transaction do
sale = create_lesson_sale(current_user)
if sale.valid?
price_info = charge_stripe_for_lesson(current_user, lesson_package_type)
SaleLineItem.create_from_lesson_package(current_user, sale, lesson_package_type)
# sale.source = 'stripe'
sale.recurly_subtotal_in_cents = price_info[:subtotal_in_cents]
sale.recurly_tax_in_cents = price_info[:tax_in_cents]
sale.recurly_total_in_cents = price_info[:total_in_cents]
sale.recurly_currency = price_info[:currency]
sale.stripe_charge_id = price_info[:charge_id]
sale.save
else
# should not get out of testing. This would be very rare (i.e., from a big regression). Sale is always valid at this point.
raise "invalid sale object"
end
end
sale
end
def self.charge_stripe_for_lesson(current_user, lesson_package_type, lesson_booking = nil)
current_user.sync_stripe_customer
purchase = LessonPackagePurchase.create(current_user, lesson_package_type, lesson_booking)
subtotal_in_cents = purchase.price_in_cents
tax_percent = 0
if current_user.stripe_zip_code
lookup =ZipCodes.identify(current_user.stripe_zip_code)
if lookup && lookup[:state_code] == 'TX'
tax_percent = 0.0825
end
end
tax_in_cents = (subtotal_in_cents * tax_percent).round
total_in_cents = subtotal_in_cents + tax_in_cents
charge = Stripe::Charge.create(
:amount => total_in_cents,
:currency => "usd",
:customer => current_user.stripe_customer_id,
:description => purchase.description(lesson_booking)
)
price_info = {}
price_info[:subtotal_in_cents] = subtotal_in_cents
price_info[:tax_in_cents] = tax_in_cents
price_info[:total_in_cents] = total_in_cents
price_info[:currency] = 'USD'
price_info[:charge_id] = charge.id
price_info
end
# this method will either return a valid sale, or throw a RecurlyClientError or ActiveRecord validation error (save! failed)
# it may return an nil sale if the JamTrack(s) specified by the shopping carts are already owned
def self.order_jam_tracks(current_user, shopping_carts)
@ -338,7 +408,6 @@ module JamRuby
end
if account
# ask the shopping cart to create the correct Recurly adjustment attributes for a JamTrack
adjustments = shopping_cart.create_adjustment_attributes(current_user)
@ -454,6 +523,10 @@ module JamRuby
sale_type == JAMTRACK_SALE
end
def is_lesson_sale?
sale_type == LESSON_SALE
end
def self.create_jam_track_sale(user)
sale = Sale.new
sale.user = user
@ -463,6 +536,15 @@ module JamRuby
sale
end
def self.create_lesson_sale(user)
sale = Sale.new
sale.user = user
sale.sale_type = LESSON_SALE # gift cards and jam tracks are sold with this type of sale
sale.order_total = 0
sale.save
sale
end
# this checks just jamtrack sales appropriately
def self.check_integrity_of_jam_track_sales
Sale.select([:total, :voided]).find_by_sql(

View File

@ -5,6 +5,7 @@ module JamRuby
JAMCLOUD = 'JamCloud'
JAMTRACK = 'JamTrack'
GIFTCARD = 'GiftCardType'
LESSON = 'LessonPackageType'
belongs_to :sale, class_name: 'JamRuby::Sale'
belongs_to :jam_track, class_name: 'JamRuby::JamTrack'
@ -13,7 +14,7 @@ module JamRuby
belongs_to :affiliate_referral, class_name: 'JamRuby::AffiliatePartner', foreign_key: :affiliate_referral_id
has_many :recurly_transactions, class_name: 'JamRuby::RecurlyTransactionWebHook', inverse_of: :sale_line_item, foreign_key: 'subscription_id', primary_key: 'recurly_subscription_uuid'
validates :product_type, inclusion: {in: [JAMBLASTER, JAMCLOUD, JAMTRACK, GIFTCARD]}
validates :product_type, inclusion: {in: [JAMBLASTER, JAMCLOUD, JAMTRACK, GIFTCARD, LESSON]}
validates :unit_price, numericality: {only_integer: false}
validates :quantity, numericality: {only_integer: true}
validates :free, numericality: {only_integer: true}
@ -81,6 +82,14 @@ module JamRuby
line_item
end
# in a shopping-cart less world (ios purchase), let's reuse as much logic as possible
def self.create_from_lesson_package(current_user, sale, lesson_package_type)
shopping_cart = ShoppingCart.create(current_user, lesson_package_type, 1)
line_item = create_from_shopping_cart(sale, shopping_cart, nil, nil, nil)
shopping_cart.destroy
line_item
end
def self.create_from_shopping_cart(sale, shopping_cart, recurly_subscription_uuid, recurly_adjustment_uuid, recurly_adjustment_credit_uuid)
product_info = shopping_cart.product_info

View File

@ -115,6 +115,10 @@ module JamRuby
cart_type == GiftCardType::PRODUCT_TYPE
end
def is_lesson?
cart_type == LessonPackageType::PRODUCT_TYPE
end
# returns an array of adjustments for the shopping cart
def create_adjustment_attributes(current_user)
raise "not a jam track or gift card" unless is_jam_track? || is_gift_card?

View File

@ -235,6 +235,21 @@ module JamRuby
teacher
end
def booking_price(length, single)
price = nil
if single
price = self["price_per_lesson_#{lesson_length}_cents"]
else
price = self["price_per_month_#{lesson_length}_cents"]
end
if !price.nil?
price.to_i
else
price
end
end
def offer_pricing
unless prices_per_lesson.present? || prices_per_month.present?
errors.add(:offer_pricing, "Must choose to price per lesson or per month")

View File

@ -170,6 +170,9 @@ module JamRuby
has_many :jam_track_rights, :class_name => "JamRuby::JamTrackRight", :foreign_key => "user_id"
has_many :purchased_jam_tracks, :through => :jam_track_rights, :class_name => "JamRuby::JamTrack", :source => :jam_track, :order => :created_at
# lessons
has_many :lesson_purchases, :class_name => "JamRuby::LessonPackagePurchase", :foreign_key => "user_id", inverse_of: :user
# Shopping carts
has_many :shopping_carts, :class_name => "JamRuby::ShoppingCart"
@ -1841,6 +1844,62 @@ module JamRuby
remaining_test_drives > 0
end
def fetch_stripe_customer
Stripe::Customer.retrieve(stripe_customer_id)
end
# if the user already has a stripe customer, then keep it synced. otherwise create it
def sync_stripe_customer
if self.stripe_customer_id
# we already have a customer for this user; re-use it
customer = fetch_stripe_customer
if customer.email.nil? || customer.email.downcase != email.downcase
customer.email = email
customer.save
end
else
customer = Stripe::Customer.create(
:description => "JK ID: #{id}",
:source => stripe_token,
:email => email)
end
self.stripe_customer_id = customer.id
User.where(id: id).update_all(stripe_customer_id: customer.id)
customer
end
def card_approved(token, zip)
approved_lesson = nil
User.transaction do
self.stripe_token = token
self.stripe_zip_code = zip
customer = sync_stripe_customer
self.stripe_customer_id = customer.id
if self.save
# we can also 'unlock' any booked sessions that still need to be done so
LessonBooking.unprocessed(self).each do |lesson|
approved_lesson = lesson.card_approved
end
end
end
approved_lesson
end
def payment_update(params)
lesson = nil
test_drive = nil
User.transaction do
lesson = card_approved(params[:token], params[:zip])
if params[:test_drive]
test_drive = Sale.purchase_test_drive(self)
end
end
{lesson: lesson, test_drive: test_drive}
end
private
def create_remember_token
self.remember_token = SecureRandom.urlsafe_base64

View File

@ -95,6 +95,11 @@ FactoryGirl.define do
connection = FactoryGirl.create(:connection, :user => user, :music_session => active_music_session)
end
end
factory :teacher_user do
after(:create) do |user, evaluator|
teacher = FactoryGirl.create(:teacher, user: user)
end
end
end
factory :teacher, :class => JamRuby::Teacher do
@ -149,6 +154,9 @@ FactoryGirl.define do
end
factory :music_session, :class => JamRuby::MusicSession do
ignore do
student nil
end
sequence(:name) { |n| "Music Session #{n}" }
sequence(:description) { |n| "Music Session Description #{n}" }
fan_chat true
@ -910,6 +918,7 @@ FactoryGirl.define do
sequence(:sibling_key ) { |n| "sibling_key#{n}" }
end
factory :lesson_booking_slot, class: 'JamRuby::LessonBookingSlot' do
factory :lesson_booking_slot_single do
slot_type 'single'
@ -917,6 +926,7 @@ FactoryGirl.define do
day_of_week nil
hour 12
minute 30
timezone 'UTC'
end
factory :lesson_booking_slot_recurring do
@ -925,8 +935,47 @@ FactoryGirl.define do
day_of_week 0
hour 12
minute 30
timezone 'UTC'
end
end
factory :lesson_booking, class: 'JamRuby::LessonBooking' do
association :user, factory: :user
association :teacher, factory: :teacher_user
card_presumed_ok false
sent_notices false
recurring false
lesson_length 30
lesson_type JamRuby::LessonBooking::LESSON_TYPE_FREE
payment_style JamRuby::LessonBooking::PAYMENT_STYLE_ELSEWHERE
description "Oh my goodness!"
status JamRuby::LessonBooking::STATUS_REQUESTED
lesson_booking_slots [FactoryGirl.build(:lesson_booking_slot_single), FactoryGirl.build(:lesson_booking_slot_single)]
end
factory :lesson_package_purchase, class: "JamRuby::LessionPackagePurchase" do
lesson_package_type { JamRuby::LessonPackageType.single }
association :user, factory: :user
association :teacher, factory: :teacher
price 30.00
end
factory :lesson_session, class: 'JamRuby::LessonSession' do
ignore do
student nil
end
music_session {FactoryGirl.create(:music_session, creator: student)}
lesson_booking {FactoryGirl.create(:lesson_booking, user: student, teacher: teacher)}
association :teacher, factory: :teacher_user
lesson_type JamRuby::LessonSession::LESSON_TYPE_SINGLE
duration 30
price 49.99
#teacher_complete true
#student_complete true
end
factory :ip_blacklist, class: "JamRuby::IpBlacklist" do
remote_ip '1.1.1.1'

View File

@ -3,7 +3,7 @@ require 'spec_helper'
# collissions with teacher's schedule?
describe LessonBooking do
let(:user) {FactoryGirl.create(:user, stored_credit_card: true, remaining_free_lessons: 1, remaining_test_drives: 1)}
let(:user) {FactoryGirl.create(:user, stored_credit_card: false, remaining_free_lessons: 1, remaining_test_drives: 1)}
let(:teacher) {FactoryGirl.create(:teacher)}
let(:teacher_user) {teacher.user}
let(:lesson_booking_slot_single1) {FactoryGirl.build(:lesson_booking_slot_single)}
@ -33,6 +33,16 @@ describe LessonBooking do
user.reload
user.remaining_free_lessons.should eq 0
user.remaining_test_drives.should eq 1
booking.card_presumed_ok.should eq false
booking.sent_notices.should eq false
user.card_approved(create_stripe_token, '78759')
user.save!
booking.reload
booking.sent_notices.should eq true
booking.card_presumed_ok.should eq true
end
it "allows long message to flow through chat" do
@ -55,7 +65,7 @@ describe LessonBooking do
booking = LessonBooking.book_free(user, teacher_user, valid_single_slots, "Hey I've heard of you before.")
booking.errors.any?.should be true
booking.errors[:user].should eq ["has no remaining free lessons"]
booking.errors[:user].should eq ["have no remaining free lessons"]
ChatMessage.count.should eq 1
end
@ -65,8 +75,7 @@ describe LessonBooking do
user.save!
booking = LessonBooking.book_free(user, teacher_user, valid_single_slots, "Hey I've heard of you before.")
booking.errors.any?.should be true
booking.errors[:user].should eq ["has no credit card stored"]
booking.errors.any?.should be false
end
it "must have 2 lesson booking slots" do
@ -127,7 +136,7 @@ describe LessonBooking do
booking = LessonBooking.book_test_drive(user, teacher_user, valid_single_slots, "Hey I've heard of you before.")
booking.errors.any?.should be true
booking.errors[:user].should eq ["has no remaining test drives"]
booking.errors[:user].should eq ["have no remaining test drives"]
ChatMessage.count.should eq 1
end
@ -221,13 +230,14 @@ describe LessonBooking do
ChatMessage.count.should eq 2
end
it "prevents user without stored credit card" do
it "does not prevent user without a stored credit card" do
user.stored_credit_card = false
user.save!
booking = LessonBooking.book_normal(user, teacher_user, valid_recurring_slots, "Hey I've heard of you before.", true, LessonBooking::PAYMENT_STYLE_WEEKLY, 60)
booking.errors.any?.should be true
booking.errors[:user].should eq ["has no credit card stored"]
booking.errors.any?.should be false
booking.card_presumed_ok.should eq false
booking.sent_notices.should eq false
end

View File

@ -3,9 +3,10 @@ require 'spec_helper'
describe LessonPackagePurchase do
let(:user) {FactoryGirl.create(:user)}
let(:lesson_booking) {FactoryGirl.create(:lesson_booking)}
it "creates" do
purchase = LessonPackagePurchase.create(user, LessonPackageType.single_free)
purchase = LessonPackagePurchase.create(user, LessonPackageType.single_free, lesson_booking)
purchase.valid?.should be_true
end
end

View File

@ -0,0 +1,39 @@
require 'spec_helper'
describe LessonSession do
let(:user) {FactoryGirl.create(:user, stored_credit_card: false, remaining_free_lessons: 1, remaining_test_drives: 1)}
let(:teacher) {FactoryGirl.create(:teacher_user)}
let(:lesson_session) {FactoryGirl.create(:lesson_session, student: user, teacher: teacher)}
let(:lesson_session2) {FactoryGirl.create(:lesson_session, student: user, teacher: teacher)}
describe "index" do
it "finds single lesson as student" do
# just sanity check that the lesson_session Factory is doing what it should
lesson_session.music_session.creator.should eql lesson_session.lesson_booking.user
lesson_session.lesson_booking.teacher.should eql teacher
query = LessonSession.index(user)[:query]
query.length.should eq 1
# make sure some random nobody can see this lesson session
query = LessonSession.index(FactoryGirl.create(:user))[:query]
query.length.should eq 0
end
it "finds single lesson as teacher" do
# just sanity check that the lesson_session Factory is doing what it should
lesson_session.music_session.creator.should eql lesson_session.lesson_booking.user
lesson_session.lesson_booking.teacher.should eql teacher
query = LessonSession.index(teacher, {as_teacher: true})[:query]
query.length.should eq 1
# make sure some random nobody can see this lesson session
query = LessonSession.index(FactoryGirl.create(:user), {as_teacher: true})[:query]
query.length.should eq 0
end
end
end

View File

@ -567,6 +567,84 @@ describe Sale do
end
end
describe "purchase_test_drive" do
it "book single" do
end
it "book recurring, single" do
end
it "book recurring, monthly" do
end
it "can succeed" do
user.stripe_token = create_stripe_token
user.save!
sale = Sale.purchase_test_drive(user)
sale.reload
sale.stripe_charge_id.should_not be_nil
sale.recurly_tax_in_cents.should be 0
sale.recurly_total_in_cents.should eql 4999
sale.recurly_subtotal_in_cents.should eql 4999
sale.recurly_currency.should eql 'USD'
line_item = sale.sale_line_items[0]
line_item.quantity.should eql 1
line_item.product_type.should eql SaleLineItem::LESSON
line_item.product_id.should eq LessonPackageType.test_drive.id
user.reload
user.stripe_customer_id.should_not be nil
user.lesson_purchases.length.should eql 1
user.remaining_test_drives.should eql 4
lesson_purchase = user.lesson_purchases[0]
lesson_purchase.price.should eql 49.99
lesson_purchase.lesson_package_type.is_test_drive?.should eql true
customer = Stripe::Customer.retrieve(user.stripe_customer_id)
customer.email.should eql user.email
end
it "can succeed with tax" do
user.stripe_token = create_stripe_token
user.stripe_zip_code = '78759'
user.save!
sale = Sale.purchase_test_drive(user)
sale.reload
sale.stripe_charge_id.should_not be_nil
sale.recurly_tax_in_cents.should be (4999 * 0.0825).round
sale.recurly_total_in_cents.should eql 4999 + (4999 * 0.0825).round
sale.recurly_subtotal_in_cents.should eql 4999
sale.recurly_currency.should eql 'USD'
line_item = sale.sale_line_items[0]
line_item.quantity.should eql 1
line_item.product_type.should eql SaleLineItem::LESSON
line_item.product_id.should eq LessonPackageType.test_drive.id
user.reload
user.stripe_customer_id.should_not be nil
user.lesson_purchases.length.should eql 1
user.remaining_test_drives.should eql 4
lesson_purchase = user.lesson_purchases[0]
lesson_purchase.price.should eql 49.99
lesson_purchase.lesson_package_type.is_test_drive?.should eql true
customer = Stripe::Customer.retrieve(user.stripe_customer_id)
customer.email.should eql user.email
end
end
describe "check_integrity_of_jam_track_sales" do
let(:user) { FactoryGirl.create(:user) }

View File

@ -760,6 +760,37 @@ describe User do
end
end
describe "sync_stripe_customer" do
let(:user) { FactoryGirl.create(:user) }
let(:token1) { create_stripe_token }
let(:token2) { create_stripe_token(2018) }
# possible Stripe::InvalidRequestError
it "reuses user on card update" do
user.stripe_customer_id.should be_nil
user.payment_update({stripe_token: token1})
user.reload
user.stripe_customer_id.should_not be_nil
customer1 = user.stripe_customer_id
# let's change email address too
user.email = 'unique+1@jamkazam.com'
user.save!
token2.should_not eql token1
user.payment_update({stripe_token: token2})
user.reload
user.stripe_customer_id.should_not be_nil
customer2 = user.stripe_customer_id
customer1.should eql customer2
# double-check that the stripe customer db record got it's email synced
customer = user.fetch_stripe_customer
customer.email.should eql 'unique+1@jamkazam.com'
end
end
=begin
describe "update avatar" do

View File

@ -39,6 +39,25 @@ describe "RenderMailers", :slow => true do
it { @filename="text_message"; UserMailer.text_message(user, user2.id, user2.name, user2.resolved_photo_url, 'Get online!!').deliver }
it { @filename="friend_request"; UserMailer.friend_request(user, 'So and so has sent you a friend request.', friend_request.id).deliver}
end
describe "student/teacher" do
let(:teacher) { u = FactoryGirl.create(:teacher); u.user }
let(:user) { FactoryGirl.create(:user) }
it "teacher_lesson_request" do
@filename = "teacher_lesson_request"
lesson_booking = FactoryGirl.create(:lesson_booking)
UserMailer.teacher_lesson_request(lesson_booking).deliver
end
it "student_lesson_request" do
@filename = "student_lesson_request"
lesson_booking = FactoryGirl.create(:lesson_booking)
UserMailer.student_lesson_request(lesson_booking).deliver
end
end
end
describe "InvitedUserMailer emails" do

View File

@ -60,6 +60,8 @@ CarrierWave.configure do |config|
config.enable_processing = false
end
Stripe.api_key = "sk_test_OkjoIF7FmdjunyNsdVqJD02D"
#uncomment the following line to use spork with the debugger
#require 'spork/ext/ruby-debug'

View File

@ -258,6 +258,7 @@ def app_config
true
end
private
def audiomixer_workspace_path
@ -328,3 +329,14 @@ def friend(user1, user2)
FactoryGirl.create(:friendship, user: user1, friend: user2)
FactoryGirl.create(:friendship, user: user2, friend: user1)
end
def create_stripe_token(exp_month = 2017)
Stripe::Token.create(
:card => {
:number => "4111111111111111",
:exp_month => 2,
:exp_year => exp_month,
:cvc => "314"
},
).id
end

View File

@ -59,7 +59,6 @@ gem 'carmen'
gem 'carrierwave', '0.9.0'
gem 'carrierwave_direct'
gem 'fog'
gem 'jquery-payment-rails'
gem 'haml-rails'
gem 'unf' #optional fog dependency
gem 'devise', '3.3.0' #3.4.0 causes uninitialized constant ActionController::Metal (NameError)
@ -89,12 +88,14 @@ gem 'htmlentities'
gem 'sanitize'
gem 'recurly'
#gem 'guard', '2.7.3'
gem 'influxdb', '0.1.8'
gem 'influxdb-rails', '0.1.10'
gem 'influxdb' #, '0.1.8'
gem 'influxdb-rails'# , '0.1.10'
gem 'sitemap_generator'
gem 'bower-rails', "~> 0.9.2"
gem 'react-rails', '~> 1.0'
gem 'sendgrid_toolkit', '>= 1.1.1'
gem 'stripe'
gem 'zip-codes'
#gem "browserify-rails", "~> 0.7"
source 'https://rails-assets.org' do

View File

@ -39,6 +39,7 @@
//= require jquery.payment
//= require jquery.visible
//= require jquery.jstarbox
//= require jquery.inputmask
//= require fingerprint2.min
//= require ResizeSensor
//= require classnames

View File

@ -2142,6 +2142,25 @@
});
}
function submitStripe(options) {
return $.ajax({
type: "POST",
url: '/api/stripe',
dataType: "json",
contentType: 'application/json',
data: JSON.stringify(options)
})
}
function getLessonSessions(options) {
return $.ajax({
type: "GET",
url: "/api/lesson_sessions?" + $.param(query),
dataType: "json",
contentType: 'application/json'
});
}
function initialize() {
return self;
}
@ -2335,6 +2354,8 @@
this.portOverCarts = portOverCarts;
this.bookLesson = bookLesson;
this.getUnprocessedLesson = getUnprocessedLesson;
this.submitStripe = submitStripe;
this.getLessonSessions = getLessonSessions;
return this;
};
})(window,jQuery);

View File

@ -115,6 +115,7 @@ UserStore = context.UserStore
options.payment_style = 'elsewhere'
options.lesson_type = 'single-free'
options.slots = [@getSlotData(0), @getSlotData(1)]
options.timezone = Ajstz.determine().name()
description = @root.find('textarea.user-description').val()
if description == ''
description == null

View File

@ -1,139 +0,0 @@
context = window
rest = context.JK.Rest()
logger = context.JK.logger
UserStore = context.UserStore
@FreeLessonPayment = React.createClass({
mixins: [
Reflux.listenTo(AppStore, "onAppInit"),
Reflux.listenTo(UserStore, "onUserChanged")
]
onAppInit: (@app) ->
@app.bindScreen('jamclass/free-lesson-payment',
{beforeShow: @beforeShow, afterShow: @afterShow, beforeHide: @beforeHide})
onUserChanged: (userState) ->
@setState({user: userState?.user})
componentDidMount: () ->
@root = $(@getDOMNode())
getInitialState: () ->
{user: null,
lesson: null,
updating: false}
beforeHide: (e) ->
@resetErrors()
beforeShow: (e) ->
afterShow: (e) ->
@resetState()
@resetErrors()
@setState({updating:true})
rest.getUnprocessedLesson().done((response) => @unprocessLoaded(response)).fail((jqXHR) => @failedBooking(jqXHR))
resetState: () ->
@setState({update: false, lesson: null})
unprocessLoaded: (response) ->
@setState({updating: false})
@setState({lesson: response})
failedUnprocessLoad: (jqXHR) ->
@setState({updating: false})
@app.layout.notify({title: 'Unable to load lesson', text: 'Please attempt to book a free lesson first or refresh this page.'})
onBack: (e) ->
e.preventDefault()
onSubmit: (e) ->
e.preventDefault()
render: () ->
disabled = @state.updating
if @state.updating
photo_url = '/assets/shared/avatar_generic.png'
name = 'Loading ...'
teacherDetails = `<div className="teacher-header">
<div className="avatar">
<img src={photo_url}/>
</div>
{name}
</div>`
else
if @state.lesson?
photo_url = @state.lesson.teacher.photo_url
name = @state.lesson.teacher.name
if !photo_url?
photo_url = '/assets/shared/avatar_generic.png'
teacherDetails = `<div className="teacher-header">
<div className="avatar">
<img src={photo_url}/>
</div>
{name}
</div>`
if lesson.lesson_type == 'single-free'
bookingInfo = `<p>You are booking a single free {this.state.lesson.lesson_length}-minute lesson.</p>`
bookingDetail = `<p>To book this lesson, you will need to enter your credit card information.
You will absolutely not be charged for this free lesson, and you have no further commitment to purchase
anything. We have to collect a credit card to prevent abuse by some users who would otherwise set up
multiple free accounts to get multiple free lessons.
<br/>
<div className="jamclass-policies"><a href="/corp/terms" rel="external" onClick={this.jamclassPolicies}>jamclass
policies</a></div>
</p>`
else if lesson.lesson_type == 'test-drive'
bookingInfo = `<p>This is not the correct page to pay for TestDrive.</p>`
bookingDetail = ''
else if lesson.lesson_type == 'paid'
bookingInfo = `<p>This is not the correct page for entering pay for a normal lesson.</p>`
bookingDetail = ''
`<div className="content-body-scroller">
<div className="column column-left">
<h2>enter card info</h2><div className="no-charge">Your card wil not be charged.<br/>See explanation to the right.</div>
<div className="field card-number">
<label>Card Number:</label>
<input disabled={disabled} type="text" name="card-number"></input>
</div>
<div className="field expiration">
<label>Expiration Date:</label>
<input disabled={disabled} type="text" name="expiration"></input>
</div>
<div className="field cvv">
<label>CVV:</label>
<input disabled={disabled} type="text" name="cvv"></input>
</div>
<div className="field zip">
<label>Zip/Postal Code:</label>
<input disabled={disabled} type="text" name="zip"></input>
</div>
</div>
<div className="column column-right">
{teacherDetails}
<div className="booking-info">
{bookingInfo}
{bookingDetail}
</div>
</div>
<div className="actions">
<a className={backClasses} onClick={this.onBack}>BACK</a><a className={submitClasses} onClick={this.onSubmit}>SUBMIT CARD INFORMATION</a>
</div>
</div>`
})

View File

@ -0,0 +1,154 @@
context = window
rest = context.JK.Rest()
logger = context.JK.logger
UserStore = context.UserStore
@JamClassStudentScreen = React.createClass({
mixins: [
@ICheckMixin,
Reflux.listenTo(AppStore, "onAppInit"),
Reflux.listenTo(UserStore, "onUserChanged")
]
onAppInit: (@app) ->
@app.bindScreen('jamclass',
{beforeShow: @beforeShow, afterShow: @afterShow, beforeHide: @beforeHide})
onUserChanged: (userState) ->
@setState({user: userState?.user})
componentDidMount: () ->
componentDidUpdate: () ->
getInitialState: () ->
{
user: null,
}
beforeHide: (e) ->
@resetErrors()
beforeShow: (e) ->
afterShow: (e) ->
@setState({updating: true})
rest.getLessonSessions().done((response) => @jamClassLoaded(response)).fail((jqXHR) => @failedJamClassLoad(jqXHR))
resetState: () ->
@setState({updating: false, lesson: null})
jamClassLoaded: (response) ->
@setState({updating: false})
@setState({summary: response})
failedJamClassLoad: (jqXHR) ->
@setState({updating: false})
@setState({summary: response})
if jqXHR.status == 404
@app.layout.notify({title: "Unable to load JamClass info", text: "Try refreshing the web page"})
render: () ->
disabled = @state.updating
classes = []
if @state.updating
classes = [`<tr><td colspan="5">Loading...</td></tr>`]
else
`<div className="content-body-scroller">
<div className="column column-left">
<div className="my-lessons jamclass-section">
<h2>my lessons</h2>
<table className="jamtable">
<thead>
<tr>
<th>TEACHER</th>
<th>DATE/TIME</th>
<th>STATUS</th>
<th></th>
<th>ACTIONS</th>
</tr>
</thead>
<tbody>
{classes}
</tbody>
</table>
<div className="calender-integration-notice">
Don't miss a lesson! <a>Integrate your lessons into your calendar.</a>
</div>
</div>
<div className="search-teachers">
<h2>search teachers</h2>
<p>JamClass instructors are each individually screened to ensure that they are highly qualified music
teachers,
equipped to teach effectively online, and background checked.
</p>
<div className="actions">
<a href="/client#/teachers/search" className="button-orange">SEARCH TEACHERS</a>
</div>
</div>
</div>
<div className="column column-right">
<div className="jamclass-section">
<h2>learn about jamclass</h2>
<p>
JamClass is the best way to make music lessons, offering significant advantadges over both traditional
face-to-face lessons
and online skype lessons.
</p>
<div className="actions">
<a href="/landing/jamclass/students" className="button-orange">LEARN MORE</a>
</div>
</div>
<div className="jamclass-section">
<h2>sign up for testdrive</h2>
<p>
There are two awesome, painless ways to get started with JamClass.
</p>
<p>
Sign up for TestDrive and take 4 full 30-minute lessons - one each from 4 different instructors - for just
$49.99.
You wouldn't marry the first person you date, right? Find the best teacher for you. It's the most important
factor in the success for your lessons!
</p>
<p>
Or take one JamClass lesson free. It's on us! We're confident you'll take more.
</p>
<p>
Sign up for TestDrive using the button below, or to take one free lesson, search our teachers, and click the
Book Free Lesson on your favorite.
</p>
<div className="actions">
<a href="/landing/jamclass/students" className="button-orange">SIGN UP FOR TESTDRIVE</a>
or
<a href="/client#/teachers/search" className="button-orange">SEARCH TEACHERS</a>
</div>
</div>
<div className="jamclass-section">
<h2>get ready for your first lesson</h2>
<p>Be sure to set up and test the JamKazam app in an online music session a few days before
your first lesson! We're happy to help, and we'll even get in a session with you to make sure everything
is working properly. Ping us at <a href="mailto:support@jamkazam.com">support@jamkazam.com anytime</a>, and
read our
<a onClick={alert.bind('not yet')}>JamClass user guide</a> to learn how to use all the lesson features.
</p>
</div>
</div>
<br className="clearall"/>
</div>`
})

View File

@ -7,23 +7,36 @@ UserStore = context.UserStore
@LessonPayment = React.createClass({
mixins: [
@ICheckMixin,
Reflux.listenTo(AppStore, "onAppInit"),
Reflux.listenTo(UserStore, "onUserChanged")
]
onAppInit: (@app) ->
@app.bindScreen('jamclass/payment',
@app.bindScreen('jamclass/lesson-payment',
{beforeShow: @beforeShow, afterShow: @afterShow, beforeHide: @beforeHide})
onUserChanged: (userState) ->
@setState({user: userState?.user})
componentDidMount: () ->
@checkboxes = [{selector: 'input.billing-address-in-us', stateKey: 'billingInUS'}]
@root = $(@getDOMNode())
@root.find('input.expiration').payment('formatCardExpiry')
@root.find("input.card-number").payment('formatCardNumber')
@root.find("input.cvv").payment('formatCardCVC')
@iCheckify()
componentDidUpdate: () ->
@iCheckify()
getInitialState: () ->
{user: null,
lesson: null}
{
user: null,
lesson: null,
updating: false,
billingInUS: true
}
beforeHide: (e) ->
@resetErrors()
@ -31,15 +44,165 @@ UserStore = context.UserStore
beforeShow: (e) ->
afterShow: (e) ->
@resetState()
@resetErrors()
rest.getUnprocessedLesson().done((response) => @booked(response)).fail((jqXHR) => @failedBooking(jqXHR))
@setState({updating: true})
rest.getUnprocessedLesson().done((response) => @unprocessLoaded(response)).fail((jqXHR) => @failedBooking(jqXHR))
resetErrors: () ->
@setState({ccError: null, cvvError: null, expiryError: null, billingInUSError: null, zipCodeError: null})
checkboxChanged: (e) ->
checked = $(e.target).is(':checked')
@setState({billingInUS: checked})
resetState: () ->
@setState({updating: false, lesson: null})
unprocessLoaded: (response) ->
@setState({updating: false})
@setState({lesson: response})
failedBooking: (jqXHR) ->
@setState({updating: false})
@setState({lesson: null})
if jqXHR.status == 404
# no unprocessed lessons. That's arguably OK; the user is just going to enter their info up front.
console.log("nothing")
failedUnprocessLoad: (jqXHR) ->
@setState({updating: false})
@app.layout.notify({
title: 'Unable to load lesson',
text: 'Please attempt to book a free lesson first or refresh this page.'
})
onBack: (e) ->
e.preventDefault()
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 !$.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,
}
window.Stripe.card.createToken(data, (status, response) => (@stripeResponseHandler(status, response)));
stripeResponseHandler: (status, response) ->
console.log("response", response)
if response.error
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})
else
if this.state.billingInUS
zip = @root.find('input.zip').val()
rest.submitStripe({
token: response.id,
zip: zip,
test_drive: @state.lesson?.lesson_type == 'test-drive'
}).done((response) => @stripeSubmitted(response)).fail((jqXHR) => @stripeSubmitFailure(jqXHR))
stripeSubmitted: (response) ->
logger.debug("stripe submitted", response)
# if the response has a lesson, take them there
if response.lesson?.id?
context.Banner.showNotice({
title: "Lesson Requested",
text: "The teacher has been notified of your lesson request, and should respond soon.<br/><br/>We've taken you automatically to the page for this request, and sent an email to you with a link here as well. All communication with the teacher will show up on this page and in email."
})
window.location = "/client#/jamclass/lesson-request/" + response.lesson.id
else if response.test_drive?
context.Banner.showNotice({
title: "Test Drive Purchased",
text: "You now have 4 lessons that you can take with 4 different teachers.<br/><br/>We've taken you automatically to the Teacher Search screen, so you can search for teachers right for you."
})
window.location = "/client#/teachers/search"
else
window.location = "/client#/teachers/search"
stripeSubmitFailure: (jqXHR) ->
@app.layout.notifyServerError(jqXHR, 'Credit Card Not Stored')
render: () ->
disabled = @state.updating
if @state.updating
photo_url = '/assets/shared/avatar_generic.png'
name = 'Loading ...'
teacherDetails = `<div className="teacher-header">
<div className="avatar">
<img src={photo_url}/>
</div>
{name}
</div>`
else
if @state.lesson?
photo_url = @state.lesson.teacher.photo_url
name = @state.lesson.teacher.name
if !photo_url?
photo_url = '/assets/shared/avatar_generic.png'
teacherDetails = `<div className="teacher-header">
@ -50,50 +213,149 @@ UserStore = context.UserStore
</div>`
if lesson.lesson_type == 'single-free'
bookingInfo = `<p>This is not the correct page to pay for TestDrive.</p>`
header = `<div><h2>enter card info</h2>
<div className="no-charge">Your card wil not be charged.<br/>See explanation to the right.</div>
</div>`
bookingInfo = `<p>You are booking a single free {this.state.lesson.lesson_length}-minute lesson.</p>`
bookingDetail = `<p>To book this lesson, you will need to enter your credit card information.
You will absolutely not be charged for this free lesson, and you have no further commitment to purchase
anything. We have to collect a credit card to prevent abuse by some users who would otherwise set up
multiple free accounts to get multiple free lessons.
<br/>
<div className="jamclass-policies"><a href="/corp/terms" rel="external" onClick={this.jamclassPolicies}>jamclass
policies</a></div>
</p>`
else if lesson.lesson_type == 'test-drive'
bookingInfo = `<p>This is not the correct page to pay for TestDrive.</p>`
header = `<div><h2>enter payment info for test drive</h2></div>`
bookingInfo = `<p></p>`
bookingDetail = `<p>You are purchasing the TestDrive package of JamClass by JamKazam. This purchase entitles
you to take 4 private online music lessons - 1 each from 4 different instructors in the JamClass instructor
community.
<br/>
<span className="jamclass-policies"><a href="/corp/terms" rel="external" onClick={this.jamclassPolicies}>jamclass
policies</a></span>
</p>`
else if lesson.lesson_type == 'paid'
bookingInfo = `<p>You are booking a {this.state.lesson.lesson_length} minute lesson for ${this.state.lesson.booked_price.toFixed(2)}</p>`
header = `<div><h2>enter payment info for lesson</h2></div>`
if this.state.lesson.recurring
if this.state.lesson.payment_style == 'single'
bookingInfo = `<p>You are booking a {this.state.lesson.lesson_length} minute lesson for
${this.state.lesson.booked_price.toFixed(2)}</p>`
bookingDetail = `<p>
Your card will not be charged until the day of the lesson. You must cancel at least 24 hours before your
lesson is scheduled, or you will be charged for the lesson in full.
<br/>
<span className="jamclass-policies"><a href="/corp/terms" rel="external" onClick={this.jamclassPolicies}>jamclass
policies</a></span>
</p>`
else if this.state.lesson.payment_style == 'weekly'
bookingInfo = `<p>You are booking a weekly recurring series of {this.state.lesson.lesson_length}-minute
lessons, to be paid individually as each lesson is taken, until cancelled.</p>`
bookingDetail = `<p>
Your card will be charged on the day of each lesson. If you need to cancel a lesson, you must do so at
least 24 hours before the lesson is scheduled, or you will be charged for the lesson in full.
<br/>
<span className="jamclass-policies"><a href="/corp/terms" rel="external" onClick={this.jamclassPolicies}>jamclass
policies</a></span>
</p>`
else if this.state.lesson.payment_style == 'monthly'
bookingInfo = `<p>You are booking a weekly recurring series of {this.state.lesson.lesson_length}-minute
lessons, to be paid for monthly until cancelled.</p>`
bookingDetail = `<p>
Your card will be charged on the first day of each month. Canceling individual lessons does not earn a
refund when buying monthly. To cancel, you must cancel at least 24 hours before the beginning of the
month, or you will be charged for that month in full.
<br/>
<span className="jamclass-policies"><a href="/corp/terms" rel="external" onClick={this.jamclassPolicies}>jamclass
policies</a></span>
</p>`
else
bookingInfo = `<p>You are booking a {this.state.lesson.lesson_length} minute lesson for
${this.state.lesson.booked_price.toFixed(2)}</p>`
bookingDetail = `<p>
Your card will not be charged until the day of the lesson. You must cancel at least 24 hours before your
lesson is scheduled, or you will be charged for the lesson in full.
<br/>
<span className="jamclass-policies"><a href="/corp/terms" rel="external" onClick={this.jamclassPolicies}>jamclass
policies</a></span>
</p>`
else
header = `<div><h2>enter payment info</h2></div>`
bookingInfo = `<p>You are entering your credit card info so that later checkouts go quickly. You can skip this
for now.</p>`
bookingDetail = `
<p>
Your card will not be charged until the day of the lesson. You must cancel at least 24 hours before your
lesson is scheduled, or you will be charged for the lesson in full.
<br/>
<span className="jamclass-policies plain"><a href="/corp/terms" rel="external" onClick={this.jamclassPolicies}>jamclass policies</a></span>
</p>`
submitClassNames = {'button-orange': true, disabled: disabled}
backClassNames = {'button-grey': true, disabled: disabled}
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}
`<div className="content-body-scroller">
<div className="column column-left">
<h2>enter payment info for lesson</h2>
{header}
<div className="field card-number">
<label>Card Number:</label>
<input type="text" name="card-number"></input>
</div>
<div className="field expiration">
<label>Expiration Date:</label>
<input type="text" name="expiration"></input>
</div>
<div className="field cvv">
<label>CVV:</label>
<input type="text" name="cvv"></input>
</div>
<div className="field zip">
<label>Zip/Postal Code:</label>
<input type="text" name="zip"></input>
</div>
<form autocomplete="on" onSubmit={this.onSubmit}>
<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>
</div>
<div className="column column-right">
{teacherDetails}
<div className="booking-info">
<p>{bookingInfo}</p>
{bookingInfo}
<p>BOOKING DETAIL TODO<br/>
<div className="jamclass-policies"><a href="/corp/terms" rel="external" onClick={this.jamclassPolicies}>jamclass
policies</a></div>
</p>
{bookingDetail}
</div>
</div>
<br className="clearall"/>
<div className="actions">
<a className={classNames(backClassNames)} onClick={this.onBack}>BACK</a><a
className={classNames(submitClassNames)} onClick={this.onSubmit}>SUBMIT CARD INFORMATION</a>
</div>
<br className="clearall"/>
</div>`
})

View File

@ -3,5 +3,6 @@ context = window
@UserActions = Reflux.createActions({
loaded: {}
modify: {}
refresh: {}
})

View File

@ -0,0 +1,40 @@
context = window
teacherActions = window.JK.Actions.Teacher
@ICheckMixin = {
iCheckIgnore: false
checkboxes: []
iCheckify: () ->
@setCheckboxState()
@enableICheck()
setCheckboxState: () ->
for checkbox in @checkboxes
selector = checkbox.selector
stateKey = checkbox.stateKey
enabled = @state[stateKey]
@iCheckIgnore = true
if enabled
@root.find(selector).iCheck('check').attr('checked', true);
else
@root.find(selector).iCheck('uncheck').attr('checked', false);
@iCheckIgnore = false
enableICheck: (e) ->
checkboxes = @root.find('input[type="checkbox"]')
context.JK.checkbox(checkboxes)
checkboxes.on('ifChanged', (e) => @checkIfCanFire(e))
true
checkIfCanFire: (e) ->
if @iCheckIgnore
return
if @checkboxChanged?
@checkboxChanged(e)
else
logger.warn("no checkbox changed implemented")
}

View File

@ -1,6 +1,7 @@
$ = jQuery
context = window
logger = context.JK.logger
rest = context.JK.Rest
@UserStore = Reflux.createStore(
{
@ -26,6 +27,12 @@ logger = context.JK.logger
@user = $.extend({}, @user, changes)
@changed()
onRefresh: () ->
rest.getUserDetail().done((response) => @onLoaded(response)).fail((jqXHR) => @onUserFail(jqXHR))
onUserFail:(jqXHR) ->
@app.layout.notify({title: 'Unable to Update User Info', text: "We recommend you refresh the page."})
changed:() ->
@trigger({user: @user})

View File

@ -65,6 +65,8 @@
tbody {
}
margin-bottom:20px;
}
.search-area {

View File

@ -1,100 +0,0 @@
@import "client/common";
#free-lesson-payment {
.content-body-scroller {
height:100%;
padding:30px;
}
h2 {
font-size: 20px;
font-weight:700;
margin-bottom: 20px !important;
display:inline-block;
}
.no-charge {
float:right;
}
.column {
@include border_box_sizing;
width:50%;
}
.column-left {
float:left;
padding-right:20px;
}
.column-right {
float:right;
padding-left:20px;
}
label {
display:inline-block;
}
select {
display:inline-block;
}
input {
display:inline-block;
width: calc(100% - 150px);
@include border_box_sizing;
}
textarea {
width:100%;
@include border_box_sizing;
height:125px;
}
.field {
position:relative;
display:block;
margin-top:15px;
margin-bottom:25px;
label {
width:150px;
}
}
p {
line-height:125% !important;
font-size:14px !important;
margin:0 0 20px 0 !important;
}
.avatar {
display:inline-block;
padding:1px;
width:48px;
height:48px;
background-color:#ed4818;
margin:10px 20px 0 0;
-webkit-border-radius:24px;
-moz-border-radius:24px;
border-radius:24px;
float:none;
}
.avatar img {
width: 48px;
height: 48px;
-webkit-border-radius:24px;
-moz-border-radius:24px;
border-radius:24px;
}
.teacher-name {
font-size:16px;
display:inline-block;
height:48px;
vertical-align:middle;
}
.jamclass-policies {
text-align:center;
margin-top:-20px;
}
.actions {
margin-left:-3px;
margin-bottom:20px;
}
.error-text {
display:block;
}
}

View File

@ -0,0 +1,86 @@
@import "client/common";
#jam-class-student-screen {
div[data-react-class="JamClassStudentScreen"] {
height:100%;
}
.content-body-scroller {
height:100%;
padding:30px;
@include border_box_sizing;
}
h2 {
font-size: 20px;
font-weight:700;
margin-bottom: 20px !important;
display:inline-block;
}
.column {
@include border_box_sizing;
width:50%;
}
.column-left {
float:left;
padding-right:20px;
}
.column-right {
float:right;
padding-left:20px;
}
p {
line-height:125% !important;
font-size:14px !important;
margin:0 0 20px 0 !important;
color: $ColorTextTypical;
}
.avatar {
display:inline-block;
padding:1px;
width:36px;
height:36px;
background-color:#ed4818;
margin:10px 20px 0 0;
-webkit-border-radius:18px;
-moz-border-radius:18px;
border-radius:18px;
float:none;
}
.avatar img {
width: 36px;
height: 36px;
-webkit-border-radius:18px;
-moz-border-radius:18px;
border-radius:18px;
}
.calender-integration-notice {
display:block;
text-align:center;
}
.actions {
display:block;
text-align:center;
}
.jamclass-section {
margin-bottom:40px;
}
.jamtable {
a {
text-decoration: underline !important;
color:#fc0 !important;
}
th {
font-size:14px;
padding:3px 10px;
}
td {
padding:4px 15px;
font-size:14px;
}
tbody {
}
}
}

View File

@ -2,4 +2,109 @@
#lesson-payment {
div[data-react-class="LessonPayment"] {
height:100%;
}
.content-body-scroller {
height:100%;
padding:30px;
@include border_box_sizing;
}
h2 {
font-size: 20px;
font-weight:700;
margin-bottom: 20px !important;
display:inline-block;
}
.no-charge {
float:right;
}
.column {
@include border_box_sizing;
width:50%;
}
.column-left {
float:left;
padding-right:20px;
}
.column-right {
float:right;
padding-left:20px;
}
label {
display:inline-block;
}
select {
display:inline-block;
}
input {
display:inline-block;
width: calc(100% - 150px);
@include border_box_sizing;
}
textarea {
width:100%;
@include border_box_sizing;
height:125px;
}
.field {
position:relative;
display:block;
margin-top:15px;
margin-bottom:25px;
label {
width:150px;
}
}
p {
line-height:125% !important;
font-size:14px !important;
margin:0 0 20px 0 !important;
}
.avatar {
display:inline-block;
padding:1px;
width:48px;
height:48px;
background-color:#ed4818;
margin:10px 20px 0 0;
-webkit-border-radius:24px;
-moz-border-radius:24px;
border-radius:24px;
float:none;
}
.avatar img {
width: 48px;
height: 48px;
-webkit-border-radius:24px;
-moz-border-radius:24px;
border-radius:24px;
}
.teacher-name {
font-size:16px;
display:inline-block;
height:48px;
vertical-align:middle;
}
.jamclass-policies {
display:block;
text-align:center;
}
.actions {
margin-left:-3px;
margin-bottom:20px;
}
.error-text {
display:block;
}
.actions {
float:left;
clear:both;
}
}

View File

@ -23,6 +23,10 @@ class ApiController < ApplicationController
@exception = exception
render "errors/conflict_error", :status => 409
end
rescue_from 'Stripe::StripeError' do |exception|
@exception = exception
render "errors/stripe_error", :status => 422
end
rescue_from 'ActiveRecord::RecordNotFound' do |exception|
log.debug(exception)
render :json => { :errors => { :resource => ["record not found"] } }, :status => 404

View File

@ -3,6 +3,15 @@ class ApiLessonBookingsController < ApiController
before_filter :api_signed_in_user
respond_to :json
def index
data = LessonBooking.index(current_user)
@lessons = data[:query]
@next = data[:next_page]
render "api_lesson_bookings/index", :layout => nil
end
def create
if params[:lesson_type] == LessonBooking::LESSON_TYPE_FREE
@ -28,6 +37,7 @@ class ApiLessonBookingsController < ApiController
specified_slot.preferred_day = day
specified_slot.hour = slot[:hour]
specified_slot.minute = slot[:minute]
specified_slot.timezone = slot[:timezone]
slots << specified_slot
end
@lesson_booking = LessonBooking.book_free(current_user, teacher, slots, params[:description])
@ -50,6 +60,6 @@ class ApiLessonBookingsController < ApiController
def unprocessed
@show_teacher = true
@lesson_booking = LessonBooking.unprocessed(current_user)
@lesson_booking = LessonBooking.unprocessed(current_user).first
end
end

View File

@ -0,0 +1,14 @@
class ApiLessonSessionsController < ApiController
before_filter :api_signed_in_user
respond_to :json
def index
data = LessonSession.index(current_user, params)
@lesson_sessions = data[:query]
@next = data[:next_page]
render "api_lesson_sessions/index", :layout => nil
end
end

View File

@ -0,0 +1,11 @@
class ApiStripeController < ApiController
before_filter :api_signed_in_user
respond_to :json
def store
data = user.payment_update(params)
@lesson = data[:lesson]
@test_drive = data[:test_drive]
end
end

View File

@ -81,5 +81,6 @@ module ClientHelper
gon.use_cached_session_scores = Rails.application.config.use_cached_session_scores
gon.allow_both_find_algos = Rails.application.config.allow_both_find_algos
gon.stripe_publishable_key = Rails.application.config.stripe_publishable_key
end
end

View File

@ -1,4 +1,4 @@
object @jamblasters
attributes :id, :serial_no, :client_id, :vtoken
attributes :id, :serial_no, :client_id, :vtoken, :key

View File

@ -10,6 +10,6 @@ child(:user => :user) {
attributes :id, :has_stored_credit_card?
}
child (:teacher => :teacher) { |teacher|
child(:teacher => :teacher) do |teacher|
partial "api_users/show", object: teacher
}
end

View File

@ -0,0 +1,11 @@
node :next do |page|
@next
end
node :entries do |page|
partial "api_lesson_sessions/show", object: @lesson_sessions
end
node :total_entries do |page|
@lesson_sessions.total_entries
end

View File

@ -0,0 +1,31 @@
object @lesson_session
attributes :id, :lesson_type, :duration, :price, :teacher_complete, :student_complete, :status, :student_canceled, :teacher_canceled, :student_canceled_at, :teacher_canceled_at, :student_canceled_reason, :teacher_canceled_reason, :status
child(:music_session => :music_session) {
attributes :id, :music_session_id, :name, :description, :musician_access, :approval_required, :fan_access, :fan_chat,
:band_id, :user_id, :genre_id, :created_at, :like_count, :comment_count, :play_count, :scheduled_duration,
:language, :recurring_mode, :language_description, :scheduled_start_date, :access_description, :timezone, :timezone_id, :timezone_description,
:musician_access_description, :fan_access_description, :session_removed_at, :legal_policy, :open_rsvps, :is_unstructured_rsvp?
node :scheduled_start_date do |session|
scheduled_start_date(session)
end
node :scheduled_start do |history|
history.scheduled_start_time.strftime("%a %e %B %Y %H:%M:%S") if history.scheduled_start
end
node :pretty_scheduled_start_with_timezone do |session|
pretty_scheduled_start(session, true)
end
node :pretty_scheduled_start_short do|session|
pretty_scheduled_start(session, false)
end
}
child(:teacher => :teacher) do |teacher|
partial "api_users/show", object: teacher
end

View File

@ -0,0 +1,14 @@
object @lesson
node :lesson do |lesson|
attribute :id
end
if @test_drive
node :test_drive do |lesson|
end
end

View File

@ -31,7 +31,7 @@ end
# give back more info if the user being fetched is yourself
if current_user && @user == current_user
attributes :email, :original_fpfile, :cropped_fpfile, :crop_selection, :session_settings, :show_whats_next, :show_whats_next_count, :subscribe_email, :auth_twitter, :new_notifications, :sales_count, :reuse_card, :purchased_jamtracks_count, :first_downloaded_client_at, :created_at, :first_opened_jamtrack_web_player, :gifted_jamtracks, :has_redeemable_jamtrack
attributes :email, :original_fpfile, :cropped_fpfile, :crop_selection, :session_settings, :show_whats_next, :show_whats_next_count, :subscribe_email, :auth_twitter, :new_notifications, :sales_count, :reuse_card, :purchased_jamtracks_count, :first_downloaded_client_at, :created_at, :first_opened_jamtrack_web_player, :gifted_jamtracks, :has_redeemable_jamtrack, :remaining
node :geoiplocation do |user|
geoiplocation = current_user.geoiplocation
@ -68,7 +68,7 @@ if current_user && @user == current_user
if @show_student
node :has_unprocessed_lesson do |user|
!!LessonBooking.unprocessed(user)
!!LessonBooking.unprocessed(user).first
end
end

View File

@ -47,7 +47,7 @@
<%= render "clients/teachers/search/search_results" %>
<%= render "clients/jamclass/book_lesson_free" %>
<%= render "clients/jamclass/lesson_payment" %>
<%= render "clients/jamclass/free_lesson_payment" %>
<%= render "clients/jamclass/jamclass_student" %>
<%= render "users/feed_music_session_ajax" %>
<%= render "users/feed_recording_ajax" %>
<%= render "jamtrack_search" %>

View File

@ -1,10 +0,0 @@
#free-lesson-payment.screen.secondary layout="screen" layout-id="jamclass/free-lesson-payment"
.content-head
.content-icon
= image_tag "content/icon_account.png", :size => "27x20"
h1
| jamclass
= render "screen_navigation"
.content-body
= react_component 'FreeLessonPayment', {}

View File

@ -0,0 +1,10 @@
#jam-class-student-screen.screen.secondary layout="screen" layout-id="jamclass"
.content-head
.content-icon
= image_tag "content/icon_jamtracks.png", :size => "24x24"
h1
| jamclass
= render "screen_navigation"
.content-body
= react_component 'JamClassStudentScreen', {}

View File

@ -1,4 +1,4 @@
#lesson-payment.screen.secondary layout="screen" layout-id="jamclass/payment" layout-arg="id"
#lesson-payment.screen.secondary layout="screen" layout-id="jamclass/lesson-payment"
.content-head
.content-icon
= image_tag "content/icon_account.png", :size => "27x20"

View File

@ -0,0 +1,13 @@
object @exception
node do |exception|
errors = {}
errors["message"] = [exception.to_s]
{
errors: errors
}
end
node "type" do
"StripeError"
end

View File

@ -24,6 +24,7 @@
<%= yield %>
<%= render "shared/ga" %>
<%= render "shared/recurly" %>
<%= render "shared/stripe" %>
<%= render "shared/google_nocaptcha" %>
<%= render "shared/olark" %>
</body>

View File

@ -0,0 +1,6 @@
javascript:
window.stripeReadyHandler = function() {
Stripe.setPublishableKey(gon.stripe_publishable_key);
}
script src="https://js.stripe.com/v2/" onload="window.stripeReadyHandler()" async

View File

@ -164,6 +164,9 @@ if defined?(Bundler)
# Use Public Keys to identify your site when using Recurly.js. See https://docs.recurly.com/js/#include to learn more.
config.recurly_public_api_key = 'sjc-SZlO11shkeA1WMGuISLGg5'
config.stripe_secret_key = 'sk_test_cPVRbtr9xbMiqffV8jwibwLA'
config.stripe_publishable_key = 'pk_test_9vO8ZnxBpb9Udb0paruV3qLv'
if Rails.env == 'production'
config.desk_url = 'https://jamkazam.desk.com'
config.multipass_callback_url = "http://jamkazam.desk.com/customer/authentication/multipass/callback"
@ -418,5 +421,11 @@ if defined?(Bundler)
config.ban_jamtrack_downloaders = true
config.chat_opened_by_default = true
config.chat_blast = true
end
config.stripe = {
:publishable_key => 'pk_test_9vO8ZnxBpb9Udb0paruV3qLv',
:secret_key => 'sk_test_cPVRbtr9xbMiqffV8jwibwLA'
}
end
end

View File

@ -0,0 +1 @@
Stripe.api_key = Rails.configuration.stripe[:secret_key]

View File

@ -0,0 +1 @@
ZipCodes.load if Rails.env.production?

View File

@ -684,7 +684,10 @@ SampleApp::Application.routes.draw do
match '/jamblasters/pairing/store' => 'api_jamblasters#store_token', :via => :post
match '/jamblasters/pairing/pair' => 'api_jamblasters#pair', :via => :post
match '/lesson_sessions' => 'api_lesson_sessions#index', :via => :get
match '/lesson_bookings' => 'api_lesson_bookings#create', :via => :post
match '/lesson_booking/unprocessed' => 'api_lesson_bookings#unprocessed', :via => :get
match '/stripe' => 'api_stripe#store', :via => :post
end
end

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,651 @@
// Generated by CoffeeScript 1.7.1
(function() {
var $, cardFromNumber, cardFromType, cards, defaultFormat, formatBackCardNumber, formatBackExpiry, formatCardNumber, formatExpiry, formatForwardExpiry, formatForwardSlashAndSpace, hasTextSelected, luhnCheck, reFormatCVC, reFormatCardNumber, reFormatExpiry, reFormatNumeric, replaceFullWidthChars, restrictCVC, restrictCardNumber, restrictExpiry, restrictNumeric, safeVal, 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; };
$ = window.jQuery || window.Zepto || window.$;
$.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;
$.payment.cards = cards = [
{
type: 'visaelectron',
patterns: [4026, 417500, 4405, 4508, 4844, 4913, 4917],
format: defaultFormat,
length: [16],
cvcLength: [3],
luhn: true
}, {
type: 'maestro',
patterns: [5018, 502, 503, 56, 58, 639, 6220, 67],
format: defaultFormat,
length: [12, 13, 14, 15, 16, 17, 18, 19],
cvcLength: [3],
luhn: true
}, {
type: 'forbrugsforeningen',
patterns: [600],
format: defaultFormat,
length: [16],
cvcLength: [3],
luhn: true
}, {
type: 'dankort',
patterns: [5019],
format: defaultFormat,
length: [16],
cvcLength: [3],
luhn: true
}, {
type: 'visa',
patterns: [4],
format: defaultFormat,
length: [13, 16],
cvcLength: [3],
luhn: true
}, {
type: 'mastercard',
patterns: [51, 52, 53, 54, 55, 22, 23, 24, 25, 26, 27],
format: defaultFormat,
length: [16],
cvcLength: [3],
luhn: true
}, {
type: 'amex',
patterns: [34, 37],
format: /(\d{1,4})(\d{1,6})?(\d{1,5})?/,
length: [15],
cvcLength: [3, 4],
luhn: true
}, {
type: 'dinersclub',
patterns: [30, 36, 38, 39],
format: /(\d{1,4})(\d{1,6})?(\d{1,4})?/,
length: [14],
cvcLength: [3],
luhn: true
}, {
type: 'discover',
patterns: [60, 64, 65, 622],
format: defaultFormat,
length: [16],
cvcLength: [3],
luhn: true
}, {
type: 'unionpay',
patterns: [62, 88],
format: defaultFormat,
length: [16, 17, 18, 19],
cvcLength: [3],
luhn: false
}, {
type: 'jcb',
patterns: [35],
format: defaultFormat,
length: [16],
cvcLength: [3],
luhn: true
}
];
cardFromNumber = function(num) {
var card, p, pattern, _i, _j, _len, _len1, _ref;
num = (num + '').replace(/\D/g, '');
for (_i = 0, _len = cards.length; _i < _len; _i++) {
card = cards[_i];
_ref = card.patterns;
for (_j = 0, _len1 = _ref.length; _j < _len1; _j++) {
pattern = _ref[_j];
p = pattern + '';
if (num.substr(0, p.length) === p) {
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 ? _ref.createRange : void 0 : void 0) != null) {
if (document.selection.createRange().text) {
return true;
}
}
return false;
};
safeVal = function(value, $target) {
var cursor, error, last;
try {
cursor = $target.prop('selectionStart');
} catch (_error) {
error = _error;
cursor = null;
}
last = $target.val();
$target.val(value);
if (cursor !== null && $target.is(":focus")) {
if (cursor === last.length) {
cursor = value.length;
}
$target.prop('selectionStart', cursor);
return $target.prop('selectionEnd', cursor);
}
};
replaceFullWidthChars = function(str) {
var chars, chr, fullWidth, halfWidth, idx, value, _i, _len;
if (str == null) {
str = '';
}
fullWidth = '\uff10\uff11\uff12\uff13\uff14\uff15\uff16\uff17\uff18\uff19';
halfWidth = '0123456789';
value = '';
chars = str.split('');
for (_i = 0, _len = chars.length; _i < _len; _i++) {
chr = chars[_i];
idx = fullWidth.indexOf(chr);
if (idx > -1) {
chr = halfWidth[idx];
}
value += chr;
}
return value;
};
reFormatNumeric = function(e) {
var $target;
$target = $(e.currentTarget);
return setTimeout(function() {
var value;
value = $target.val();
value = replaceFullWidthChars(value);
value = value.replace(/\D/g, '');
return safeVal(value, $target);
});
};
reFormatCardNumber = function(e) {
var $target;
$target = $(e.currentTarget);
return setTimeout(function() {
var value;
value = $target.val();
value = replaceFullWidthChars(value);
value = $.payment.formatCardNumber(value);
return safeVal(value, $target);
});
};
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 setTimeout(function() {
return $target.val(value + ' ' + digit);
});
} else if (re.test(value + digit)) {
e.preventDefault();
return setTimeout(function() {
return $target.val(value + digit + ' ');
});
}
};
formatBackCardNumber = function(e) {
var $target, value;
$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 setTimeout(function() {
return $target.val(value.replace(/\d\s$/, ''));
});
} else if (/\s\d?$/.test(value)) {
e.preventDefault();
return setTimeout(function() {
return $target.val(value.replace(/\d$/, ''));
});
}
};
reFormatExpiry = function(e) {
var $target;
$target = $(e.currentTarget);
return setTimeout(function() {
var value;
value = $target.val();
value = replaceFullWidthChars(value);
value = $.payment.formatExpiry(value);
return safeVal(value, $target);
});
};
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 setTimeout(function() {
return $target.val("0" + val + " / ");
});
} else if (/^\d\d$/.test(val)) {
e.preventDefault();
return setTimeout(function() {
var m1, m2;
m1 = parseInt(val[0], 10);
m2 = parseInt(val[1], 10);
if (m2 > 2 && m1 !== 0) {
return $target.val("0" + m1 + " / " + m2);
} else {
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 + " / ");
}
};
formatForwardSlashAndSpace = function(e) {
var $target, val, which;
which = String.fromCharCode(e.which);
if (!(which === '/' || which === ' ')) {
return;
}
$target = $(e.currentTarget);
val = $target.val();
if (/^\d$/.test(val) && val !== '0') {
return $target.val("0" + val + " / ");
}
};
formatBackExpiry = function(e) {
var $target, value;
$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\/\s$/.test(value)) {
e.preventDefault();
return setTimeout(function() {
return $target.val(value.replace(/\d\s\/\s$/, ''));
});
}
};
reFormatCVC = function(e) {
var $target;
$target = $(e.currentTarget);
return setTimeout(function() {
var value;
value = $target.val();
value = replaceFullWidthChars(value);
value = value.replace(/\D/g, '').slice(0, 4);
return safeVal(value, $target);
});
};
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;
}
if (hasTextSelected($target)) {
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.on('keypress', restrictNumeric);
this.on('keypress', restrictCVC);
this.on('paste', reFormatCVC);
this.on('change', reFormatCVC);
this.on('input', reFormatCVC);
return this;
};
$.payment.fn.formatCardExpiry = function() {
this.on('keypress', restrictNumeric);
this.on('keypress', restrictExpiry);
this.on('keypress', formatExpiry);
this.on('keypress', formatForwardSlashAndSpace);
this.on('keypress', formatForwardExpiry);
this.on('keydown', formatBackExpiry);
this.on('change', reFormatExpiry);
this.on('input', reFormatExpiry);
return this;
};
$.payment.fn.formatCardNumber = function() {
this.on('keypress', restrictNumeric);
this.on('keypress', restrictCardNumber);
this.on('keypress', formatCardNumber);
this.on('keydown', formatBackCardNumber);
this.on('keyup', setCardType);
this.on('paste', reFormatCardNumber);
this.on('change', reFormatCardNumber);
this.on('input', reFormatCardNumber);
this.on('input', setCardType);
return this;
};
$.payment.fn.restrictNumeric = function() {
this.on('keypress', restrictNumeric);
this.on('paste', reFormatNumeric);
this.on('change', reFormatNumeric);
this.on('input', reFormatNumeric);
return this;
};
$.payment.fn.cardExpiryVal = function() {
return $.payment.cardExpiryVal($(this).val());
};
$.payment.cardExpiryVal = function(value) {
var month, prefix, year, _ref;
_ref = value.split(/[\s\/]+/, 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, _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 (!((1 <= month && month <= 12))) {
return false;
}
if (year.length === 2) {
if (year < 70) {
year = "20" + year;
} else {
year = "19" + year;
}
}
if (year.length !== 4) {
return false;
}
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 card, _ref;
cvc = $.trim(cvc);
if (!/^\d+$/.test(cvc)) {
return false;
}
card = cardFromType(type);
if (card != null) {
return _ref = cvc.length, __indexOf.call(card.cvcLength, _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;
num = num.replace(/\D/g, '');
card = cardFromNumber(num);
if (!card) {
return num;
}
upperLength = card.length[card.length.length - 1];
num = num.slice(0, upperLength);
if (card.format.global) {
return (_ref = num.match(card.format)) != null ? _ref.join(' ') : void 0;
} else {
groups = card.format.exec(num);
if (groups == null) {
return;
}
groups.shift();
groups = $.grep(groups, function(n) {
return n;
});
return groups.join(' ');
}
};
$.payment.formatExpiry = function(expiry) {
var mon, parts, sep, year;
parts = expiry.match(/^\D*(\d{1,2})(\D+)?(\d{1,4})?/);
if (!parts) {
return '';
}
mon = parts[1] || '';
sep = parts[2] || '';
year = parts[3] || '';
if (year.length > 0) {
sep = ' / ';
} else if (sep === ' /') {
mon = mon.substring(0, 1);
sep = '';
} else if (mon.length === 2 || sep.length > 0) {
sep = ' / ';
} else if (mon.length === 1 && (mon !== '0' && mon !== '1')) {
mon = "0" + mon;
sep = ' / ';
}
return mon + sep + year;
};
}).call(this);

View File

@ -1310,7 +1310,6 @@ module JamWebsockets
@message_stats['total_time'] = total_time
@message_stats['banned_users'] = @temp_ban.length
Stats.write('gateway.stats', @message_stats)
# clear out stats