diff --git a/admin/app/admin/students.rb b/admin/app/admin/students.rb index 7dae16c8f..a266e2625 100644 --- a/admin/app/admin/students.rb +++ b/admin/app/admin/students.rb @@ -8,14 +8,14 @@ ActiveAdmin.register JamRuby::User, :as => 'Students' do config.paginate = true def booked_anything(scope) - scope.joins(:student_lesson_bookings).uniq + scope.joins(:student_lesson_bookings).where('lesson_bookings.status = ?' > LessonBooking::STATUS_APPROVED).uniq end scope("Default", default: true) { |scope| booked_anything(scope).order('ready_for_session_at IS NULL DESC') } index do column "Name" do |user| - link_to teacher.user.name, "#{Rails.application.config.external_root_url}/client#/profile/#{user.id}" + link_to user.name, "#{Rails.application.config.external_root_url}/client#/profile/#{user.id}" end column "Email" do |user| user.email diff --git a/db/up/lessons.sql b/db/up/lessons.sql index c10667361..11f010ecf 100644 --- a/db/up/lessons.sql +++ b/db/up/lessons.sql @@ -27,6 +27,24 @@ CREATE TABLE lesson_bookings ( updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE charges ( + id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4(), + amount NUMERIC(8,2) NOT NULL, + type VARCHAR(64) NOT NULL, + sent_billing_notices BOOLEAN NOT NULL DEFAULT FALSE, + sent_billing_notices_at TIMESTAMP, + last_billing_attempt_at TIMESTAMP, + billed BOOLEAN NOT NULL DEFAULT FALSE, + billed_at TIMESTAMP, + post_processed BOOLEAN NOT NULL DEFAULT FALSE, + post_processed_at TIMESTAMP, + billing_error_reason VARCHAR, + billing_error_detail VARCHAR, + billing_should_retry BOOLEAN NOT NULL DEFAULT TRUE , + billing_attempts INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); CREATE TABLE lesson_package_purchases ( id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4(), @@ -132,4 +150,25 @@ 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); -ALTER TABLE sale_line_items ADD COLUMN lesson_package_purchase_id VARCHAR(64) REFERENCES lesson_package_purchases(id); \ No newline at end of file +ALTER TABLE sale_line_items ADD COLUMN lesson_package_purchase_id VARCHAR(64) REFERENCES lesson_package_purchases(id); + + +CREATE TABLE teacher_payments ( + id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4(), + teacher_id VARCHAR(64) REFERENCES users(id) NOT NULL, + charge_id VARCHAR(64) REFERENCES charges(id) NOT NULL, + amount NUMERIC(8,2) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE teacher_distributions ( + id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4(), + teacher_id VARCHAR(64) REFERENCES users(id) NOT NULL, + lesson_session_id VARCHAR(64) REFERENCES lesson_sessions(id), + teacher_payment_id VARCHAR(64) REFERENCES teacher_payments(id), + lesson_payment_purchase_id VARCHAR(64) REFERENCES lesson_package_purchases(id), + amount_in_cents INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/ruby/lib/jam_ruby.rb b/ruby/lib/jam_ruby.rb index 5619c7340..9b5f099d4 100755 --- a/ruby/lib/jam_ruby.rb +++ b/ruby/lib/jam_ruby.rb @@ -281,6 +281,8 @@ require "jam_ruby/models/jamblaster_pairing_request" require "jam_ruby/models/sale_receipt_ios" require "jam_ruby/models/lesson_session_analyser" require "jam_ruby/models/lesson_session_monthly_price" +require "jam_ruby/models/teacher_distribution" +require "jam_ruby/models/charge" include Jampb diff --git a/ruby/lib/jam_ruby/models/charge.rb b/ruby/lib/jam_ruby/models/charge.rb new file mode 100644 index 000000000..26279c64b --- /dev/null +++ b/ruby/lib/jam_ruby/models/charge.rb @@ -0,0 +1,131 @@ +module JamRuby + class Charge + validates :sent_notices, inclusion: {in: [true, false]} + + def max_retries + raise "not implemented" + end + def do_charge + raise "not implemented" + end + + def bill_lesson + + if !self.billed + + # check if we can bill at the moment + if last_billing_attempt_at && (24.hours.ago < last_billing_attempt_at) + return + end + + if !billing_should_retry + return + end + + + # bill the user right now. if it fails, move on; will be tried again + self.billing_attempts = self.billing_attempts + 1 + self.billing_should_retry = self.billing_attempts < max_retries + self.last_billing_attempt_at = Time.now + self.save(validate: false) + + begin + + do_charge + + if sale.errors.any? + self.billing_error_reason = 'sale_error' + self.billing_error_detail = sale.errors.inspect + line_item = sale.sale_line_items[0] + if line_item && line_item.errors.any? + self.billing_error_detail = "#{self.billing_error_detail}\n\n#{line_item.errors.inspect}" + end + self.save(validate: false) + return false + else + self.billed = true + self.billed_at = Time.now + self.save(validate: false) + end + rescue Stripe::StripeError => e + + stripe_handler(e) + + subject = "Unable to charge user #{student.email} for lesson #{self.id} (stripe)" + body = "teacher=#{teacher.email}\n\nbilling_error_reason=#{billing_error_reason}\n\nbilling_error_detail = #{billing_error_detail}" + AdminMailer.alerts({subject: subject, body: body}) + UserMailer.student_unable_charge(self) + return false + rescue Exception => e + subject = "Unable to charge user #{student.email} for lesson #{self.id} (unhandled)" + body = "teacher=#{teacher.email}\n\nbilling_error_reason=#{billing_error_reason}\n\nbilling_error_detail = #{billing_error_detail}" + AdminMailer.alerts({subject: subject, body: body}) + unhandled_handler(e) + return false + end + + end + + if !self.sent_billing_notices + # If the charge is successful, then we post the charge to the student’s payment history, + # and associate the charge with the lesson, so that everyone knows the student has paid, and we send an email + + UserMailer.student_lesson_normal_done(self).deliver + UserMailer.teacher_lesson_normal_done(self).deliver + + self.sent_billing_notices = true + self.sent_billing_notices_at = Time.now + self.post_processed = true + self.post_processed_at = Time.now + self.save(validate: false) + end + + + return true + end + + def unhandled_handler(e, reason = 'unhandled_exception') + self.billing_error_reason = reason + if e.cause + self.billing_error_detail = e.cause.to_s + "\n" + e.cause.backtrace.join("\n\t") if e.cause.backtrace + self.billing_error_detail << "\n\n" + self.billing_error_detail << e.to_s + "\n" + e.backtrace.join("\n\t") if e.backtrace + else + self.billing_error_detail = e.to_s + "\n" + e.backtrace.join("\n\t") if e.backtrace + end + self.save(validate: :false) + end + + def is_card_declined? + billed == false && billing_error_reason == 'card_declined' + end + + def is_card_expired? + billed == false && billing_error_reason == 'card_expired' + end + + def last_billed_at_date + last_billing_attempt_at.strftime("%B %d, %Y") if last_billing_attempt_at + end + def stripe_handler(e) + + msg = e.to_s + + if msg.include?('declined') + self.billing_error_reason = 'card_declined' + self.billing_error_detail = msg + elsif msg.include?('expired') + self.billing_error_reason = 'card_expired' + self.billing_error_detail = msg + elsif msg.include?('processing') + self.billing_error_reason = 'processing_error' + self.billing_error_detail = msg + else + self.billing_error_reason = 'stripe' + self.billing_error_detail = msg + end + + self.save(validate: false) + end + end +end \ No newline at end of file diff --git a/ruby/lib/jam_ruby/models/teacher_distribution.rb b/ruby/lib/jam_ruby/models/teacher_distribution.rb new file mode 100644 index 000000000..266fdfe38 --- /dev/null +++ b/ruby/lib/jam_ruby/models/teacher_distribution.rb @@ -0,0 +1,14 @@ +module JamRuby + class TeacherPayment + + belongs_to :teacher, class: "JamRuby::User" + belongs_to :charge, class: "JamRuby::Charge" + + validates :teacher, presence: true + validates :charge, presence: true + validates :amount, presence: true + + + + end +end \ No newline at end of file diff --git a/ruby/lib/jam_ruby/models/teacher_payment_charge.rb b/ruby/lib/jam_ruby/models/teacher_payment_charge.rb new file mode 100644 index 000000000..276d5ab42 --- /dev/null +++ b/ruby/lib/jam_ruby/models/teacher_payment_charge.rb @@ -0,0 +1,48 @@ +module JamRuby + class TeacherPaymentCharge < Charge + + has_one :teacher_payment, class: "JamRuby::TeacherDistribution" + + def max_retries + 1 + end + + def teacher + @teacher ||= teacher_distribution.teacher + end + + def amount_in_cents + amount * 100 + end + + def uncollected_charges + T.where(teacher_id: teacher.id).where(distributed: false).where(billed:true).where(type: 'JamRuby::StudentLessonCharge') + end + def do_charge + + @uncollected_charges = uncollected_charges + charge = Stripe::Charge.create( + :amount => amount_in_cents, + :currency => "usd", + :source => APP_CONFIG.stripe_charge_token, + :description => construct_description, + :destination => teacher.stripe_account_id + ) + + 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[:change] = charge + price_info[:purchase] = purchase + price_info + end + + def construct_description + teacher_distribution. + end + + end +end \ No newline at end of file diff --git a/web/config/application.rb b/web/config/application.rb index 13008c0bb..8e307f3d2 100644 --- a/web/config/application.rb +++ b/web/config/application.rb @@ -166,6 +166,7 @@ if defined?(Bundler) config.stripe_secret_key = 'sk_test_cPVRbtr9xbMiqffV8jwibwLA' config.stripe_publishable_key = 'pk_test_9vO8ZnxBpb9Udb0paruV3qLv' + config.stripe_charge_token = '#XXX#' if Rails.env == 'production' config.desk_url = 'https://jamkazam.desk.com'