diff --git a/app/assets/images/pro-checkmark.png b/app/assets/images/pro-checkmark.png new file mode 100644 index 000000000..4631165b6 Binary files /dev/null and b/app/assets/images/pro-checkmark.png differ diff --git a/app/assets/javascripts/initializers/initializeBaseUserData.js b/app/assets/javascripts/initializers/initializeBaseUserData.js index 4bd482c9d..047c45eb0 100644 --- a/app/assets/javascripts/initializers/initializeBaseUserData.js +++ b/app/assets/javascripts/initializers/initializeBaseUserData.js @@ -7,10 +7,10 @@ function initializeUserProfileContent(user) { '" class="sidebar-profile-pic-img" src="' + user.profile_image_90 + '" />'; - document.getElementById('sidebar-profile-name').innerHTML = filterXSS( - user.name, - ); - document.getElementById('sidebar-profile-username').innerHTML = + + document.getElementById('sidebar-profile-name').innerHTML = + filterXSS(user.name); + document.getElementById('sidebar-profile-username').innerHTML = '@' + user.username; document.getElementById('sidebar-profile-snapshot-inner').href = '/' + user.username; diff --git a/app/assets/javascripts/initializers/initializeSettings.js b/app/assets/javascripts/initializers/initializeSettings.js index e2a60ad3a..1e28e9c93 100644 --- a/app/assets/javascripts/initializers/initializeSettings.js +++ b/app/assets/javascripts/initializers/initializeSettings.js @@ -3,14 +3,17 @@ /* global timestampToLocalDateTime */ function initializeSettings() { - if (document.getElementById('settings-org-secret')) { - document.getElementById('settings-org-secret').onclick = event => { + // highlights organization secret on click + const settingsOrgSecret = document.getElementById('settings-org-secret'); + if (settingsOrgSecret) { + settingsOrgSecret.addEventListener('click', event => { event.target.select(); - }; + }); } - if (document.getElementById('rss-fetch-time')) { - var timeNode = document.getElementById('rss-fetch-time'); + // shows RSS fetch time in local time + let timeNode = document.getElementById('rss-fetch-time'); + if (timeNode) { var timeStamp = timeNode.getAttribute('datetime'); var timeOptions = { month: 'long', @@ -26,4 +29,20 @@ function initializeSettings() { timeOptions, ); } + + // asks for confirmation on activating pro membership + const createProForm = document.getElementById('new_pro_membership'); + if (createProForm) { + createProForm.addEventListener('submit', event => { + event.preventDefault(); + + if (window.confirm('Are you sure?')) { + // eslint-disable-line no-alert + event.target.submit(); + return true; + } + + return false; + }); + } } diff --git a/app/assets/javascripts/utilities/buildArticleHTML.js.erb b/app/assets/javascripts/utilities/buildArticleHTML.js.erb index 99212c9dd..13e00269a 100644 --- a/app/assets/javascripts/utilities/buildArticleHTML.js.erb +++ b/app/assets/javascripts/utilities/buildArticleHTML.js.erb @@ -96,19 +96,23 @@ function buildArticleHTML(article) { publishDate = '・' + ''; } } + var readingTimeHTML = ''; if (article.class_name === "Article") { // we have ` ... || null` for the case article.reading_time is undefined readingTimeHTML = ''+ ((article.reading_time || null) < 1 ? '1 min' : article.reading_time + ' min') +' read' } + var videoHTML = ''; if (article.cloudinary_video_url) { videoHTML = '
" alt="video camera">'+article.video_duration_in_minutes+'
' } + var timeAgoInWords = ''; if (article.published_at_int) { timeAgoInWords = timeAgo(article.published_at_int); } + return '
\ '+videoHTML+'\ '+orgHeadline+'\ diff --git a/app/assets/stylesheets/settings.scss b/app/assets/stylesheets/settings.scss index 3ec5f6e29..2e4ff52a5 100755 --- a/app/assets/stylesheets/settings.scss +++ b/app/assets/stylesheets/settings.scss @@ -671,3 +671,39 @@ ul.delete__account { } } } + +.membership-status { + display: flex; + align-items: center; + justify-content: space-between; + flex-flow: row wrap; + @include themeable(background, theme-container-accent-background, $off-white); + border-radius: 5px; + border: 1px solid $light-medium-gray; + padding: 0.5rem 0 0.8rem 0.5rem; + + dt, label { + flex-basis: 40%; + font-size: 1.1rem; + font-weight: bold; + + margin-top: 0.3rem; + padding: 0.2rem; + } + + dd { + flex-basis: 40%; + flex-grow: 1; + + margin-top: 0.3rem; + padding: 0.2rem; + + .no { + color: darken($red, 10%); + } + } +} + +.membership-purchase-link { + font-size: 2rem; +} diff --git a/app/assets/stylesheets/user-profile-header.scss b/app/assets/stylesheets/user-profile-header.scss index dc5eb74d2..7baefef4c 100644 --- a/app/assets/stylesheets/user-profile-header.scss +++ b/app/assets/stylesheets/user-profile-header.scss @@ -10,8 +10,8 @@ max-width:92%; margin: 30px auto 20px; @include themeable( - background, - theme-container-background, + background, + theme-container-background, #fff ); border-radius: 3px; @@ -109,6 +109,13 @@ width: 1.5em; vertical-align: -0.22em; margin-right: 0.15em; + + &.pro-checkmark { + height: 0.5em; + width: 0.5em; + vertical-align: 0.08em; + margin-left: 0; + } } } a{ @@ -168,8 +175,8 @@ margin:0; font-style: italic; @include themeable( - color, - theme-secondary-color, + color, + theme-secondary-color, $medium-gray ); width: 94%; @@ -261,8 +268,8 @@ font-weight: 800; margin-bottom: 2px; @include themeable( - color, - theme-secondary-color, + color, + theme-secondary-color, $medium-gray ); } @@ -450,7 +457,7 @@ margin: 10px 10px 10px 0; width: 82px; height: 80px; - } + } } } p { diff --git a/app/controllers/admin/pro_memberships_controller.rb b/app/controllers/admin/pro_memberships_controller.rb new file mode 100644 index 000000000..4d4dadc0d --- /dev/null +++ b/app/controllers/admin/pro_memberships_controller.rb @@ -0,0 +1,21 @@ +module Admin + class ProMembershipsController < Admin::ApplicationController + # To customize the behavior of this controller, + # you can overwrite any of the RESTful actions. For example: + # + # def index + # super + # @resources = ProMembership. + # page(params[:page]). + # per(10) + # end + + # Define a custom finder by overriding the `find_resource` method: + # def find_resource(param) + # ProMembership.find_by!(slug: param) + # end + + # See https://administrate-prototype.herokuapp.com/customizing_controller_actions + # for more information + end +end diff --git a/app/controllers/api/v0/analytics_controller.rb b/app/controllers/api/v0/analytics_controller.rb index 357bbd56b..3493acbb0 100644 --- a/app/controllers/api/v0/analytics_controller.rb +++ b/app/controllers/api/v0/analytics_controller.rb @@ -47,7 +47,7 @@ module Api private def authorize_pro_user - raise UnauthorizedError unless @user&.has_role?(:pro) + raise UnauthorizedError unless @user&.pro? end def authorize_user_organization diff --git a/app/controllers/async_info_controller.rb b/app/controllers/async_info_controller.rb index 41f40d598..291c4d8d2 100644 --- a/app/controllers/async_info_controller.rb +++ b/app/controllers/async_info_controller.rb @@ -69,6 +69,7 @@ class AsyncInfoController < ApplicationController #{current_user&.saw_onboarding}__ #{current_user&.checked_code_of_conduct}__ #{current_user&.articles_count}__ + #{current_user&.pro?}__ #{cookies[:remember_user_token]}" end diff --git a/app/controllers/credits_controller.rb b/app/controllers/credits_controller.rb index 148a4bd55..975974249 100644 --- a/app/controllers/credits_controller.rb +++ b/app/controllers/credits_controller.rb @@ -16,7 +16,7 @@ class CreditsController < ApplicationController current_user end @organizations = current_user.admin_organizations - @customer = Stripe::Customer.retrieve(current_user.stripe_id_code) if current_user.stripe_id_code + @customer = Payments::Customer.get(current_user.stripe_id_code) if current_user.stripe_id_code end def create @@ -43,13 +43,15 @@ class CreditsController < ApplicationController redirect_to credits_path, notice: "#{@number_to_purchase} new credits purchased!" end + private + def make_payment find_or_create_customer find_or_create_card update_user_stripe_info create_charge true - rescue Stripe::CardError => e + rescue Payments::PaymentsError => e flash[:error] = e.message redirect_to purchase_credits_path false @@ -57,22 +59,17 @@ class CreditsController < ApplicationController def find_or_create_customer @customer = if current_user.stripe_id_code - Stripe::Customer.retrieve(current_user.stripe_id_code) + Payments::Customer.get(current_user.stripe_id_code) else - Stripe::Customer.create( - email: current_user.email, - ) + Payments::Customer.create(email: current_user.email) end end def find_or_create_card @card = if params[:stripe_token] - Stripe::Customer.create_source( - @customer.id, - source: params[:stripe_token], - ) + Payments::Customer.create_source(@customer.id, params[:stripe_token]) else - @customer.sources.retrieve(params[:selected_card]) + Payments::Customer.get_source(@customer, params[:selected_card]) end end @@ -81,14 +78,11 @@ class CreditsController < ApplicationController end def create_charge - @amount = generate_cost - source = Rails.env.test? ? @card.id : (@card || @customer.default_source) - Stripe::Charge.create( - customer: @customer.id, - source: source, - amount: @amount, + Payments::Customer.charge( + customer: @customer, + amount: generate_cost, description: "Purchase of #{@number_to_purchase} credits.", - currency: "usd", + card_id: @card&.id, ) end diff --git a/app/controllers/history_controller.rb b/app/controllers/history_controller.rb index b264c3c0d..8de2cf3d8 100644 --- a/app/controllers/history_controller.rb +++ b/app/controllers/history_controller.rb @@ -1,4 +1,5 @@ class HistoryController < ApplicationController + before_action :authenticate_user! before_action :generate_algolia_search_key def index diff --git a/app/controllers/pro_memberships_controller.rb b/app/controllers/pro_memberships_controller.rb new file mode 100644 index 000000000..802fd884c --- /dev/null +++ b/app/controllers/pro_memberships_controller.rb @@ -0,0 +1,46 @@ +class ProMembershipsController < ApplicationController + before_action :authenticate_user!, except: %i[show] + before_action :load_pro_membership, only: %i[update] + after_action :verify_authorized, except: %i[show] + + def show + @user = current_user + @pro_membership = current_user&.pro_membership + end + + def create + authorize ProMembership + + if ProMemberships::Creator.call(current_user) + flash[:settings_notice] = "You are now a Pro!" + else + flash[:error] = "You don't have enough credits!" + end + + redirect_to user_settings_path("pro-membership") + end + + def update + raise Pundit::NotAuthorizedError, "You don't have a Pro Membership" unless @pro_membership + + authorize @pro_membership + + if @pro_membership.update(update_params) + flash[:settings_notice] = "Your membership has been updated!" + else + flash[:error] = "An error has occurred while updating your membership!" + end + + redirect_to user_settings_path("pro-membership") + end + + private + + def load_pro_membership + @pro_membership = current_user.pro_membership + end + + def update_params + params.require(:pro_membership).permit(:auto_recharge) + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 922ecb7eb..330b3cf7a 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -203,12 +203,9 @@ class UsersController < ApplicationController stripe_code = current_user.stripe_id_code return if stripe_code == "special" - @customer = Stripe::Customer.retrieve(stripe_code) if stripe_code.present? - when "membership" - if current_user.monthly_dues.zero? - redirect_to "/membership" - return - end + @customer = Payments::Customer.get(stripe_code) if stripe_code.present? + when "pro-membership" + @pro_membership = current_user.pro_membership when "account" @email_body = <<~HEREDOC Hello DEV Team, diff --git a/app/dashboards/dashboard_manifest.rb b/app/dashboards/dashboard_manifest.rb index fccb302b6..c58b5d711 100644 --- a/app/dashboards/dashboard_manifest.rb +++ b/app/dashboards/dashboard_manifest.rb @@ -27,6 +27,7 @@ class DashboardManifest badge_achievements html_variants sponsorships + pro_memberships ].freeze # DASHBOARDS = [ # :users, diff --git a/app/dashboards/pro_membership_dashboard.rb b/app/dashboards/pro_membership_dashboard.rb new file mode 100644 index 000000000..43d16ee87 --- /dev/null +++ b/app/dashboards/pro_membership_dashboard.rb @@ -0,0 +1,67 @@ +require "administrate/base_dashboard" + +class ProMembershipDashboard < Administrate::BaseDashboard + # ATTRIBUTE_TYPES + # a hash that describes the type of each of the model's fields. + # + # Each different type represents an Administrate::Field object, + # which determines how the attribute is displayed + # on pages throughout the dashboard. + ATTRIBUTE_TYPES = { + user: Field::BelongsTo, + id: Field::Number, + status: Field::String, + expires_at: Field::DateTime, + expiration_notification_at: Field::DateTime, + expiration_notifications_count: Field::Number, + auto_recharge: Field::Boolean, + created_at: Field::DateTime, + updated_at: Field::DateTime + }.freeze + + # COLLECTION_ATTRIBUTES + # an array of attributes that will be displayed on the model's index page. + # + # By default, it's limited to four items to reduce clutter on index pages. + # Feel free to add, remove, or rearrange items. + COLLECTION_ATTRIBUTES = %i[ + user + id + status + expires_at + auto_recharge + ].freeze + + # SHOW_PAGE_ATTRIBUTES + # an array of attributes that will be displayed on the model's show page. + SHOW_PAGE_ATTRIBUTES = %i[ + user + id + status + expires_at + expiration_notification_at + expiration_notifications_count + auto_recharge + created_at + updated_at + ].freeze + + # FORM_ATTRIBUTES + # an array of attributes that will be displayed + # on the model's form (`new` and `edit`) pages. + FORM_ATTRIBUTES = %i[ + user + status + expires_at + expiration_notification_at + expiration_notifications_count + auto_recharge + ].freeze + + # Overwrite this method to customize how pro memberships are displayed + # across all pages of the admin dashboard. + # + # def display_resource(pro_membership) + # "ProMembership ##{pro_membership.id}" + # end +end diff --git a/app/errors/payments.rb b/app/errors/payments.rb new file mode 100644 index 000000000..42dee9452 --- /dev/null +++ b/app/errors/payments.rb @@ -0,0 +1,10 @@ +module Payments + class PaymentsError < StandardError + end + + class InvalidRequestError < PaymentsError + end + + class CardError < PaymentsError + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 63bf43987..872cc2846 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -37,7 +37,7 @@ module ApplicationHelper classified_listings credits partnerships - pro_accounts + pro_memberships ].include?(controller_name) end diff --git a/app/javascript/searchableItemList/searchableItemList.js b/app/javascript/searchableItemList/searchableItemList.js index 62e3f4e50..d4c0e9102 100644 --- a/app/javascript/searchableItemList/searchableItemList.js +++ b/app/javascript/searchableItemList/searchableItemList.js @@ -8,7 +8,7 @@ export function defaultState(options) { index: null, page: 0, - hitsPerPage: 100, + hitsPerPage: 80, totalCount: 0, items: [], @@ -82,7 +82,7 @@ export function search(query, { page, tags, statusView }) { const { index, hitsPerPage, items } = component.state; - const filters = { hitsPerPage, page }; + const filters = { hitsPerPage, page: newPage }; if (tags && tags.length > 0) { filters.tagFilters = tags; } @@ -90,11 +90,9 @@ export function search(query, { page, tags, statusView }) { if (statusView) { filters.filters = `status:${statusView}`; } - index.search(query, filters).then(result => { // append new items at the end const allItems = [...items, ...result.hits]; - component.setState({ query, page: newPage, diff --git a/app/jobs/pro_memberships/populate_history_job.rb b/app/jobs/pro_memberships/populate_history_job.rb new file mode 100644 index 000000000..cbdb379ba --- /dev/null +++ b/app/jobs/pro_memberships/populate_history_job.rb @@ -0,0 +1,12 @@ +module ProMemberships + class PopulateHistoryJob < ApplicationJob + queue_as :pro_memberships_populate_history + + def perform(user_id) + user = User.find_by(id: user_id) + return unless user&.pro? + + user.page_views.reindex! + end + end +end diff --git a/app/jobs/slack_bot_ping_job.rb b/app/jobs/slack_bot_ping_job.rb index 2d483721f..242bbebcf 100644 --- a/app/jobs/slack_bot_ping_job.rb +++ b/app/jobs/slack_bot_ping_job.rb @@ -2,9 +2,6 @@ class SlackBotPingJob < ApplicationJob queue_as :slack_bot_ping def perform(message:, channel:, username:, icon_emoji:) - SlackBot.ping message, - channel: channel, - username: username, - icon_emoji: icon_emoji + SlackBot.ping(message, channel: channel, username: username, icon_emoji: icon_emoji) end end diff --git a/app/jobs/users/bust_cache_job.rb b/app/jobs/users/bust_cache_job.rb index bc26ecbfb..7ae6e644b 100644 --- a/app/jobs/users/bust_cache_job.rb +++ b/app/jobs/users/bust_cache_job.rb @@ -4,8 +4,9 @@ module Users def perform(user_id, cache_buster = CacheBuster.new) user = User.find_by(id: user_id) + return unless user - cache_buster.bust_user(user) if user + cache_buster.bust_user(user) end end end diff --git a/app/jobs/users/resave_articles_job.rb b/app/jobs/users/resave_articles_job.rb new file mode 100644 index 000000000..980a02ce6 --- /dev/null +++ b/app/jobs/users/resave_articles_job.rb @@ -0,0 +1,12 @@ +module Users + class ResaveArticlesJob < ApplicationJob + queue_as :users_resave_articles + + def perform(user_id) + user = User.find_by(id: user_id) + return unless user + + user.resave_articles + end + end +end diff --git a/app/labor/article_suggester.rb b/app/labor/article_suggester.rb index 8bdc2872e..535b84a8a 100644 --- a/app/labor/article_suggester.rb +++ b/app/labor/article_suggester.rb @@ -31,7 +31,7 @@ class ArticleSuggester Article.published.where(featured: true). where.not(id: ids_to_ignore). order("hotness_score DESC"). - includes(:user). + includes(user: [:pro_membership]). offset(rand(0..offsets[1])). first(max) end @@ -40,7 +40,7 @@ class ArticleSuggester Article.published.tagged_with(article.tag_list, any: true). where.not(id: article.id). order("hotness_score DESC"). - includes(:user). + includes(user: [:pro_membership]). offset(rand(0..offsets[0])). first(max) end diff --git a/app/mailers/pro_membership_mailer.rb b/app/mailers/pro_membership_mailer.rb new file mode 100644 index 000000000..4a901ee12 --- /dev/null +++ b/app/mailers/pro_membership_mailer.rb @@ -0,0 +1,11 @@ +class ProMembershipMailer < ApplicationMailer + default from: "DEV Pro Memberships " + + def expiring_membership(pro_membership, expiration_date) + @pro_membership = pro_membership + @user = pro_membership.user + @days = (Time.current.to_date..expiration_date.to_date).count - 1 + @date = expiration_date.to_date + mail(to: @user.email, subject: "Your Pro Membership will expire in #{@days} days!") + end +end diff --git a/app/models/article.rb b/app/models/article.rb index 4e0c4aafc..9285e6c00 100644 --- a/app/models/article.rb +++ b/app/models/article.rb @@ -145,9 +145,8 @@ class Article < ApplicationRecord :path, :class_name, :user_name, :user_username, :comments_blob, :body_text, :tag_keywords_for_search, :search_score, :readable_publish_date, :flare_tag attribute :user do - { username: user.username, - name: user.name, - profile_image_90: ProfileImage.new(user).get(90) } + { username: user.username, name: user.name, + profile_image_90: ProfileImage.new(user).get(90), pro: user.pro? } end tags do [tag_list, @@ -584,7 +583,8 @@ class Article < ApplicationRecord username: object.username, slug: object == organization ? object.slug : object.username, profile_image_90: object.profile_image_90, - profile_image_url: object.profile_image_url + profile_image_url: object.profile_image_url, + pro: object == user ? user.pro? : false # organizations can't be pro users } end diff --git a/app/models/chat_channel.rb b/app/models/chat_channel.rb index 4faac1e39..cd24f5637 100644 --- a/app/models/chat_channel.rb +++ b/app/models/chat_channel.rb @@ -98,6 +98,10 @@ class ChatChannel < ApplicationRecord end end + def remove_user(user) + chat_channel_memberships.where(user: user).destroy_all + end + def pusher_channels if invite_only? "presence-channel-#{id}" diff --git a/app/models/organization.rb b/app/models/organization.rb index bc448d36e..40af1273b 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -92,6 +92,14 @@ class Organization < ApplicationRecord ProfileImage.new(self).get(90) end + def has_enough_credits?(num_credits_needed) + credits.unspent.size >= num_credits_needed + end + + def pro? + false + end + def banned false end diff --git a/app/models/pro_membership.rb b/app/models/pro_membership.rb new file mode 100644 index 000000000..71b2986d3 --- /dev/null +++ b/app/models/pro_membership.rb @@ -0,0 +1,56 @@ +class ProMembership < ApplicationRecord + STATUSES = %w[active expired].freeze + MONTHLY_COST = 5 + MONTHLY_COST_USD = 25 + + belongs_to :user + + validates :user, :status, :expiration_notifications_count, presence: true + validates :user, uniqueness: true + validates :expires_at, presence: true, on: :save + validates :status, inclusion: { in: STATUSES } + + scope :active, -> { where(status: :active) } + scope :expired, -> { where(status: :expired) } + + before_create :set_expiration_date + after_save :resave_user_articles + + def expired? + expires_at <= Time.current + end + + def active? + expires_at > Time.current + end + + def expire! + update!(expires_at: Time.current, status: :expired) + end + + def renew! + update!( + expires_at: 1.month.from_now, + status: :active, + expiration_notification_at: nil, + expiration_notifications_count: 0, + ) + end + + private + + def set_expiration_date + self.expires_at = 1.month.from_now + end + + # if the membership is new or the user flips from expired to active and viceversa, + # we need to resave all of the user's articles to make sure that the pro details + # that are cached with them are refreshed + def resave_user_articles + if saved_change_to_created_at? || + saved_change_to_expires_at? || + saved_change_to_status? + Users::ResaveArticlesJob.perform_later(user.id) + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 51b3ead99..9ef84236f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -49,6 +49,7 @@ class User < ApplicationRecord has_many :access_grants, class_name: "Doorkeeper::AccessGrant", foreign_key: :resource_owner_id, inverse_of: :resource_owner, dependent: :delete_all has_many :access_tokens, class_name: "Doorkeeper::AccessToken", foreign_key: :resource_owner_id, inverse_of: :resource_owner, dependent: :delete_all has_many :webhook_endpoints, class_name: "Webhook::Endpoint", foreign_key: :user_id, inverse_of: :user, dependent: :delete_all + has_one :pro_membership, dependent: :destroy mount_uploader :profile_image, ProfileImageUploader @@ -168,9 +169,12 @@ class User < ApplicationRecord per_environment: true, enqueue: true do attribute :user do - { username: user.username, + { + username: user.username, name: user.username, - profile_image_90: profile_image_90 } + profile_image_90: profile_image_90, + pro: user.pro? + } end attribute :title, :path, :tag_list, :main_image, :id, :featured, :published, :published_at, :featured_number, :comments_count, @@ -327,7 +331,7 @@ class User < ApplicationRecord end def pro? - has_role?(:pro) + pro_membership&.active? || has_role?(:pro) end def trusted @@ -401,8 +405,10 @@ class User < ApplicationRecord def resave_articles cache_buster = CacheBuster.new articles.find_each do |article| - cache_buster.bust(article.path) if article.path - cache_buster.bust(article.path + "?i=i") if article.path + if article.path + cache_buster.bust(article.path) + cache_buster.bust(article.path + "?i=i") + end article.save end end @@ -445,6 +451,10 @@ class User < ApplicationRecord currently_streaming_on == "twitch" end + def has_enough_credits?(num_credits_needed) + credits.unspent.size >= num_credits_needed + end + private def set_default_language @@ -521,7 +531,7 @@ class User < ApplicationRecord end def conditionally_resave_articles - delay.resave_articles if core_profile_details_changed? && !user.banned + Users::ResaveArticlesJob.perform_later(id) if core_profile_details_changed? && !user.banned end def bust_cache diff --git a/app/policies/organization_policy.rb b/app/policies/organization_policy.rb index df2a6a37c..471940b75 100644 --- a/app/policies/organization_policy.rb +++ b/app/policies/organization_policy.rb @@ -28,6 +28,6 @@ class OrganizationPolicy < ApplicationPolicy end def pro_org_user? - user.has_role?(:pro) && OrganizationMembership.exists?(user_id: user.id, organization_id: record.id) + user.pro? && OrganizationMembership.exists?(user_id: user.id, organization_id: record.id) end end diff --git a/app/policies/pro_membership_policy.rb b/app/policies/pro_membership_policy.rb new file mode 100644 index 000000000..e1816f4d1 --- /dev/null +++ b/app/policies/pro_membership_policy.rb @@ -0,0 +1,9 @@ +class ProMembershipPolicy < ApplicationPolicy + def create? + user.pro_membership.nil? && !user.has_role?(:pro) + end + + def update? + user.pro_membership.present? + end +end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index ae7186376..cb3ab26a3 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -44,7 +44,7 @@ class UserPolicy < ApplicationPolicy end def pro_user? - current_user? && user.has_role?(:pro) + current_user? && user.pro? end def moderation_routes? diff --git a/app/services/credits/buyer.rb b/app/services/credits/buyer.rb index 6cf4dbe9a..fdafc301f 100644 --- a/app/services/credits/buyer.rb +++ b/app/services/credits/buyer.rb @@ -11,7 +11,7 @@ module Credits end def call - return false unless has_available_credits? + return false unless purchaser.has_enough_credits?(cost) purchaser.credits.unspent.limit(cost).update_all( spent: true, @@ -19,15 +19,13 @@ module Credits purchase_type: purchase.class.name, purchase_id: purchase.id, ) + purchaser.save + true end private attr_reader :purchaser, :purchase, :cost - - def has_available_credits? - purchaser.credits.unspent.size >= cost - end end end diff --git a/app/services/payments/customer.rb b/app/services/payments/customer.rb new file mode 100644 index 000000000..e3edb39eb --- /dev/null +++ b/app/services/payments/customer.rb @@ -0,0 +1,57 @@ +module Payments + # A thin wrapper on Stripe Customers and Charges APIs + # see: , + # + class Customer + class << self + def get(customer_id) + request do + Stripe::Customer.retrieve(customer_id) + end + end + + def create(**params) + request do + Stripe::Customer.create(**params) + end + end + + def create_source(customer_id, token) + request do + Stripe::Customer.create_source(customer_id, source: token) + end + end + + def get_source(customer, source_id) + request do + customer.sources.retrieve(source_id) + end + end + + def charge(customer:, amount:, description:, card_id: nil) + source = card_id || customer.default_source + + request do + Stripe::Charge.create( + customer: customer.id, + source: source, + amount: amount, + description: description, + currency: "usd", + ) + end + end + + def request + yield + rescue Stripe::InvalidRequestError => e + raise InvalidRequestError, e.message + rescue Stripe::CardError => e + raise CardError, e.message + rescue Stripe::StripeError => e + Rails.logger.error(e) + raise PaymentsError, e.message + end + end + end +end diff --git a/app/services/pro_memberships/biller.rb b/app/services/pro_memberships/biller.rb new file mode 100644 index 000000000..c2d555bf0 --- /dev/null +++ b/app/services/pro_memberships/biller.rb @@ -0,0 +1,118 @@ +module ProMemberships + # Bills pro memberships that expire today + class Biller + def self.call(*args) + new(*args).call + end + + def call + relation = ProMembership.includes(user: [:credits]). + where("DATE(expires_at) = ?", Time.zone.today) + relation.find_each do |membership| + user = membership.user + cost = ProMembership::MONTHLY_COST + + if user.has_enough_credits?(cost) + renew_membership(membership, cost) + elsif membership.auto_recharge + if user.stripe_id_code + charge_user_and_renew_membership(membership, cost) + else + notify_admins(user, "auto recharge error: missing Stripe customer ID!") + end + else + expire_membership(membership) + notify_admins(user, "pro membership expired!") + end + end + end + + private + + def renew_membership(membership, cost) + ActiveRecord::Base.transaction do + user = membership.user + + membership.renew! + + success = Credits::Buyer.call( + purchaser: user, + purchase: membership, + cost: cost, + ) + + unless success + notify_admins("#{user.name}'s pro membership could not be renewed with enough credits!") + raise ActiveRecord::Rollback + end + + chat_channel = ChatChannel.find_by(slug: "pro-members") + if chat_channel && !chat_channel.chat_channel_memberships.exists?(user_id: user.id) + chat_channel.add_users(user) + end + + success + end + rescue StandardError => e + Rails.logger.error(e) + notify_admins(membership.user, "error: #{e.message}") + end + + def expire_membership(membership) + ActiveRecord::Base.transaction do + user = membership.user + + membership.expire! + + chat_channel = ChatChannel.find_by(slug: "pro-members") + chat_channel&.remove_user(user) + + user.save + end + rescue StandardError => e + Rails.logger.error(e) + notify_admins(membership.user, "error: #{e.message}") + end + + def recharge(membership, cost) + user = membership.user + + # charge customer for credits + customer = Payments::Customer.get(user.stripe_id_code) + Payments::Customer.charge( + customer: customer, + amount: ProMembership::MONTHLY_COST_USD, + description: "Purchase of #{cost} credits.", + ) + + # add credits + credits = Array.new(cost) { Credit.new(user: user, cost: 1) } + user.credits << credits + user.save! + end + + def charge_user_and_renew_membership(membership, cost) + user = membership.user + recharge(membership, cost) + renew_membership(membership, cost) + rescue Payments::PaymentsError => e + Rails.logger.error(e) + notify_admins(user, "payment error: #{e.message}") + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error(e) + notify_admins(user, "credits creation error: #{e.message}") + rescue StandardError => e + Rails.logger.error(e) + notify_admins(user, "error: #{e.message}") + end + + def notify_admins(user, message) + SlackBotPingJob.perform_later( + message: "ProMemberships::Biller: #{user.username}: #{message}", + channel: "pro-memberships", + username: "pro-memberships", + icon_emoji: ":fire:", + ) + end + end +end diff --git a/app/services/pro_memberships/creator.rb b/app/services/pro_memberships/creator.rb new file mode 100644 index 000000000..bd8143411 --- /dev/null +++ b/app/services/pro_memberships/creator.rb @@ -0,0 +1,42 @@ +module ProMemberships + class Creator + def initialize(user) + @user = user + end + + def self.call(*args) + new(*args).call + end + + def call + if purchase_pro_membership + ProMemberships::PopulateHistoryJob.perform_later(user.id) + + channel = ChatChannel.find_by(slug: "pro-members") + channel&.add_users(user) + + true + else + false + end + end + + private + + attr_reader :user + + def purchase_pro_membership + cost = ProMembership::MONTHLY_COST + return false unless user.has_enough_credits?(cost) + + ActiveRecord::Base.transaction do + pro_membership = ProMembership.create!(user: user) + Credits::Buyer.call( + purchaser: user, + purchase: pro_membership, + cost: cost, + ) + end + end + end +end diff --git a/app/services/pro_memberships/expiration_notifier.rb b/app/services/pro_memberships/expiration_notifier.rb new file mode 100644 index 000000000..fe9452d44 --- /dev/null +++ b/app/services/pro_memberships/expiration_notifier.rb @@ -0,0 +1,47 @@ +module ProMemberships + # Notifies Pro members with insufficient credits of the impeding expiration + class ExpirationNotifier + def initialize(expiration_date) + @expiration_date = expiration_date + end + + def self.call(*args) + new(*args).call + end + + def call + count = 0 + + # NOTE: naive implementation because this is supposed to be called every 24hrs + relation = ProMembership. + includes(user: [:credits]). + where("DATE(expires_at) = ?", expiration_date). + where(auto_recharge: false) + relation.find_each do |membership| + next if membership.user.has_enough_credits?(ProMembership::MONTHLY_COST) + + # NOTE: maybe we should "deliver_later" and update the flags there + ProMembershipMailer.expiring_membership(membership, expiration_date).deliver_now + + membership.expiration_notification_at = Time.current + membership.increment(:expiration_notifications_count) + membership.save! + + SlackBotPingJob.perform_later( + message: "#{membership.user.name}'s pro membership expires on #{expiration_date}", + channel: "pro-memberships", + username: "pro-memberships", + icon_emoji: ":fire:", + ) + + count += 1 + end + + count + end + + private + + attr_reader :expiration_date + end +end diff --git a/app/services/suggester/articles/boosted.rb b/app/services/suggester/articles/boosted.rb index 21d7e2c6f..91d1c7f00 100644 --- a/app/services/suggester/articles/boosted.rb +++ b/app/services/suggester/articles/boosted.rb @@ -10,7 +10,7 @@ module Suggester end def suggest - base_articles = Article.includes(:user). + base_articles = Article.includes(user: [:pro_membership]). includes(:organization). where.not(id: not_ids, organization_id: nil). cached_tagged_with(tag) diff --git a/app/services/suggester/articles/classic.rb b/app/services/suggester/articles/classic.rb index e84b026cd..71ee2930d 100644 --- a/app/services/suggester/articles/classic.rb +++ b/app/services/suggester/articles/classic.rb @@ -4,6 +4,7 @@ module Suggester MIN_REACTION_COUNT = Rails.env.production? ? 45 : 1 attr_accessor :input, :not_ids + def initialize(input = nil, options = {}) @input = input @not_ids = options[:not_ids] @@ -23,7 +24,7 @@ module Suggester tag_name = tag_names.sample Rails.cache.fetch("classic-article-for-tag-#{tag_name}}", expires_in: 90.minutes) do Article.published.cached_tagged_with(tag_name). - includes(:user). + includes(user: [:pro_membership]). limited_column_select. where(featured: true). where("positive_reactions_count > ?", MIN_REACTION_COUNT). diff --git a/app/services/suggester/articles/high_quality.rb b/app/services/suggester/articles/high_quality.rb index e3237a1a0..c985be3ee 100644 --- a/app/services/suggester/articles/high_quality.rb +++ b/app/services/suggester/articles/high_quality.rb @@ -9,7 +9,7 @@ module Suggester def suggest(num) Article.published.where(featured: true). - includes(:user). + includes(user: [:pro_membership]). limited_column_select. where("positive_reactions_count > ?", MIN_HQ_REACTION_COUNT). where.not(id: @not_ids). diff --git a/app/views/additional_content_boxes/_article_content_area.html.erb b/app/views/additional_content_boxes/_article_content_area.html.erb index b69fe4994..b6e428722 100644 --- a/app/views/additional_content_boxes/_article_content_area.html.erb +++ b/app/views/additional_content_boxes/_article_content_area.html.erb @@ -5,6 +5,7 @@ <%= article.user.username %> profile image <%= article.user.name %> + <%= render "shared/pro_checkmark" if article.user.pro? %>

diff --git a/app/views/additional_content_boxes/_article_followable_area.html.erb b/app/views/additional_content_boxes/_article_followable_area.html.erb index 8bc8fdff9..b8aa20391 100644 --- a/app/views/additional_content_boxes/_article_followable_area.html.erb +++ b/app/views/additional_content_boxes/_article_followable_area.html.erb @@ -30,6 +30,7 @@ " data-details="<%= followable&.slug if classification == "boosted" %>__PROFILE"><%= followable.name %> + <%= render "shared/pro_checkmark" if followable.pro? %> <% end %> diff --git a/config/initializers/stripe.rb b/config/initializers/stripe.rb index 687c3a088..e2f5ed49e 100644 --- a/config/initializers/stripe.rb +++ b/config/initializers/stripe.rb @@ -5,3 +5,7 @@ Rails.configuration.stripe = { } Stripe.api_key = Rails.configuration.stripe[:secret_key] + +if Rails.env.development? && Stripe.api_key.present? + Stripe.log_level = Stripe::LEVEL_INFO +end diff --git a/config/routes.rb b/config/routes.rb index 437b1b53e..bdf950813 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -173,6 +173,8 @@ Rails.application.routes.draw do resources :profile_pins, only: %i[create update] resources :partnerships, only: %i[index create show], param: :option resources :display_ad_events, only: [:create] + resource :pro_membership, path: :pro, only: %i[show create update] + resolve("ProMembership") { [:pro_membership] } # see https://guides.rubyonrails.org/routing.html#using-resolve get "/chat_channel_memberships/find_by_chat_channel_id" => "chat_channel_memberships#find_by_chat_channel_id" get "/listings/dashboard" => "classified_listings#dashboard" @@ -196,7 +198,6 @@ Rails.application.routes.draw do post "/chat_channels/create_chat" => "chat_channels#create_chat" post "/chat_channels/block_chat" => "chat_channels#block_chat" get "/live/:username" => "twitch_live_streams#show" - get "/pro" => "pro_accounts#index" post "/pusher/auth" => "pusher#auth" @@ -301,7 +302,7 @@ Rails.application.routes.draw do end end - get "/settings/(:tab)" => "users#edit" + get "/settings/(:tab)" => "users#edit", as: :user_settings get "/settings/:tab/:org_id" => "users#edit" get "/signout_confirm" => "users#signout_confirm" get "/dashboard" => "dashboards#show" diff --git a/db/migrate/20190711093610_create_pro_memberships.rb b/db/migrate/20190711093610_create_pro_memberships.rb new file mode 100644 index 000000000..f6ca1cf7f --- /dev/null +++ b/db/migrate/20190711093610_create_pro_memberships.rb @@ -0,0 +1,17 @@ +class CreateProMemberships < ActiveRecord::Migration[5.2] + def change + create_table :pro_memberships do |t| + t.references :user, foreign_key: true + t.string :status, default: "active" + t.datetime :expires_at, null: false + t.datetime :expiration_notification_at + t.integer :expiration_notifications_count, null: false, default: 0 + t.boolean :auto_recharge, null: false, default: false + + t.timestamps + end + add_index :pro_memberships, :status + add_index :pro_memberships, :expires_at + add_index :pro_memberships, :auto_recharge + end +end diff --git a/db/schema.rb b/db/schema.rb index 4ca5a1a8d..fef46ea16 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -792,6 +792,21 @@ ActiveRecord::Schema.define(version: 2019_09_18_104106) do t.datetime "updated_at", null: false end + create_table "pro_memberships", force: :cascade do |t| + t.boolean "auto_recharge", default: false, null: false + t.datetime "created_at", null: false + t.datetime "expiration_notification_at" + t.integer "expiration_notifications_count", default: 0, null: false + t.datetime "expires_at", null: false + t.string "status", default: "active" + t.datetime "updated_at", null: false + t.bigint "user_id" + t.index ["auto_recharge"], name: "index_pro_memberships_on_auto_recharge" + t.index ["expires_at"], name: "index_pro_memberships_on_expires_at" + t.index ["status"], name: "index_pro_memberships_on_status" + t.index ["user_id"], name: "index_pro_memberships_on_user_id" + end + create_table "profile_pins", force: :cascade do |t| t.datetime "created_at", null: false t.bigint "pinnable_id" diff --git a/lib/tasks/pro_memberships.rake b/lib/tasks/pro_memberships.rake new file mode 100644 index 000000000..e3c800f1e --- /dev/null +++ b/lib/tasks/pro_memberships.rake @@ -0,0 +1,19 @@ +# All of these tasks need to be scheduled daily +namespace :pro_memberships do + desc "Notify pro users with insufficient credits that their membership is about to expire in a week" + task notify_expirations_one_week_before: :environment do + num_notified = ProMemberships::ExpirationNotifier.call(1.week.from_now) + Rails.logger.info("Notified #{num_notified} Pro users...") + end + + desc "Notify pro users with insufficient credits that their membership is about to expire in a day" + task notify_expirations_one_day_before: :environment do + num_notified = ProMemberships::ExpirationNotifier.call(1.day.from_now) + Rails.logger.info("Notified #{num_notified} Pro users...") + end + + desc "Bill pro users and optionally charge their cards" + task bill_users: :environment do + ProMemberships::Biller.call + end +end diff --git a/spec/factories/pro_memberships.rb b/spec/factories/pro_memberships.rb new file mode 100644 index 000000000..0f0115f72 --- /dev/null +++ b/spec/factories/pro_memberships.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :pro_membership do + user + status { "active" } + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index f1438982d..829604af9 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -101,5 +101,11 @@ FactoryBot.define do user.update(articles_count: 1, comments_count: 1) end end + + trait :with_pro_membership do + after(:create) do |user| + create(:pro_membership, user: user) + end + end end end diff --git a/spec/jobs/pro_memberships/populate_history_job_spec.rb b/spec/jobs/pro_memberships/populate_history_job_spec.rb new file mode 100644 index 000000000..da500c980 --- /dev/null +++ b/spec/jobs/pro_memberships/populate_history_job_spec.rb @@ -0,0 +1,19 @@ +require "rails_helper" + +RSpec.describe ProMemberships::PopulateHistoryJob, type: :job do + include_examples "#enqueues_job", "pro_memberships_populate_history", 1 + + describe "#perform_now" do + let(:user) { create(:user) } + + before do + allow(user.page_views).to receive(:reindex!) + end + + it "indexes user page views" do + described_class.perform_now(user.id) do + expect(user.page_views).to have_received(:reindex!).once + end + end + end +end diff --git a/spec/jobs/users/resave_articles_job_spec.rb b/spec/jobs/users/resave_articles_job_spec.rb new file mode 100644 index 000000000..fd7ca3dd3 --- /dev/null +++ b/spec/jobs/users/resave_articles_job_spec.rb @@ -0,0 +1,19 @@ +require "rails_helper" + +RSpec.describe Users::ResaveArticlesJob, type: :job do + include_examples "#enqueues_job", "users_resave_articles", [1, 2] + + describe "#perform_now" do + let(:user) { create(:user) } + let(:article) { create(:article, user: user) } + + it "resaves articles" do + old_updated_at = article.updated_at + Timecop.freeze(Time.current) do + described_class.perform_now(user.id) do + expect(article.reload.updated_at > old_updated_at).to be(true) + end + end + end + end +end diff --git a/spec/mailers/previews/digest_mailer_preview.rb b/spec/mailers/previews/digest_mailer_preview.rb index f48215f73..1287dc485 100644 --- a/spec/mailers/previews/digest_mailer_preview.rb +++ b/spec/mailers/previews/digest_mailer_preview.rb @@ -1,4 +1,4 @@ -# Preview all emails at http://localhost:3000/rails/mailers/notify_mailer +# Preview all emails at http://localhost:3000/rails/mailers/digest_mailer class DigestMailerPreview < ActionMailer::Preview def digest_email DigestMailer.digest_email(User.last, Article.all) diff --git a/spec/mailers/previews/pro_membership_mailer_preview.rb b/spec/mailers/previews/pro_membership_mailer_preview.rb new file mode 100644 index 000000000..b0ddb8519 --- /dev/null +++ b/spec/mailers/previews/pro_membership_mailer_preview.rb @@ -0,0 +1,7 @@ +# Preview all emails at http://localhost:3000/rails/mailers/pro_membership_mailer +class ProMembershipMailerPreview < ActionMailer::Preview + def expiring_membership + pro_membership = ProMembership.find_or_create_by(user: User.last) + ProMembershipMailer.expiring_membership(pro_membership, 1.week.from_now) + end +end diff --git a/spec/mailers/previews/scholarship_mailer_preview.rb b/spec/mailers/previews/scholarship_mailer_preview.rb index 1b1486570..9d44fabc5 100644 --- a/spec/mailers/previews/scholarship_mailer_preview.rb +++ b/spec/mailers/previews/scholarship_mailer_preview.rb @@ -1,3 +1,4 @@ +# Preview all emails at http://localhost:3000/rails/mailers/scholarship_mailer class ScholarshipMailerPreview < ActionMailer::Preview def scholarship_awarded_email ScholarshipMailer.scholarship_awarded_email(User.last) diff --git a/spec/mailers/pro_membership_mailer_spec.rb b/spec/mailers/pro_membership_mailer_spec.rb new file mode 100644 index 000000000..2a0032d81 --- /dev/null +++ b/spec/mailers/pro_membership_mailer_spec.rb @@ -0,0 +1,68 @@ +require "rails_helper" + +RSpec.describe ProMembershipMailer, type: :mailer do + let(:pro_membership) { build_stubbed(:pro_membership) } + let(:user) { pro_membership.user } + + describe "#expiring_membership" do + it "works correctly" do + Timecop.freeze(Time.current) do + email = described_class.expiring_membership(pro_membership, 1.week.from_now) + + expect(email.subject).to eq("Your Pro Membership will expire in 7 days!") + expect(email.to).to eq([user.email]) + expect(email.from).to eq(["yo@dev.to"]) + end + end + + context "when generating the plain text email" do + it "includes the user's name" do + email = described_class.expiring_membership(pro_membership, 1.week.from_now) + expect(email.text_part.body).to include(user.name) + end + + it "includes the expiration date" do + Timecop.freeze(Time.current) do + expiration_date = 1.week.from_now + email = described_class.expiring_membership(pro_membership, expiration_date) + expect(email.text_part.body).to include(expiration_date.to_date.to_s(:long)) + end + end + + it "includes credits path" do + email = described_class.expiring_membership(pro_membership, 1.week.from_now) + expect(email.text_part.body).to include(credits_path) + end + + it "includes pro membership path" do + email = described_class.expiring_membership(pro_membership, 1.week.from_now) + expect(email.text_part.body).to include(user_settings_path("pro-membership")) + end + end + + context "when generating the html email" do + it "includes the user's name" do + email = described_class.expiring_membership(pro_membership, 1.week.from_now) + expect(email.html_part.body).to include(user.name) + end + + it "includes the expiration date" do + Timecop.freeze(Time.current) do + expiration_date = 1.week.from_now + email = described_class.expiring_membership(pro_membership, expiration_date) + expect(email.html_part.body).to include(expiration_date.to_date.to_s(:long)) + end + end + + it "includes credits path" do + email = described_class.expiring_membership(pro_membership, 1.week.from_now) + expect(email.html_part.body).to include(CGI.escape(credits_path)) + end + + it "includes pro membership path" do + email = described_class.expiring_membership(pro_membership, 1.week.from_now) + expect(email.html_part.body).to include(CGI.escape(user_settings_path("pro-membership"))) + end + end + end +end diff --git a/spec/models/article_spec.rb b/spec/models/article_spec.rb index e3cc7bac6..8a5d69154 100644 --- a/spec/models/article_spec.rb +++ b/spec/models/article_spec.rb @@ -393,21 +393,26 @@ RSpec.describe Article, type: :model do article = create(:article, user_id: user.id) expect(article.path).to eq("/#{article.username}/#{article.slug}") end + it "assigns cached_user_name on save" do article = create(:article, user_id: user.id) expect(article.cached_user_name).to eq(article.cached_user_name) end + it "assigns cached_user_username on save" do article = create(:article, user_id: user.id) expect(article.cached_user_username).to eq(article.user_username) end + it "assigns cached_user on save" do article = create(:article, user_id: user.id) expect(article.cached_user.username).to eq(article.user.username) expect(article.cached_user.name).to eq(article.user.name) expect(article.cached_user.profile_image_url).to eq(article.user.profile_image_url) expect(article.cached_user.profile_image_90).to eq(article.user.profile_image_90) + expect(article.cached_user.pro).to eq(article.user.pro?) end + it "assigns cached_organization on save" do organization = create(:organization) article = create(:article, user_id: user.id, organization_id: organization.id) @@ -416,6 +421,7 @@ RSpec.describe Article, type: :model do expect(article.cached_organization.slug).to eq(article.organization.slug) expect(article.cached_organization.profile_image_90).to eq(article.organization.profile_image_90) expect(article.cached_organization.profile_image_url).to eq(article.organization.profile_image_url) + expect(article.cached_organization.pro).to be(false) end end diff --git a/spec/models/chat_channel_spec.rb b/spec/models/chat_channel_spec.rb index 12e9fd0a4..e9e9226e8 100644 --- a/spec/models/chat_channel_spec.rb +++ b/spec/models/chat_channel_spec.rb @@ -31,4 +31,15 @@ RSpec.describe ChatChannel, type: :model do expect(chat_channel.active_users.size).to eq(1) expect(chat_channel.channel_users.size).to eq(1) end + + describe "#remove_user" do + let(:user) { create(:user) } + + it "removes a user from a channel" do + chat_channel.add_users(user) + expect(chat_channel.chat_channel_memberships.exists?(user_id: user.id)).to be(true) + chat_channel.remove_user(user) + expect(chat_channel.chat_channel_memberships.exists?(user_id: user.id)).to be(false) + end + end end diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index 60a25f281..934bd0b89 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -168,4 +168,26 @@ RSpec.describe Organization, type: :model do expect(article.cached_organization.slug).to eq new_slug end end + + describe "#has_enough_credits?" do + it "returns false if the user has less unspent credits than neeed" do + expect(organization.has_enough_credits?(1)).to be(false) + end + + it "returns true if the user has the exact amount of unspent credits" do + create(:credit, organization: organization, spent: false) + expect(organization.has_enough_credits?(1)).to be(true) + end + + it "returns true if the user has more unspent credits than needed" do + create_list(:credit, 2, organization: organization, spent: false) + expect(organization.has_enough_credits?(1)).to be(true) + end + end + + describe "#pro?" do + it "always returns false" do + expect(organization.pro?).to be(false) + end + end end diff --git a/spec/models/pro_membership_spec.rb b/spec/models/pro_membership_spec.rb new file mode 100644 index 000000000..068480d94 --- /dev/null +++ b/spec/models/pro_membership_spec.rb @@ -0,0 +1,138 @@ +require "rails_helper" + +RSpec.describe ProMembership, type: :model do + it { is_expected.to belong_to(:user) } + it { is_expected.to validate_presence_of(:user) } + it { is_expected.to validate_presence_of(:status) } + it { is_expected.to validate_presence_of(:expires_at).on(:save) } + it { is_expected.to validate_presence_of(:expiration_notifications_count) } + it { is_expected.to validate_inclusion_of(:status).in_array(ProMembership::STATUSES) } + it { is_expected.to have_db_index(:status) } + it { is_expected.to have_db_index(:expires_at) } + + describe "constants" do + it "has the correct values for constants" do + expect(ProMembership::STATUSES).to eq(%w[active expired]) + expect(ProMembership::MONTHLY_COST).to eq(5) + expect(ProMembership::MONTHLY_COST_USD).to eq(25) + end + end + + describe "defaults" do + it "has the correct defaults" do + pm = ProMembership.new + expect(pm.status).to eq("active") + expect(pm.expires_at).to be(nil) + expect(pm.expiration_notification_at).to be(nil) + expect(pm.expiration_notifications_count).to eq(0) + expect(pm.auto_recharge).to be(false) + end + end + + describe "creation" do + it "sets expires_at to a month from now" do + Timecop.freeze(Time.current) do + pm = ProMembership.create!(user: create(:user)) + expect(pm.expires_at.to_i).to eq(1.month.from_now.to_i) + end + end + end + + describe "#expired?" do + it "returns false if expires_at is in the future" do + Timecop.freeze(Time.current) do + pm = build(:pro_membership, expires_at: 5.seconds.from_now) + expect(pm.expired?).to be(false) + end + end + + it "returns true if expires_at is in the past" do + Timecop.freeze(Time.current) do + pm = build(:pro_membership, expires_at: 5.seconds.ago) + expect(pm.expired?).to be(true) + end + end + end + + describe "#active?" do + it "returns true if expires_at is in the future" do + Timecop.freeze(Time.current) do + pm = build(:pro_membership, expires_at: 5.seconds.from_now) + expect(pm.active?).to be(true) + end + end + + it "returns false if expires_at is in the past" do + Timecop.freeze(Time.current) do + pm = build(:pro_membership, expires_at: 5.seconds.ago) + expect(pm.active?).to be(false) + end + end + end + + describe "#expire!" do + let(:pro_membership) { create(:pro_membership) } + + it "sets expires_at to the current time" do + Timecop.freeze(Time.current) do + pro_membership.expire! + expect(pro_membership.reload.expires_at.to_i).to eq(Time.current.to_i) + end + end + + it "sets status to expired" do + Timecop.freeze(Time.current) do + pro_membership.expire! + expect(pro_membership.reload.status).to eq("expired") + end + end + + it "makes the membership expired" do + Timecop.freeze(Time.current) do + pro_membership.expire! + expect(pro_membership.reload.expired?).to be(true) + end + end + end + + describe "#renew!" do + let(:pro_membership) { create(:pro_membership) } + + it "sets expires_at to a month from now" do + Timecop.freeze(Time.current) do + pro_membership.renew! + expect(pro_membership.reload.expires_at.to_i).to eq(1.month.from_now.to_i) + end + end + + it "sets status to active" do + Timecop.freeze(Time.current) do + pro_membership.renew! + expect(pro_membership.reload.status).to eq("active") + end + end + + it "sets expiration_notification_at to nil" do + Timecop.freeze(Time.current) do + pro_membership.update_column(:expiration_notification_at, Time.current) + pro_membership.renew! + expect(pro_membership.reload.expiration_notification_at).to be(nil) + end + end + + it "sets expiration_notifications_count to 0" do + Timecop.freeze(Time.current) do + pro_membership.update_column(:expiration_notifications_count, 1) + pro_membership.renew! + expect(pro_membership.reload.expiration_notifications_count).to eq(0) + end + end + + it "makes the membership active" do + Timecop.freeze(Time.current) do + pro_membership.renew! + expect(pro_membership.reload.active?).to be(true) + end + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index f8c524902..740646427 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -30,6 +30,7 @@ RSpec.describe User, type: :model do it { is_expected.to have_many(:chat_channels).through(:chat_channel_memberships) } it { is_expected.to have_many(:push_notification_subscriptions).dependent(:destroy) } it { is_expected.to have_many(:notification_subscriptions).dependent(:destroy) } + it { is_expected.to have_one(:pro_membership).dependent(:destroy) } it { is_expected.to validate_uniqueness_of(:username).case_insensitive } it { is_expected.to validate_uniqueness_of(:github_username).allow_nil } it { is_expected.to validate_uniqueness_of(:twitter_username).allow_nil } @@ -674,14 +675,29 @@ RSpec.describe User, type: :model do end describe "#pro?" do + let(:user) { create(:user) } + it "returns false if the user is not a pro" do expect(user.pro?).to be(false) end - it "returns true if the user is a pro" do + it "returns true if the user has the pro role" do user.add_role(:pro) expect(user.pro?).to be(true) end + + it "returns true if the user has an active pro membership" do + create(:pro_membership, user: user) + expect(user.pro?).to be(true) + end + + it "returns false if the user has an expired pro membership" do + Timecop.freeze(Time.current) do + membership = create(:pro_membership, user: user) + membership.expire! + expect(user.pro?).to be(false) + end + end end describe "when agolia auto-indexing/removal is triggered" do @@ -693,4 +709,20 @@ RSpec.describe User, type: :model do expect { user.destroy }.not_to have_enqueued_job.on_queue("algoliasearch") end end + + describe "#has_enough_credits?" do + it "returns false if the user has less unspent credits than neeed" do + expect(user.has_enough_credits?(1)).to be(false) + end + + it "returns true if the user has the exact amount of unspent credits" do + create(:credit, user: user, spent: false) + expect(user.has_enough_credits?(1)).to be(true) + end + + it "returns true if the user has more unspent credits than needed" do + create_list(:credit, 2, user: user, spent: false) + expect(user.has_enough_credits?(1)).to be(true) + end + end end diff --git a/spec/requests/credits_spec.rb b/spec/requests/credits_spec.rb index 88ea3a2e0..935812699 100644 --- a/spec/requests/credits_spec.rb +++ b/spec/requests/credits_spec.rb @@ -27,7 +27,7 @@ RSpec.describe "Credits", type: :request do expect(response.body).to include(CGI.escapeHTML(org.name)) end - context "when the user has made purchases" do + context "when the user has made purchases that will appear in the ledger" do let(:params) { { spent: true, spent_at: Time.current } } it "shows listing purchases" do @@ -96,6 +96,19 @@ RSpec.describe "Credits", type: :request do expect(response.body).to include("Purchase history") expect(response.body).to include("Miscellaneous items") end + + it "shows a pro membership purchase" do + pro_membership = create(:pro_membership, user: user) + purchase_params = { user: user, purchase_type: pro_membership.class.name, purchase_id: pro_membership.id } + create(:credit, params.merge(purchase_params)) + + sign_in user + get credits_path + + expect(response.body).to include("Purchase history") + expect(response.body).to include("Pro Membership") + expect(response.body).to include(pro_membership_path) + end end end diff --git a/spec/requests/dashboard_spec.rb b/spec/requests/dashboard_spec.rb index 7059aa371..e72f18544 100644 --- a/spec/requests/dashboard_spec.rb +++ b/spec/requests/dashboard_spec.rb @@ -197,6 +197,15 @@ RSpec.describe "Dashboards", type: :request do end end + context "when user has a pro membership" do + it "shows page properly" do + create(:pro_membership, user: user) + sign_in user + get "/dashboard/pro" + expect(response.body).to include("pro") + end + end + context "when user has pro permission and is an org admin" do it "shows page properly" do org = create :organization diff --git a/spec/requests/history_spec.rb b/spec/requests/history_spec.rb index 1c0c5ea6f..f848b061c 100644 --- a/spec/requests/history_spec.rb +++ b/spec/requests/history_spec.rb @@ -3,6 +3,7 @@ require "rails_helper" RSpec.describe "History", type: :request do let(:user) { create(:user) } let(:pro_user) { create(:user, :pro) } + let(:pro_membership_user) { create(:user, :with_pro_membership) } describe "GET /history" do it "does not allow access to a regular user" do @@ -16,5 +17,12 @@ RSpec.describe "History", type: :request do expect(response).to have_http_status(:ok) expect(response.body).to include("History") end + + it "allows access to a user with a pro membership" do + sign_in pro_membership_user + get history_path + expect(response).to have_http_status(:ok) + expect(response.body).to include("History") + end end end diff --git a/spec/requests/partnerships_spec.rb b/spec/requests/partnerships_spec.rb index 1bb13fac7..d7964e5ca 100644 --- a/spec/requests/partnerships_spec.rb +++ b/spec/requests/partnerships_spec.rb @@ -1,7 +1,7 @@ # rubocop:disable RSpec/NestedGroups require "rails_helper" -RSpec.describe "Pages", type: :request do +RSpec.describe "Partnerships", type: :request do describe "GET /partnerships" do context "when user is logged in" do before do diff --git a/spec/requests/pro_accounts_spec.rb b/spec/requests/pro_accounts_spec.rb deleted file mode 100644 index 69add8ce3..000000000 --- a/spec/requests/pro_accounts_spec.rb +++ /dev/null @@ -1,10 +0,0 @@ -require "rails_helper" - -RSpec.describe "PollVotes", type: :request do - describe "GET /pro" do - it "returns pro lander" do - get "/pro" - expect(response.body).to include("Like a Pro") - end - end -end diff --git a/spec/requests/pro_memberships_spec.rb b/spec/requests/pro_memberships_spec.rb new file mode 100644 index 000000000..d210a4f16 --- /dev/null +++ b/spec/requests/pro_memberships_spec.rb @@ -0,0 +1,187 @@ +require "rails_helper" + +RSpec.describe "Pro Memberships", type: :request do + describe "GET /pro" do + it "returns Pro landing page" do + get pro_membership_path + expect(response.body).to include("Like a Pro") + end + end + + describe "POST /pro" do + let(:user) { create(:user) } + + context "when the user is not logged in" do + it "redirects to the sign up page" do + post pro_membership_path + expect(response).to redirect_to(sign_up_path) + end + end + + context "when the user is logged in and already has a pro memberships" do + before do + sign_in user + end + + it "does not authorize creation if it has an active membership" do + create(:pro_membership, user: user) + expect do + post pro_membership_path + end.to raise_error(Pundit::NotAuthorizedError) + end + + it "does not authorize creation if it has an expired membership" do + Timecop.freeze(Time.current) do + membership = create(:pro_membership, user: user) + membership.expire! + + expect do + post pro_membership_path + end.to raise_error(Pundit::NotAuthorizedError) + end + end + + it "does not authorize creation if the user as a pro role" do + user.add_role(:pro) + expect do + post pro_membership_path + end.to raise_error(Pundit::NotAuthorizedError) + end + end + + context "when the user is logged in without a pro membership and enough credits" do + before do + sign_in user + create_list(:credit, ProMembership::MONTHLY_COST, user: user) + end + + it "creates an active pro membership" do + expect do + post pro_membership_path + end.to change(ProMembership, :count).by(1) + expect(user.reload.pro_membership.active?).to be(true) + end + + it "buys the pro membership with the correct amount of credits" do + expect do + post pro_membership_path + end.to change(user.credits.spent, :count).by(ProMembership::MONTHLY_COST) + end + + it "enqueues a job to populate the history" do + assert_enqueued_with( + job: ProMemberships::PopulateHistoryJob, + args: [user.id], + queue: "pro_memberships_populate_history", + ) do + post pro_membership_path + end + end + + it "enqueues a job to bust the user's cache" do + ActiveJob::Base.queue_adapter.enqueued_jobs.clear # make sure it hasn't been previously queued + assert_enqueued_with( + job: Users::BustCacheJob, + args: [user.id], + queue: "users_bust_cache", + ) do + post pro_membership_path + end + end + + it "enqueues a job to bust the user's articles caches" do + assert_enqueued_with( + job: Users::ResaveArticlesJob, + args: [user.id], + queue: "users_resave_articles", + ) do + post pro_membership_path + end + end + + it "adds the user to the pro members channel" do + create(:chat_channel, channel_type: "invite_only", slug: "pro-members") + post pro_membership_path + expect(user.reload.chat_channels.exists?(slug: "pro-members")).to be(true) + end + + it "redirects to the pro membership settings page with a notice" do + post pro_membership_path + expect(response).to redirect_to(user_settings_path("pro-membership")) + expect(flash[:settings_notice]).to eq("You are now a Pro!") + end + end + + context "when the user is logged in without a pro membership and not enough credits" do + before do + sign_in user + end + + it "does not create an active pro membership" do + expect do + post pro_membership_path + end.to change(ProMembership, :count).by(0) + end + + it "does not subtract credits" do + expect do + post pro_membership_path + end.to change(user.credits.spent, :count).by(0) + end + + it "redirects to the pro membership settings page with an error message" do + post pro_membership_path + expect(response).to redirect_to(user_settings_path("pro-membership")) + expect(flash[:error]).to eq("You don't have enough credits!") + end + end + end + + describe "PUT /pro" do + let(:user) { create(:user) } + + context "when the user is not logged in" do + it "redirects to the sign up page" do + put pro_membership_path + expect(response).to redirect_to(sign_up_path) + end + end + + context "when the user is logged in without a pro membership" do + before do + sign_in user + end + + it "does not authorize the operation" do + expect do + put pro_membership_path + end.to raise_error(Pundit::NotAuthorizedError) + end + end + + context "when the user is logged in with a pro membership" do + before do + sign_in user + end + + it "works correctly" do + create(:pro_membership, user: user) + put pro_membership_path, params: { pro_membership: { auto_recharge: true } } + expect(flash[:settings_notice]).to eq("Your membership has been updated!") + expect(response).to redirect_to(user_settings_path("pro-membership")) + end + + it "activates auto recharge" do + pro_membership = create(:pro_membership, user: user) + put pro_membership_path, params: { pro_membership: { auto_recharge: true } } + expect(pro_membership.reload.auto_recharge).to be(true) + end + + it "deactivates auto recharge" do + pro_membership = create(:pro_membership, user: user, auto_recharge: true) + put pro_membership_path, params: { pro_membership: { auto_recharge: false } } + expect(pro_membership.reload.auto_recharge).to be(false) + end + end + end +end diff --git a/spec/services/credits/buyer_spec.rb b/spec/services/credits/buyer_spec.rb index 2e1bdaf5d..c42e1dd1a 100644 --- a/spec/services/credits/buyer_spec.rb +++ b/spec/services/credits/buyer_spec.rb @@ -39,5 +39,21 @@ RSpec.describe Credits::Buyer do expect(res).to be(true) end.to change(org.credits.spent, :count) end + + it "updates the updated_at of the user" do + create_list(:credit, 2, user: user) + Timecop.freeze(Time.current) do + described_class.call(purchaser: user, purchase: listing, cost: 2) + expect(user.reload.updated_at.to_i >= Time.current.to_i).to be(true) + end + end + + it "updates the updated_at of the organization" do + create_list(:credit, 2, organization: org) + Timecop.freeze(Time.current) do + described_class.call(purchaser: org, purchase: listing, cost: 2) + expect(org.reload.updated_at.to_i >= Time.current.to_i).to be(true) + end + end end end diff --git a/spec/services/payments/customer_spec.rb b/spec/services/payments/customer_spec.rb new file mode 100644 index 000000000..fc7a31f3a --- /dev/null +++ b/spec/services/payments/customer_spec.rb @@ -0,0 +1,97 @@ +require "rails_helper" + +RSpec.describe Payments::Customer do + before do + StripeMock.start + end + + after do + StripeMock.stop + end + + describe ".get" do + it "retrieves an existing customer" do + customer = Stripe::Customer.create + expect(described_class.get(customer.id)).to be_present + end + + it "raises Payments::CustomerNotFoundError if the customer does not exist" do + expect { described_class.get("foobar") }.to raise_error(Payments::InvalidRequestError) + end + + it "raises Payments::PaymentsError for any other known error" do + allow(Stripe::Customer).to receive(:retrieve).with("foobar").and_raise(Stripe::StripeError) + expect { described_class.get("foobar") }.to raise_error(Payments::PaymentsError) + end + end + + describe ".create" do + it "creates a new customer" do + expect(described_class.create).to be_present + end + + it "raises an error if anything in the params is invalid" do + error = Stripe::InvalidRequestError.new("message", :email) + allow(Stripe::Customer).to receive(:create).and_raise(error) + expect { described_class.create(email: "foobar") }.to raise_error(Payments::InvalidRequestError) + end + + it "raises Payments::PaymentsError for any other known error" do + allow(Stripe::Customer).to receive(:create).with(email: "foobar"). + and_raise(Stripe::StripeError) + expect { described_class.create(email: "foobar") }.to raise_error(Payments::PaymentsError) + end + end + + describe ".create_source" do + it "creates a new source" do + customer = Stripe::Customer.create + expect(described_class.create_source(customer.id, "token")).to be_present + end + + it "raises an error if anything in the params is invalid" do + error = Stripe::InvalidRequestError.new("message", :token) + allow(Stripe::Customer).to receive(:create_source).and_raise(error) + expect { described_class.create_source("customer_id", "token") }.to raise_error(Payments::InvalidRequestError) + end + + it "raises Payments::PaymentsError for any other known error" do + allow(Stripe::Customer).to receive(:create_source).and_raise(Stripe::StripeError) + expect { described_class.create_source("customer_id", "token") }.to raise_error(Payments::PaymentsError) + end + end + + describe ".charge" do + let(:stripe_helper) { StripeMock.create_test_helper } + + it "charges a customer" do + customer = Stripe::Customer.create + charge = described_class.charge(customer: customer, amount: 1, description: "Test charge") + expect(charge).to be_present + end + + it "charges a customer with an explicit card id" do + customer = Stripe::Customer.create + token = stripe_helper.generate_card_token + card = Stripe::Customer.create_source(customer.id, source: token) + charge = described_class.charge( + customer: customer, amount: 1, description: "Test charge", card_id: card.id, + ) + expect(charge).to be_present + end + + it "raises a card error if the card has any troubles" do + StripeMock.prepare_card_error(:expired_card) + + customer = Stripe::Customer.create + token = stripe_helper.generate_card_token + card = Stripe::Customer.create_source(customer.id, source: token) + + expect do + described_class.charge( + customer: customer, amount: 1, description: "Test charge", card_id: card.id, + ) + end.to raise_error(Payments::CardError) + end + end +end diff --git a/spec/services/pro_memberships/biller_spec.rb b/spec/services/pro_memberships/biller_spec.rb new file mode 100644 index 000000000..20ad0329e --- /dev/null +++ b/spec/services/pro_memberships/biller_spec.rb @@ -0,0 +1,264 @@ +require "rails_helper" + +RSpec.describe ProMemberships::Biller, type: :service do + context "when there are expiring memberships with enough credits" do + let(:pro_membership) { create(:pro_membership) } + let(:user) { pro_membership.user } + + before do + create_list(:credit, ProMembership::MONTHLY_COST, user: user) + end + + it "renews the membership" do + Timecop.travel(pro_membership.expires_at) do + described_class.call + pro_membership.reload + expect(pro_membership.expires_at.to_i).to eq(1.month.from_now.to_i) + expect(pro_membership.status).to eq("active") + end + end + + it "subtracts the correct amount of credits" do + Timecop.travel(pro_membership.expires_at) do + expect do + described_class.call + end.to change(user.credits.spent, :size).by(ProMembership::MONTHLY_COST) + end + end + + it "adds the user back to the pro members chat channel" do + create(:chat_channel, slug: "pro-members", channel_type: "invite_only") + + Timecop.travel(pro_membership.expires_at) do + described_class.call + expect(user.reload.chat_channels.exists?(slug: "pro-members")).to be(true) + end + end + + it "does not fail if the user is already in the pro members chat channel" do + cc = create(:chat_channel, slug: "pro-members", channel_type: "invite_only") + cc.add_users(user) + + allow(Rails.logger).to receive(:error) + Timecop.travel(pro_membership.expires_at) do + described_class.call + expect(Rails.logger).not_to have_received(:error) + expect(user.reload.chat_channels.exists?(slug: "pro-members")).to be(true) + end + end + + it "enqueues a job to bust the users caches" do + ActiveJob::Base.queue_adapter.enqueued_jobs.clear # make sure it hasn't been previously queued + Timecop.travel(pro_membership.expires_at) do + assert_enqueued_with( + job: Users::BustCacheJob, + args: [user.id], + queue: "users_bust_cache", + ) do + described_class.call + end + end + end + + it "enqueues a job to bust the users articles caches" do + Timecop.travel(pro_membership.expires_at) do + assert_enqueued_with( + job: Users::ResaveArticlesJob, + args: [user.id], + queue: "users_resave_articles", + ) do + described_class.call + end + end + end + + context "when an error occurs" do + it "does not renew the membership" do + Timecop.travel(pro_membership.expires_at) do + pro_membership.expire! + allow(Credits::Buyer).to receive(:call).and_raise(StandardError) + described_class.call + expect(pro_membership.expires_at.to_i).to be(Time.current.to_i) + expect(pro_membership.status).to eq("expired") + end + end + + it "does not subtract credits" do + Timecop.travel(pro_membership.expires_at) do + allow(Credits::Buyer).to receive(:call).and_raise(StandardError) + expect do + described_class.call + end.to change(user.credits.spent, :size).by(0) + end + end + + it "notifies the admins about the error" do + Timecop.travel(pro_membership.expires_at) do + allow(Credits::Buyer).to receive(:call).and_raise(StandardError) + assert_enqueued_with(job: SlackBotPingJob) do + described_class.call + end + end + end + end + end + + context "when there are expiring memberships with insufficient credits" do + let(:pro_membership) { create(:pro_membership) } + let(:user) { pro_membership.user } + + it "expires the membership" do + Timecop.travel(pro_membership.expires_at) do + described_class.call + pro_membership.reload + expect(pro_membership.expired?).to be(true) + expect(pro_membership.status).to eq("expired") + end + end + + it "removes the user from the pro members chat channel" do + cc = create(:chat_channel, slug: "pro-members", channel_type: "invite_only") + cc.add_users(user) + + Timecop.travel(pro_membership.expires_at) do + described_class.call + expect(user.reload.chat_channels.exists?(slug: "pro-members")).to be(false) + end + end + + it "notifies the admins about the expiration" do + Timecop.travel(pro_membership.expires_at) do + assert_enqueued_with(job: SlackBotPingJob) do + described_class.call + end + end + end + + it "enqueues a job to bust the users caches" do + ActiveJob::Base.queue_adapter.enqueued_jobs.clear # make sure it hasn't been previously queued + Timecop.travel(pro_membership.expires_at) do + assert_enqueued_with( + job: Users::BustCacheJob, + args: [user.id], + queue: "users_bust_cache", + ) do + described_class.call + end + end + end + + it "enqueues a job to bust the users articles caches" do + Timecop.travel(pro_membership.expires_at) do + assert_enqueued_with( + job: Users::ResaveArticlesJob, + args: [user.id], + queue: "users_resave_articles", + ) do + described_class.call + end + end + end + end + + context "when there are expiring memberships with insufficient credits and auto recharge" do + let(:pro_membership) { create(:pro_membership, auto_recharge: true) } + let(:user) { pro_membership.user } + + context "when the user has an associated customer" do + before do + StripeMock.start + customer = Payments::Customer.create + user.update_columns(stripe_id_code: customer.id) + end + + after do + StripeMock.stop + end + + it "charges the customer" do + customer = Payments::Customer.get(user.stripe_id_code) + allow(Payments::Customer).to receive(:charge) + Timecop.travel(pro_membership.expires_at) do + described_class.call + end + + expect(Payments::Customer).to have_received(:charge).with( + customer: customer, + amount: ProMembership::MONTHLY_COST_USD, + description: "Purchase of 5 credits.", + ) + end + + it "adds the correct amount of credits" do + Timecop.travel(pro_membership.expires_at) do + # we cannot use "expect.to change" because of how activerecord-import works + old_num_credits = user.credits.size + described_class.call + expect(user.reload.credits.size).to be(old_num_credits + ProMembership::MONTHLY_COST) + end + end + + it "renews the membership" do + Timecop.travel(pro_membership.expires_at) do + described_class.call + pro_membership.reload + expect(pro_membership.expires_at.to_i).to eq(1.month.from_now.to_i) + expect(pro_membership.status).to eq("active") + end + end + + it "spends the correct amount of credits" do + Timecop.travel(pro_membership.expires_at) do + # we cannot use "expect.to change" because of how activerecord-import works + old_num_credits = user.reload.credits.spent.size + described_class.call + expect(user.reload.credits.spent.size).to eq(old_num_credits + ProMembership::MONTHLY_COST) + end + end + + it "enqueues a job to bust the users caches" do + ActiveJob::Base.queue_adapter.enqueued_jobs.clear # make sure it hasn't been previously queued + Timecop.travel(pro_membership.expires_at) do + assert_enqueued_with( + job: Users::BustCacheJob, + args: [user.id], + queue: "users_bust_cache", + ) do + described_class.call + end + end + end + + it "enqueues a job to bust the users articles caches" do + Timecop.travel(pro_membership.expires_at) do + assert_enqueued_with( + job: Users::ResaveArticlesJob, + args: [user.id], + queue: "users_resave_articles", + ) do + described_class.call + end + end + end + end + + context "when the user has no associated customer" do + it "notifies the admins about the problem" do + allow(user).to receive(:stripe_id_code).and_return(nil) + Timecop.travel(pro_membership.expires_at) do + assert_enqueued_with(job: SlackBotPingJob) do + described_class.call + end + end + end + + it "does not change the number of credits" do + Timecop.travel(pro_membership.expires_at) do + expect do + described_class.call + end.to change(user.credits, :size).by(0) + end + end + end + end +end diff --git a/spec/services/pro_memberships/expiration_notifier_spec.rb b/spec/services/pro_memberships/expiration_notifier_spec.rb new file mode 100644 index 000000000..4fdb7a9d9 --- /dev/null +++ b/spec/services/pro_memberships/expiration_notifier_spec.rb @@ -0,0 +1,66 @@ +require "rails_helper" + +RSpec.describe ProMemberships::ExpirationNotifier, type: :service do + context "when there are expiring memberships with enough credits" do + let(:pro_membership) { create(:pro_membership) } + let(:user) { pro_membership.user } + + it "does not deliver an email to the user" do + create_list(:credit, ProMembership::MONTHLY_COST, user: user) + Timecop.travel(pro_membership.expires_at - 1.week) do + assert_emails 0 do + described_class.call(1.week.from_now) + end + end + end + end + + context "when there are expiring memberships with insufficient credits" do + let(:pro_membership) { create(:pro_membership) } + + it "delivers an email to the user" do + Timecop.travel(pro_membership.expires_at - 1.week) do + assert_emails 1 do + described_class.call(1.week.from_now) + end + end + end + + it "sets the expiration notification datetime" do + Timecop.travel(pro_membership.expires_at - 1.week) do + described_class.call(1.week.from_now) + expect(pro_membership.reload.expiration_notification_at.to_i).to eq(Time.current.to_i) + end + end + + it "increments the notifications count" do + Timecop.travel(pro_membership.expires_at - 1.week) do + expect(pro_membership.expiration_notifications_count).to eq(0) + described_class.call(1.week.from_now) + expect(pro_membership.reload.expiration_notifications_count).to eq(1) + end + end + + it "enqueus a slack bot ping job" do + Timecop.travel(pro_membership.expires_at - 1.week) do + assert_enqueued_with(job: SlackBotPingJob) do + described_class.call(1.week.from_now) + end + end + end + end + + context "when there are expiring memberships with insufficient credits and auto recharge" do + let(:pro_membership) { create(:pro_membership) } + let(:user) { pro_membership.user } + + it "does not deliver an email to the user" do + pro_membership.update_columns(auto_recharge: true) + Timecop.travel(pro_membership.expires_at - 1.week) do + assert_emails 0 do + described_class.call(1.week.from_now) + end + end + end + end +end diff --git a/spec/system/pro_memberships/user_creates_a_pro_membership_spec.rb b/spec/system/pro_memberships/user_creates_a_pro_membership_spec.rb new file mode 100644 index 000000000..8921e31cd --- /dev/null +++ b/spec/system/pro_memberships/user_creates_a_pro_membership_spec.rb @@ -0,0 +1,19 @@ +require "rails_helper" + +RSpec.describe "Creates a Pro Membership", type: :system do + let(:user) { create(:user) } + + context "when signed in as a regular user with enough credits" do + before do + create_list(:credit, 5, user: user) + sign_in user + end + + it "makes the user become a pro" do + visit "/settings/pro-membership" + label = "Become a Pro member" + click_on label + expect(page).to have_content("You are now a Pro!") + end + end +end diff --git a/spec/system/pro_memberships/user_visits_pro_page_spec.rb b/spec/system/pro_memberships/user_visits_pro_page_spec.rb new file mode 100644 index 000000000..010c403e1 --- /dev/null +++ b/spec/system/pro_memberships/user_visits_pro_page_spec.rb @@ -0,0 +1,67 @@ +require "rails_helper" + +RSpec.describe "Visits Pro Memberships page", type: :system do + let(:user) { create(:user) } + + context "when not signed in" do + it "does not say you are a member" do + visit "/pro" + expect(page).not_to have_content("View your Pro Membership") + end + + it "does not ask to become a pro member" do + visit "/pro" + expect(page).not_to have_content("Become a Pro member") + end + end + + context "when signed in as a regular user" do + before do + sign_in user + end + + it "does not say you are a member" do + visit "/pro" + expect(page).not_to have_content("View your Pro Membership") + end + + it "asks to become a pro member" do + visit "/pro" + expect(page).to have_content("Become a Pro member") + end + end + + context "when signed in as as user with a pro role" do + before do + user.add_role(:pro) + sign_in user + end + + it "says you are a member" do + visit "/pro" + expect(page).to have_content("View your Pro Membership") + end + + it "does not ask to become a pro member" do + visit "/pro" + expect(page).not_to have_content("Become a Pro member") + end + end + + context "when signed in as as user with a pro membership" do + before do + create(:pro_membership, user: user, expires_at: 1.month.from_now) + sign_in user + end + + it "says you are a member" do + visit "/pro" + expect(page).to have_content("View your Pro Membership") + end + + it "does not ask to become a pro member" do + visit "/pro" + expect(page).not_to have_content("Become a Pro member") + end + end +end diff --git a/spec/system/pro_memberships/user_visits_settings_pro_membership_page_spec.rb b/spec/system/pro_memberships/user_visits_settings_pro_membership_page_spec.rb new file mode 100644 index 000000000..97073856e --- /dev/null +++ b/spec/system/pro_memberships/user_visits_settings_pro_membership_page_spec.rb @@ -0,0 +1,86 @@ +require "rails_helper" + +RSpec.describe "Visits Pro Membership settings page", type: :system do + let(:user) { create(:user) } + let(:cost) { ProMembership::MONTHLY_COST } + + context "when signed in as a regular user" do + before do + sign_in user + end + + it "contains the correct info if you have no credits" do + visit "/settings/pro-membership" + + expect(page).to have_content("Pro Membership is #{cost} credits per month") + expect(page).to have_content("You currently have no credits, you need #{cost} to become a Pro member") + expect(page).to have_link("Purchase credits", href: "/credits/purchase") + expect(page).to have_link("Pro Membership page", href: "/pro") + end + + it "contains the correct info if you have some credits but not enough" do + create(:credit, user: user) + + visit "/settings/pro-membership" + expect(page).to have_content("Pro Membership is #{cost} credits per month") + expect(page).to have_content( + "You currently have #{cost - 1} credits, you need #{cost} to become a Pro member", + ) + expect(page).to have_link("Purchase credits", href: "/credits/purchase") + expect(page).to have_link("Pro Membership page", href: "/pro") + end + + it "contains the correct info if you have enough credits" do + create_list(:credit, cost, user: user) + + visit "/settings/pro-membership" + expect(page).to have_content("Pro Membership is #{cost} credits per month") + expect(page).to have_content("You currently have #{user.credits.unspent.size} available credits") + expect(page).not_to have_link("Purchase credits", href: "/credits/purchase") + expect(page).to have_selector("input[type=submit][value='Become a Pro member']") + expect(page).to have_link("Pro Membership page", href: "/pro") + end + end + + context "when signed in as as user with a pro role" do + before do + user.add_role(:pro) + sign_in user + end + + it "shows the status of the membership" do + visit "/settings/pro-membership" + + expect(page).to have_content("Status") + expect(page).to have_content("Expiration date") + expect(page).to have_content("Never") + expect(page).to have_link("Pro Membership page", href: "/pro") + end + end + + context "when signed in as as user with a pro membership" do + before do + create(:pro_membership, user: user, expires_at: 1.month.from_now) + sign_in user + end + + it "shows the status of the membership" do + visit "/settings/pro-membership" + + expect(page).to have_content("Status") + expect(page).to have_content("Expiration date") + expect(page).to have_content(user.pro_membership.expires_at.to_date.to_s(:long)) + expect(page).to have_content("Top up from credit card?") + expect(page).to have_selector("input[type=submit]") + expect(page).to have_link("Pro Membership page", href: "/pro") + end + + it "updates the auto recharge" do + visit "/settings/pro-membership" + + check "pro_membership[auto_recharge]" + click_on "SUBMIT" + expect(page).to have_content("Your membership has been updated!") + end + end +end