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:
rhymes 2019-09-24 20:38:54 +03:00 committed by Ben Halpern
parent 70631ad2f7
commit c8abbe9d60
87 changed files with 2008 additions and 102 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -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;

View file

@ -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;
});
}
}

View file

@ -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+'\

View file

@ -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;
}

View file

@ -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 {

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,4 +1,5 @@
class HistoryController < ApplicationController
before_action :authenticate_user!
before_action :generate_algolia_search_key
def index

View 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

View file

@ -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,

View file

@ -27,6 +27,7 @@ class DashboardManifest
badge_achievements
html_variants
sponsorships
pro_memberships
].freeze
# DASHBOARDS = [
# :users,

View 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
View file

@ -0,0 +1,10 @@
module Payments
class PaymentsError < StandardError
end
class InvalidRequestError < PaymentsError
end
class CardError < PaymentsError
end
end

View file

@ -37,7 +37,7 @@ module ApplicationHelper
classified_listings
credits
partnerships
pro_accounts
pro_memberships
].include?(controller_name)
end

View file

@ -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,

View 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

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View 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

View file

@ -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

View file

@ -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}"

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View 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

View file

@ -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?

View file

@ -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

View 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

View 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

View 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

View 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

View file

@ -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)

View file

@ -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).

View file

@ -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).

View file

@ -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>

View file

@ -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" %>"

View file

@ -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>

View file

@ -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">

View file

@ -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| %>

View file

@ -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>

View file

@ -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 %>

View file

@ -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>

View file

@ -0,0 +1,3 @@
<a href="<%= pro_membership_path %>">
Pro Membership
</a>

View file

@ -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>

View file

@ -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 %>

View file

@ -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>

View file

@ -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>

View file

@ -0,0 +1 @@
<% # placeholder for future "pro indicator" %>

View 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>

View file

@ -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 %>"}'>&nbsp;</button>
</span>

View file

@ -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

View file

@ -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"

View 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

View file

@ -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"

View 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

View file

@ -0,0 +1,6 @@
FactoryBot.define do
factory :pro_membership do
user
status { "active" }
end
end

View file

@ -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

View 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

View 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

View file

@ -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)

View 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

View file

@ -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)

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View 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

View 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

View 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

View file

@ -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

View 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

View file

@ -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