Pro: memberships (#3461)
* Add ProMembership model * Implement ProMembershipsController#create * Implement basic ProMembershipsController#show * Add ProMembership to ledger * Populate user history after pro subscription is created * Add fields for expiration notifications * Add ProMemberships::ExpirationNotifier to notify users of expiring memberships * Add tasks for recurring jobs to notify users of expiration * Add auto_recharge column to ProMembership * Add ProMemberships::Biller (incomplete) * Fix specs * Add ProMembership to Administrate * Fix spec * Add has_enough_credits? to User and Organization * Add Payments::Customer class * Finish ProMembership::Biller functionality * Fix ProMemberships::Creator check for credits * Disable destroy actions for ProMembershipsController * Correctly authenticate ProMembershipsController actions * Make sure only pro user's history can be indexed * Add ProMembershipsController#update action for auto recharge * Use regular AR to save new credits and add touch to the purchaser * Clarify Pro membership create policy * Display information about an existing pro membership * Add UI to show page * Add system test for Pro membership creation * Implement edit membership * Make sure users with pro memberships can access history and dashboard pro * Fix padding issue * Show a different text for a user that has credits but not enough for Pro * Move Pro Membership functionality inside settings * Update Pro Membership link in email notifications * Bust all relevant caches * Add the Pro checkmark around the website * Use Users::ResaveArticlesJob instead of delay * Add/remove user from pro-members chat channel * Use the appropriate Pro checkmark * Remove unfinished pro elements * Remove checkmark JS
This commit is contained in:
parent
70631ad2f7
commit
c8abbe9d60
87 changed files with 2008 additions and 102 deletions
BIN
app/assets/images/pro-checkmark.png
Normal file
BIN
app/assets/images/pro-checkmark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,19 +96,23 @@ function buildArticleHTML(article) {
|
|||
publishDate = '・' + '<time>'+article.readable_publish_date+'</time>';
|
||||
}
|
||||
}
|
||||
|
||||
var readingTimeHTML = '';
|
||||
if (article.class_name === "Article") {
|
||||
// we have ` ... || null` for the case article.reading_time is undefined
|
||||
readingTimeHTML = '<a href="'+article.path+'" class="article-reading-time">'+ ((article.reading_time || null) < 1 ? '1 min' : article.reading_time + ' min') +' read</a>'
|
||||
}
|
||||
|
||||
var videoHTML = '';
|
||||
if (article.cloudinary_video_url) {
|
||||
videoHTML = '<a href="'+article.path+'" class="single-article-video-preview" style="background-image:url('+article.cloudinary_video_url+')"><div class="single-article-video-duration"><img src="<%= asset_path("video-camera.svg") %>" alt="video camera">'+article.video_duration_in_minutes+'</div></a>'
|
||||
}
|
||||
|
||||
var timeAgoInWords = '';
|
||||
if (article.published_at_int) {
|
||||
timeAgoInWords = timeAgo(article.published_at_int);
|
||||
}
|
||||
|
||||
return '<div class="single-article single-article-small-pic">\
|
||||
'+videoHTML+'\
|
||||
'+orgHeadline+'\
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
21
app/controllers/admin/pro_memberships_controller.rb
Normal file
21
app/controllers/admin/pro_memberships_controller.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
class HistoryController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :generate_algolia_search_key
|
||||
|
||||
def index
|
||||
|
|
|
|||
46
app/controllers/pro_memberships_controller.rb
Normal file
46
app/controllers/pro_memberships_controller.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ class DashboardManifest
|
|||
badge_achievements
|
||||
html_variants
|
||||
sponsorships
|
||||
pro_memberships
|
||||
].freeze
|
||||
# DASHBOARDS = [
|
||||
# :users,
|
||||
|
|
|
|||
67
app/dashboards/pro_membership_dashboard.rb
Normal file
67
app/dashboards/pro_membership_dashboard.rb
Normal file
|
|
@ -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
|
||||
10
app/errors/payments.rb
Normal file
10
app/errors/payments.rb
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
module Payments
|
||||
class PaymentsError < StandardError
|
||||
end
|
||||
|
||||
class InvalidRequestError < PaymentsError
|
||||
end
|
||||
|
||||
class CardError < PaymentsError
|
||||
end
|
||||
end
|
||||
|
|
@ -37,7 +37,7 @@ module ApplicationHelper
|
|||
classified_listings
|
||||
credits
|
||||
partnerships
|
||||
pro_accounts
|
||||
pro_memberships
|
||||
].include?(controller_name)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
12
app/jobs/pro_memberships/populate_history_job.rb
Normal file
12
app/jobs/pro_memberships/populate_history_job.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
12
app/jobs/users/resave_articles_job.rb
Normal file
12
app/jobs/users/resave_articles_job.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
11
app/mailers/pro_membership_mailer.rb
Normal file
11
app/mailers/pro_membership_mailer.rb
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
class ProMembershipMailer < ApplicationMailer
|
||||
default from: "DEV Pro Memberships <yo@dev.to>"
|
||||
|
||||
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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
56
app/models/pro_membership.rb
Normal file
56
app/models/pro_membership.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
9
app/policies/pro_membership_policy.rb
Normal file
9
app/policies/pro_membership_policy.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
57
app/services/payments/customer.rb
Normal file
57
app/services/payments/customer.rb
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
module Payments
|
||||
# A thin wrapper on Stripe Customers and Charges APIs
|
||||
# see: <https://stripe.com/docs/api/customers/object>,
|
||||
# <https://stripe.com/docs/api/charges>
|
||||
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
|
||||
118
app/services/pro_memberships/biller.rb
Normal file
118
app/services/pro_memberships/biller.rb
Normal file
|
|
@ -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
|
||||
42
app/services/pro_memberships/creator.rb
Normal file
42
app/services/pro_memberships/creator.rb
Normal file
|
|
@ -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
|
||||
47
app/services/pro_memberships/expiration_notifier.rb
Normal file
47
app/services/pro_memberships/expiration_notifier.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
<a href="<%= article.user.path + article.decorate.internal_utm_params %>">
|
||||
<img class="profile-pic" src="<%= ProfileImage.new(article.user).get(50) %>" alt="<%= article.user.username %> profile image" />
|
||||
<span><%= article.user.name %></span>
|
||||
<%= render "shared/pro_checkmark" if article.user.pro? %>
|
||||
</a>
|
||||
</div>
|
||||
<p>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
<a href="<%= followable.path + article.decorate.internal_utm_params %>"
|
||||
class="<%= "partner-link" if classification == "boosted" %>"
|
||||
data-details="<%= followable&.slug if classification == "boosted" %>__PROFILE"><%= followable.name %></a>
|
||||
<%= render "shared/pro_checkmark" if followable.pro? %>
|
||||
</div>
|
||||
<% end %>
|
||||
<button class="cta follow-action-button user-profile-follow-button <%= "partner-link" if classification == "boosted" %>"
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
<div class="content">
|
||||
<h3><%= article.title %></h3>
|
||||
<h4>
|
||||
<%= article.user.name %> - <time datetime="<%= article.published_timestamp %>"><%= article.readable_publish_date %></time>
|
||||
<%= article.user.name %><%= render "shared/pro_checkmark", width: "12px" if article.user.pro? %> - <time datetime="<%= article.published_timestamp %>"><%= article.readable_publish_date %></time>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
</a>
|
||||
<h4>
|
||||
<a href="/<%= story.cached_user.username %>">
|
||||
<%= story.cached_user.name %>・<time datetime="<%= story.published_timestamp %>"><%= story.readable_publish_date %></time><span class="time-ago-indicator-initial-placeholder" data-seconds="<%= story.published_at_int %>"></span>
|
||||
<%= story.cached_user.name %><%= render "shared/pro_checkmark" if story.cached_user.pro %>・<time datetime="<%= story.published_timestamp %>"><%= story.readable_publish_date %></time><span class="time-ago-indicator-initial-placeholder" data-seconds="<%= story.published_at_int %>"></span>
|
||||
</a>
|
||||
</h4>
|
||||
<div class="tags">
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
<div class="primary-sticky-nav-author-name">
|
||||
<a href="<%= @actor.path %>">
|
||||
<%= @actor.name %>
|
||||
<%= render "shared/pro_checkmark" if @actor.pro? %>
|
||||
</a>
|
||||
</div>
|
||||
<div class="primary-sticky-nav-author-username">
|
||||
|
|
@ -119,7 +120,7 @@
|
|||
</div>
|
||||
<% end %>
|
||||
<a class="primary-sticky-nav-element" href="<%= article.path %>">
|
||||
<img src="<%= ProfileImage.new(article.user).get(90) %>" class="primary-sticky-nav-profile-image" loading="lazy" alt="<%= article.user.name %> profile image">
|
||||
<img src="<%= ProfileImage.new(article.user).get(90) %>" class="primary-sticky-nav-profile-image" loading="lazy" alt="<%= article.user.name %> profile image">
|
||||
<%= article.title %>
|
||||
<div class="primary-sticky-nav-element-details">
|
||||
<% article.decorate.cached_tag_list_array.each do |tag| %>
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@
|
|||
</a>
|
||||
<div class="featured-user-name">
|
||||
<a href="/<%= @featured_story.cached_user.username %>">
|
||||
<%= @featured_story.cached_user.name %>・<time datetime="<%= @featured_story.published_timestamp %>"><%= @featured_story.readable_publish_date %></time>
|
||||
<%= @featured_story.cached_user.name %><%= render "shared/pro_checkmark" if @featured_story.cached_user.pro %>・<time datetime="<%= @featured_story.published_timestamp %>"><%= @featured_story.readable_publish_date %></time>
|
||||
<span class="time-ago-indicator-initial-placeholder" data-seconds="<%= @featured_story.published_at_int %>"></span>
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -213,7 +213,8 @@
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<% cache("sticky-sidebar-#{@article.id}-#{(@organization || @user).profile_updated_at}-#{(@organization || @user).last_article_at}-#{@variant_number}", expires_in: 50.hours) do %>
|
||||
<% stick_nav_cache_key = "sticky-sidebar-#{@article.id}-#{(@organization || @user).profile_updated_at}-#{(@organization || @user).last_article_at}-#{@variant_number}-#{(@organization || @user).pro?}" %>
|
||||
<% cache(stick_nav_cache_key, expires_in: 50.hours) do %>
|
||||
<%= render "articles/sticky_nav" %>
|
||||
<% end %>
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
<% end %>
|
||||
<td>
|
||||
<% if item.purchase %>
|
||||
<%= item.purchase.class.name == "ClassifiedListing" ? "Listing" : item.purchase.class.name %>
|
||||
<%= item.purchase.class.name == "ClassifiedListing" ? "Listing" : item.purchase.class.name.titleize %>
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
|
|
|
|||
3
app/views/credits/_ledger_pro_membership.html.erb
Normal file
3
app/views/credits/_ledger_pro_membership.html.erb
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<a href="<%= pro_membership_path %>">
|
||||
Pro Membership
|
||||
</a>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<% host = "#{ApplicationConfig['APP_PROTOCOL']}#{ApplicationConfig['APP_DOMAIN']}" %>
|
||||
<% credits_url = url_for(host: host, controller: :credits, action: :index) %>
|
||||
<% pro_membership_url = url_for(host: host, controller: :users, action: :edit, tab: "pro-membership") %>
|
||||
|
||||
<p>Hi <%= @user.name %>, your Pro Membership will expire in <%= @days %> days on <%= @date.to_s(:long) %>!</p>
|
||||
|
||||
<p>Make sure you have enough credits for its renewal: <a href="<%= credits_url %>"><%= credits_url %></a>.</p>
|
||||
|
||||
<p>You can also <a href="<%= pro_membership_url %>">review your Pro Membership status</a>.</p>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<% host = "#{ApplicationConfig['APP_PROTOCOL']}#{ApplicationConfig['APP_DOMAIN']}" %>
|
||||
<% credits_url = url_for(host: host, controller: :credits, action: :index) %>
|
||||
<% pro_membership_url = url_for(host: host, controller: :users, action: :edit, tab: "pro-membership") %>
|
||||
|
||||
Hi <%= @user.name %>, your Pro Membership will expire in <%= @days %> days on <%= @date.to_s(:long) %>!
|
||||
|
||||
Make sure you have enough credits for its renewal: <%= credits_url %>
|
||||
|
||||
You can also review your Pro Membership status: <%= pro_membership_url %>
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
</div>
|
||||
<% elsif %w[silver bronze tag].include?(level) %>
|
||||
<% organizations.find_each do |org| %>
|
||||
<% if org.credits.unspent.size < Sponsorship::CREDITS[level] %>
|
||||
<% if !org.has_enough_credits?(Sponsorship::CREDITS[level]) %>
|
||||
<div class="partner-credits-explainer">
|
||||
<h4>What next?</h4>
|
||||
<h3><img src="<%= ProfileImage.new(org).get(90) %>" /> Purchase Credits for @<%= org.slug %></h3>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<% title "Pro Account" %>
|
||||
<% title "Pro Membership" %>
|
||||
|
||||
<style>
|
||||
<% cache "partnership-org-section-credits-#{ApplicationConfig['DEPLOYMENT_SIGNATURE']}", expires_in: 10.hours do %>
|
||||
|
|
@ -7,6 +7,17 @@
|
|||
</style>
|
||||
|
||||
<div class="partnerships-container">
|
||||
<% if flash[:notice].present? %>
|
||||
<div class="notice" id="notice">
|
||||
<%= flash[:notice] %>
|
||||
</div>
|
||||
<% elsif flash[:error].present? %>
|
||||
<div class="notice error-notice" id="notice" style="text-align:center;color:red">
|
||||
ERROR
|
||||
<%= flash[:error] %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<h1><img src="<%= asset_path "rainbowdev.svg" %>" /> Like a Pro</h1>
|
||||
<p>
|
||||
<strong><em>Pro is coming soon. This page is a work in progress. Since we're open source and transparently working on this, no sense in keeping it private eh?</em></strong>
|
||||
|
|
@ -28,8 +39,13 @@
|
|||
<h3>🙌 Always Improving</h3>
|
||||
<p>Because this will be the landing destination for experimental and low scale features, you will always have the best and most interesting DEV features. We also plan to connect for exclusive deals on new and upcoming features on other platforms.
|
||||
|
||||
<div class="partner-credits-explainer">
|
||||
<h1>$25/mo</h1>
|
||||
<h2 style="text-align: center">(or 5 credits/mo)</h2>
|
||||
</div>
|
||||
<% if @user %>
|
||||
<div class="partner-credits-explainer">
|
||||
<% if @user.pro? %>
|
||||
<%= link_to "View your Pro Membership", user_settings_path("pro-membership") %>
|
||||
<% else %>
|
||||
<%= link_to "Become a Pro member", user_settings_path("pro-membership") %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
1
app/views/shared/_pro_checkmark.html.erb
Normal file
1
app/views/shared/_pro_checkmark.html.erb
Normal file
|
|
@ -0,0 +1 @@
|
|||
<% # placeholder for future "pro indicator" %>
|
||||
66
app/views/users/_pro_membership.html.erb
Normal file
66
app/views/users/_pro_membership.html.erb
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<h3>Pro Membership</h3>
|
||||
|
||||
<% if @user.pro? %>
|
||||
<% if @pro_membership %>
|
||||
<%= form_for(@pro_membership) do |f| %>
|
||||
<dl class="membership-status">
|
||||
<dt>Status</dt>
|
||||
<dd class="status">
|
||||
<% if @pro_membership.active? %>
|
||||
<%= image_tag("checkmark-green.svg", class: "icon-img", alt: "check mark", title: "active", width: 25) %>
|
||||
<% else %>
|
||||
<span class="no">expired</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
<dt>Expiration date</dt>
|
||||
<dd class="expiration-date">
|
||||
<time datetime="<%= @pro_membership.expires_at.iso8601 %>">
|
||||
<%= @pro_membership.expires_at.to_date.to_s(:long) %>
|
||||
</time>
|
||||
</dd>
|
||||
<dt>Top up from credit card?</dt>
|
||||
<dd class="auto-recharge">
|
||||
<div class="field">
|
||||
<%= f.check_box :auto_recharge %>
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
<%= f.submit "SUBMIT", class: "cta" %>
|
||||
<% end %>
|
||||
<% else %> <!-- existing pro roles without paid membership -->
|
||||
<dl class="membership-status">
|
||||
<dt>Status</dt>
|
||||
<dd class="status">
|
||||
<%= image_tag("checkmark-green.svg", class: "icon-img", alt: "check mark", title: "active", width: 25) %>
|
||||
</dd>
|
||||
<dt>Expiration date</dt>
|
||||
<dd class="expiration-date">
|
||||
<em>Never</em>
|
||||
</dd>
|
||||
</dl>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p>
|
||||
<%= link_to "Pro Membership", pro_membership_path %>
|
||||
is <strong><%= ProMembership::MONTHLY_COST %></strong> credits per month.
|
||||
</p>
|
||||
|
||||
<% available_credits = @user.credits.unspent.size %>
|
||||
<% if available_credits >= ProMembership::MONTHLY_COST %>
|
||||
<p>You currently have <strong><%= available_credits %></strong> available credits.</p>
|
||||
|
||||
<%= form_for(ProMembership.new(user: @user)) do |f| %>
|
||||
<%= f.submit("Become a Pro member", class: "cta") %>
|
||||
<% end %>
|
||||
<% elsif available_credits.positive? %>
|
||||
<p>You currently have <strong>4 credits</strong>, you need <%= ProMembership::MONTHLY_COST %> to become a Pro member.</p>
|
||||
<a href="<%= purchase_credits_path %>" class="membership-purchase-link">Purchase credits</a>
|
||||
<% else %>
|
||||
<p>You currently have <strong>no credits</strong>, you need <%= ProMembership::MONTHLY_COST %> to become a Pro member.</p>
|
||||
<a href="<%= purchase_credits_path %>" class="membership-purchase-link">Purchase credits</a>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<p>
|
||||
You can find additional info on the <%= link_to "Pro Membership page", pro_membership_path %>.
|
||||
</p>
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
</div>
|
||||
<div class="profile-details">
|
||||
<h1>
|
||||
<span itemprop="name"><%= @user.name %></span>
|
||||
<span itemprop="name"><%= @user.name %><%= render "shared/pro_checkmark", style: nil, width: nil %></span>
|
||||
<span class="user-profile-follow-button-wrapper">
|
||||
<button id="user-follow-butt" class="cta follow-action-button user-profile-follow-button" style="color:<%= user_colors(@user)[:text] %>;background-color:<%= user_colors(@user)[:bg] %>" data-info='{"id":<%= @user.id %>,"className":"<%= @user.class.name %>"}'> </button>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
17
db/migrate/20190711093610_create_pro_memberships.rb
Normal file
17
db/migrate/20190711093610_create_pro_memberships.rb
Normal file
|
|
@ -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
|
||||
15
db/schema.rb
15
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"
|
||||
|
|
|
|||
19
lib/tasks/pro_memberships.rake
Normal file
19
lib/tasks/pro_memberships.rake
Normal file
|
|
@ -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
|
||||
6
spec/factories/pro_memberships.rb
Normal file
6
spec/factories/pro_memberships.rb
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
FactoryBot.define do
|
||||
factory :pro_membership do
|
||||
user
|
||||
status { "active" }
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
|
|||
19
spec/jobs/pro_memberships/populate_history_job_spec.rb
Normal file
19
spec/jobs/pro_memberships/populate_history_job_spec.rb
Normal file
|
|
@ -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
|
||||
19
spec/jobs/users/resave_articles_job_spec.rb
Normal file
19
spec/jobs/users/resave_articles_job_spec.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
7
spec/mailers/previews/pro_membership_mailer_preview.rb
Normal file
7
spec/mailers/previews/pro_membership_mailer_preview.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
68
spec/mailers/pro_membership_mailer_spec.rb
Normal file
68
spec/mailers/pro_membership_mailer_spec.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
138
spec/models/pro_membership_spec.rb
Normal file
138
spec/models/pro_membership_spec.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
187
spec/requests/pro_memberships_spec.rb
Normal file
187
spec/requests/pro_memberships_spec.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
97
spec/services/payments/customer_spec.rb
Normal file
97
spec/services/payments/customer_spec.rb
Normal file
|
|
@ -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
|
||||
264
spec/services/pro_memberships/biller_spec.rb
Normal file
264
spec/services/pro_memberships/biller_spec.rb
Normal file
|
|
@ -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
|
||||
66
spec/services/pro_memberships/expiration_notifier_spec.rb
Normal file
66
spec/services/pro_memberships/expiration_notifier_spec.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
67
spec/system/pro_memberships/user_visits_pro_page_spec.rb
Normal file
67
spec/system/pro_memberships/user_visits_pro_page_spec.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue