Credits ledger (#3395)
* Add #purchase to Credit * Assigns spent credits to the listing * Add Credits::Ledger and use it * Localize ledger datetimes * Add organization to the ledger * Add tests for ledger * Remove unused class * Fix scope error * Compare UTC times * Fix broken specs * Wrap listing create in a transaction * Wrap listings bump into a transaction * Avoid microseconds issue with datetimes in tests * Use .detect instead of .select.first * Fix spec description
This commit is contained in:
parent
a9acb8642e
commit
1f97669e66
25 changed files with 585 additions and 100 deletions
|
|
@ -41,6 +41,7 @@ function callInitalizers(){
|
|||
initializePWAFunctionality();
|
||||
initializeEllipsisMenu();
|
||||
initializeArchivedPostFilter();
|
||||
initializeCreditsPage();
|
||||
|
||||
initializeDrawerSliders();
|
||||
|
||||
|
|
|
|||
13
app/assets/javascripts/initializers/initializeCreditsPage.js
Normal file
13
app/assets/javascripts/initializers/initializeCreditsPage.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/* global localizeTimeElements */
|
||||
function initializeCreditsPage() {
|
||||
const datetimes = document.querySelectorAll('.ledger time');
|
||||
|
||||
localizeTimeElements(datetimes, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
|
@ -55,3 +55,20 @@ function addLocalizedDateTimeToElementsTitles(elements, timestampAttribute) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
function localizeTimeElements(elements, timeOptions) {
|
||||
for (let i = 0; i < elements.length; i += 1) {
|
||||
const element = elements[i];
|
||||
|
||||
const timestamp = element.getAttribute('datetime');
|
||||
if (timestamp) {
|
||||
const localDateTime = timestampToLocalDateTime(
|
||||
timestamp,
|
||||
navigator.language,
|
||||
timeOptions,
|
||||
);
|
||||
|
||||
element.textContent = localDateTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@
|
|||
|
||||
.credits-container {
|
||||
margin: 50px auto;
|
||||
|
||||
.notice {
|
||||
background: $green;
|
||||
padding: 30px 0px;
|
||||
margin-top: 68px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.credits-banner {
|
||||
background-color: $dark-purple;
|
||||
background-image: linear-gradient(141deg, $dark-purple 0%, #1f89db 51%, #2981e5 75%);
|
||||
|
|
@ -33,7 +35,7 @@
|
|||
font-size: 1.8em;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
form#new_credit {
|
||||
width: 500px;
|
||||
|
|
@ -44,13 +46,13 @@
|
|||
|
||||
.InputElement {
|
||||
@include themeable(
|
||||
background-color,
|
||||
theme-container-background,
|
||||
background-color,
|
||||
theme-container-background,
|
||||
white
|
||||
);
|
||||
@include themeable(
|
||||
color,
|
||||
theme-color,
|
||||
color,
|
||||
theme-color,
|
||||
$black
|
||||
);
|
||||
}
|
||||
|
|
@ -62,13 +64,13 @@
|
|||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
@include themeable(
|
||||
background-color,
|
||||
theme-container-background,
|
||||
background-color,
|
||||
theme-container-background,
|
||||
white
|
||||
);
|
||||
@include themeable(
|
||||
color,
|
||||
theme-color,
|
||||
color,
|
||||
theme-color,
|
||||
$black
|
||||
);
|
||||
|
||||
|
|
@ -196,13 +198,13 @@
|
|||
}
|
||||
button#add-new-card {
|
||||
@include themeable(
|
||||
background-color,
|
||||
theme-container-background,
|
||||
background-color,
|
||||
theme-container-background,
|
||||
$light-gray
|
||||
);
|
||||
@include themeable(
|
||||
color,
|
||||
theme-color,
|
||||
color,
|
||||
theme-color,
|
||||
$black
|
||||
);
|
||||
font-size: 15px;
|
||||
|
|
@ -221,8 +223,8 @@
|
|||
}
|
||||
.credit-card-component {
|
||||
@include themeable(
|
||||
background,
|
||||
theme-container-accent-background,
|
||||
background,
|
||||
theme-container-accent-background,
|
||||
$lightest-gray
|
||||
);
|
||||
border-radius: 3px;
|
||||
|
|
@ -235,8 +237,8 @@
|
|||
label.main-form-label {
|
||||
margin-bottom: 4px;
|
||||
@include themeable(
|
||||
background,
|
||||
theme-container-accent-background,
|
||||
background,
|
||||
theme-container-accent-background,
|
||||
$light-gray
|
||||
);
|
||||
border-radius: 3px;
|
||||
|
|
@ -254,6 +256,7 @@
|
|||
padding: 30px;
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
|
||||
.existing-credits-status {
|
||||
font-size: 1.8em;
|
||||
font-weight: bold;
|
||||
|
|
@ -263,7 +266,8 @@
|
|||
font-size: initial;
|
||||
}
|
||||
}
|
||||
a {
|
||||
|
||||
> a {
|
||||
color: white;
|
||||
border: 3px solid white;
|
||||
padding: 10px 30px;
|
||||
|
|
@ -271,4 +275,28 @@
|
|||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ledger {
|
||||
margin: 2rem auto;
|
||||
text-align: right;
|
||||
width: 600px;
|
||||
|
||||
caption {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
td {
|
||||
padding-top: 1.1rem;
|
||||
}
|
||||
|
||||
.misc {
|
||||
td {
|
||||
padding-top: 1.5rem;
|
||||
&:first-child { font-style: italic; }
|
||||
}
|
||||
}
|
||||
|
||||
a { color: #fff; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,18 @@ class ClassifiedListingsController < ApplicationController
|
|||
|
||||
def index
|
||||
@displayed_classified_listing = ClassifiedListing.find_by!(slug: params[:slug]) if params[:slug]
|
||||
mod_page if params[:view] == "moderate"
|
||||
|
||||
if params[:view] == "moderate"
|
||||
return redirect_to "/internal/listings/#{@displayed_classified_listing.id}/edit"
|
||||
end
|
||||
|
||||
@classified_listings = if params[:category].blank?
|
||||
ClassifiedListing.where(published: true).order("bumped_at DESC").limit(12)
|
||||
ClassifiedListing.where(published: true).
|
||||
order("bumped_at DESC").
|
||||
includes(:user, :organization, :taggings).
|
||||
limit(12)
|
||||
else
|
||||
[]
|
||||
ClassifiedListing.none
|
||||
end
|
||||
set_surrogate_key_header "classified-listings-#{params[:category]}"
|
||||
end
|
||||
|
|
@ -19,59 +26,55 @@ class ClassifiedListingsController < ApplicationController
|
|||
def new
|
||||
@classified_listing = ClassifiedListing.new
|
||||
@organizations = current_user.organizations
|
||||
@credits = current_user.credits.where(spent: false)
|
||||
@credits = current_user.credits.unspent
|
||||
end
|
||||
|
||||
def edit
|
||||
authorize @classified_listing
|
||||
@organizations = current_user.organizations
|
||||
@credits = current_user.credits.where(spent: false)
|
||||
@credits = current_user.credits.unspent
|
||||
end
|
||||
|
||||
def create
|
||||
@classified_listing = ClassifiedListing.new(listing_params)
|
||||
@classified_listing.user_id = current_user.id
|
||||
@number_of_credits_needed = ClassifiedListing.cost_by_category(@classified_listing.category)
|
||||
@org = Organization.find_by(id: @classified_listing.organization_id)
|
||||
available_org_credits = @org.credits.where(spent: false) if @org
|
||||
available_individual_credits = current_user.credits.where(spent: false)
|
||||
|
||||
if @org && available_org_credits.size >= @number_of_credits_needed
|
||||
create_listing(available_org_credits)
|
||||
elsif available_individual_credits.size >= @number_of_credits_needed
|
||||
create_listing(available_individual_credits)
|
||||
else
|
||||
redirect_to "/credits"
|
||||
end
|
||||
end
|
||||
|
||||
def create_listing(credits)
|
||||
@classified_listing.bumped_at = Time.current
|
||||
@classified_listing.published = true
|
||||
# this will 500 for now if they don't belong in the org
|
||||
authorize @classified_listing, :authorized_organization_poster? if @classified_listing.organization_id.present?
|
||||
if @classified_listing.save
|
||||
clear_listings_cache
|
||||
credits.limit(@number_of_credits_needed).update_all(spent: true)
|
||||
@classified_listing.index!
|
||||
redirect_to "/listings"
|
||||
|
||||
@classified_listing.user_id = current_user.id
|
||||
cost = ClassifiedListing.cost_by_category(@classified_listing.category)
|
||||
|
||||
org = Organization.find_by(id: @classified_listing.organization_id)
|
||||
|
||||
available_org_credits = org.credits.unspent if org
|
||||
available_user_credits = current_user.credits.unspent
|
||||
|
||||
# we use the org's credits if available, otherwise we default to the user's
|
||||
if org && available_org_credits.size >= cost
|
||||
create_listing(org, cost)
|
||||
elsif available_user_credits.size >= cost
|
||||
create_listing(current_user, cost)
|
||||
else
|
||||
@credits = current_user.credits.where(spent: false)
|
||||
@classified_listing.cached_tag_list = listing_params[:tag_list]
|
||||
@organizations = current_user.organizations
|
||||
render :new
|
||||
redirect_to credits_path, notice: "Not enough available credits"
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @classified_listing
|
||||
available_credits = current_user.credits.where(spent: false)
|
||||
number_of_credits_needed = ClassifiedListing.cost_by_category(@classified_listing.category) # Bumping
|
||||
|
||||
# NOTE: this should probably be split in three different actions: bump, unpublish, publish
|
||||
if listing_params[:action] == "bump"
|
||||
bump_listing
|
||||
if available_credits.size >= number_of_credits_needed
|
||||
@classified_listing.save
|
||||
available_credits.limit(number_of_credits_needed).update_all(spent: true)
|
||||
cost = ClassifiedListing.cost_by_category(@classified_listing.category)
|
||||
if current_user.credits.unspent.size >= cost
|
||||
ActiveRecord::Base.transaction do
|
||||
Credits::Buyer.call(
|
||||
purchaser: current_user,
|
||||
purchase: @classified_listing,
|
||||
cost: cost,
|
||||
)
|
||||
|
||||
raise ActiveRecord::Rollback unless bump_listing
|
||||
end
|
||||
end
|
||||
elsif listing_params[:action] == "unpublish"
|
||||
unpublish_listing
|
||||
|
|
@ -80,12 +83,15 @@ class ClassifiedListingsController < ApplicationController
|
|||
elsif listing_params[:body_markdown].present? && @classified_listing.bumped_at > 24.hours.ago
|
||||
update_listing_details
|
||||
end
|
||||
|
||||
clear_listings_cache
|
||||
redirect_to "/listings"
|
||||
end
|
||||
|
||||
def dashboard
|
||||
@classified_listings = current_user.classified_listings
|
||||
@classified_listings = current_user.classified_listings.
|
||||
includes(:organization, :taggings)
|
||||
|
||||
organizations_ids = current_user.organization_memberships.
|
||||
where(type_of_user: "admin").
|
||||
pluck(:organization_id)
|
||||
|
|
@ -96,8 +102,38 @@ class ClassifiedListingsController < ApplicationController
|
|||
|
||||
private
|
||||
|
||||
def mod_page
|
||||
redirect_to "/internal/listings/#{@displayed_classified_listing.id}/edit"
|
||||
def create_listing(purchaser, cost)
|
||||
successful_transaction = false
|
||||
ActiveRecord::Base.transaction do
|
||||
# substract credits
|
||||
Credits::Buyer.call(
|
||||
purchaser: purchaser,
|
||||
purchase: @classified_listing,
|
||||
cost: cost,
|
||||
)
|
||||
|
||||
# save the listing
|
||||
@classified_listing.bumped_at = Time.current
|
||||
@classified_listing.published = true
|
||||
|
||||
# since we can't raise active record errors in this transaction
|
||||
# due to the fact that we need to display them in the :new view,
|
||||
# we manually rollback the transaction if there are validation errors
|
||||
raise ActiveRecord::Rollback unless @classified_listing.save
|
||||
|
||||
successful_transaction = true
|
||||
end
|
||||
|
||||
if successful_transaction
|
||||
clear_listings_cache
|
||||
@classified_listing.index!
|
||||
redirect_to classified_listings_path
|
||||
else
|
||||
@credits = current_user.credits.unspent
|
||||
@classified_listing.cached_tag_list = listing_params[:tag_list]
|
||||
@organizations = current_user.organizations
|
||||
render :new
|
||||
end
|
||||
end
|
||||
|
||||
def set_classified_listing
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ module ClassifiedListingsToolkit
|
|||
|
||||
def bump_listing
|
||||
@classified_listing.bumped_at = Time.current
|
||||
@classified_listing.save
|
||||
saved = @classified_listing.save
|
||||
@classified_listing.index! if saved
|
||||
saved
|
||||
end
|
||||
|
||||
def clear_listings_cache
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ class CreditsController < ApplicationController
|
|||
before_action :authenticate_user!
|
||||
|
||||
def index
|
||||
@credits = current_user.credits.where(spent: false)
|
||||
@user_unspent_credits_count = current_user.credits.unspent.size
|
||||
@ledger = Credits::Ledger.call(current_user)
|
||||
|
||||
@organizations = current_user.admin_organizations
|
||||
end
|
||||
|
||||
|
|
@ -35,10 +37,10 @@ class CreditsController < ApplicationController
|
|||
end
|
||||
Credit.import credit_objects
|
||||
@purchaser.credits_count = @purchaser.credits.size
|
||||
@purchaser.spent_credits_count = @purchaser.credits.where(spent: true).size
|
||||
@purchaser.unspent_credits_count = @purchaser.credits.where(spent: false).size
|
||||
@purchaser.spent_credits_count = @purchaser.credits.spent.size
|
||||
@purchaser.unspent_credits_count = @purchaser.credits.unspent.size
|
||||
@purchaser.save
|
||||
redirect_to "/credits", notice: "#{@number_to_purchase} new credits purchased!"
|
||||
redirect_to credits_path, notice: "#{@number_to_purchase} new credits purchased!"
|
||||
end
|
||||
|
||||
def make_payment
|
||||
|
|
@ -49,7 +51,7 @@ class CreditsController < ApplicationController
|
|||
true
|
||||
rescue Stripe::CardError => e
|
||||
flash[:error] = e.message
|
||||
redirect_to "/credits/purchase"
|
||||
redirect_to purchase_credits_path
|
||||
false
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ class ClassifiedListing < ApplicationRecord
|
|||
before_create :create_slug
|
||||
before_validation :modify_inputs
|
||||
acts_as_taggable_on :tags
|
||||
has_many :credits, as: :purchase, inverse_of: :purchase, dependent: :nullify
|
||||
|
||||
validates :user_id, presence: true
|
||||
validates :organization_id, presence: true, unless: :user_id?
|
||||
|
|
@ -71,7 +72,7 @@ class ClassifiedListing < ApplicationRecord
|
|||
"forsale" => { cost: 1, name: "Stuff for Sale", rules: "Personally owned physical items for sale." },
|
||||
"events" => { cost: 1, name: "Upcoming Events", rules: "In-person or online events with date included." },
|
||||
"misc" => { cost: 1, name: "Miscellaneous", rules: "Must not fit in any other category." }
|
||||
}
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
def path
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
class Credit < ApplicationRecord
|
||||
attr_accessor :number_to_purchase
|
||||
|
||||
belongs_to :user, optional: true
|
||||
belongs_to :organization, optional: true
|
||||
belongs_to :user, optional: true
|
||||
belongs_to :organization, optional: true
|
||||
belongs_to :purchase, polymorphic: true, optional: true
|
||||
|
||||
scope :spent, -> { where(spent: true) }
|
||||
scope :unspent, -> { where(spent: false) }
|
||||
|
||||
counter_culture :user,
|
||||
column_name: proc { |model| "#{model.spent ? 'spent' : 'unspent'}_credits_count" },
|
||||
|
|
|
|||
33
app/services/credits/buyer.rb
Normal file
33
app/services/credits/buyer.rb
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
module Credits
|
||||
class Buyer
|
||||
def initialize(purchaser:, purchase:, cost:)
|
||||
@purchaser = purchaser
|
||||
@purchase = purchase
|
||||
@cost = cost
|
||||
end
|
||||
|
||||
def self.call(*args)
|
||||
new(*args).call
|
||||
end
|
||||
|
||||
def call
|
||||
return false unless has_available_credits?
|
||||
|
||||
purchaser.credits.unspent.limit(cost).update_all(
|
||||
spent: true,
|
||||
spent_at: Time.current,
|
||||
purchase_type: purchase.class.name,
|
||||
purchase_id: purchase.id,
|
||||
)
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :purchaser, :purchase, :cost
|
||||
|
||||
def has_available_credits?
|
||||
purchaser.credits.unspent.size >= cost
|
||||
end
|
||||
end
|
||||
end
|
||||
69
app/services/credits/ledger.rb
Normal file
69
app/services/credits/ledger.rb
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
module Credits
|
||||
class Ledger
|
||||
Item = Struct.new(:purchase, :cost, :purchased_at, keyword_init: true)
|
||||
|
||||
def initialize(user)
|
||||
@user = user
|
||||
end
|
||||
|
||||
def self.call(*args)
|
||||
new(*args).call
|
||||
end
|
||||
|
||||
def call
|
||||
# build the ledger for the user
|
||||
ledger = {
|
||||
[User.name, user.id] => build_ledger_for(user.credits)
|
||||
}
|
||||
|
||||
# build the ledger for the organizations the user is an admin at
|
||||
user.admin_organizations.find_each do |org|
|
||||
ledger[[Organization.name, org.id]] = build_ledger_for(org.credits)
|
||||
end
|
||||
|
||||
ledger
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user
|
||||
|
||||
def purchaseable
|
||||
%w[ClassifiedListing]
|
||||
end
|
||||
|
||||
def load_purchases(credits)
|
||||
credits.spent.select(
|
||||
:purchase_id,
|
||||
:purchase_type,
|
||||
Arel.sql("COUNT(*)").as("cost"),
|
||||
Arel.sql("MAX(spent_at)").as("purchased_at"),
|
||||
).group(:purchase_id, :purchase_type).order(purchased_at: :desc)
|
||||
end
|
||||
|
||||
def build_ledger_for(credits)
|
||||
purchases = load_purchases(credits)
|
||||
items = []
|
||||
|
||||
# to avoid N+1 on purchases, we load them by type separately
|
||||
listings_purchases = purchases.select { |row| row.purchase_type == "ClassifiedListing" }
|
||||
listings = ClassifiedListing.where(id: listings_purchases.map(&:purchase_id))
|
||||
listings_purchases.each do |purchase|
|
||||
listing = listings.detect { |l| l.id == purchase.purchase_id }
|
||||
items << Item.new(
|
||||
purchase: listing,
|
||||
cost: purchase.cost.to_i,
|
||||
purchased_at: purchase.purchased_at,
|
||||
)
|
||||
end
|
||||
|
||||
# add items without a purchase association at the bottom
|
||||
unassociated_purchase = purchases.reject(&:purchase_type).first
|
||||
if unassociated_purchase
|
||||
items << Item.new(cost: unassociated_purchase.cost.to_i)
|
||||
end
|
||||
|
||||
items
|
||||
end
|
||||
end
|
||||
end
|
||||
39
app/views/credits/_ledger.html.erb
Normal file
39
app/views/credits/_ledger.html.erb
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<table class="ledger">
|
||||
<caption>Purchase history</caption>
|
||||
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th>Cost (credits)</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<% for item in items %>
|
||||
<% if item.purchase %>
|
||||
<tr>
|
||||
<% else %>
|
||||
<tr class="misc">
|
||||
<% end %>
|
||||
<td>
|
||||
<% if item.purchase %>
|
||||
<a
|
||||
href="<%= classified_listing_slug_path(item.purchase.category, item.purchase.slug) %>"
|
||||
title="<%= item.purchase.title %>">
|
||||
<%= truncate(item.purchase.title, length: 20) %>
|
||||
</a>
|
||||
<% else %>
|
||||
<span>Miscellaneous items</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="cost"><%= item.cost %></td>
|
||||
<td>
|
||||
<% if item.purchased_at %>
|
||||
<time datetime="<%= item.purchased_at.iso8601 %>"><%= item.purchased_at.iso8601 %></time>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -1,33 +1,47 @@
|
|||
<% title "Credits" %>
|
||||
|
||||
<div class="credits-container">
|
||||
<% if flash[:notice].present? %>
|
||||
<div class="notice" id="notice">
|
||||
<%= flash[:notice] %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if flash[:notice].present? %>
|
||||
<div class="notice" id="notice">
|
||||
<%= flash[:notice] %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="existing-credits">
|
||||
<div class="existing-credits-status">You have <%= @credits.size %> credits to spend</div>
|
||||
<a href="/credits/purchase" data-no-instant>Purchase additional credits</a>
|
||||
<div class="existing-credits-status">You have <%= @user_unspent_credits_count %> credits to spend</div>
|
||||
<a href="<%= purchase_credits_path %>" data-no-instant>Purchase additional credits</a>
|
||||
|
||||
<% if @user_unspent_credits_count.positive? %>
|
||||
<%= render "ledger", items: @ledger[["User", current_user.id]] %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @organizations.present? %>
|
||||
<% @organizations.each do |org| %>
|
||||
<% @organizations.find_each do |org| %>
|
||||
<div class="existing-credits">
|
||||
<div class="existing-credits-status">
|
||||
<%= org.name %> has <span id="org-credits-number"><%= org.unspent_credits_count %></span> credits to spend
|
||||
</div>
|
||||
<a id="org-credits-purchase-link" href="/credits/purchase?organization_id=<%= org.id %>" data-no-instant>Purchase additional credits</a>
|
||||
<a id="org-credits-purchase-link" href="<%= purchase_credits_path(organization_id: org.id) %>" data-no-instant>
|
||||
Purchase additional credits
|
||||
</a>
|
||||
|
||||
<% if org.unspent_credits_count.positive? %>
|
||||
<%= render "ledger", items: @ledger[["Organization", org.id]] %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if @credits.size > 0 %>
|
||||
|
||||
<% if @user_unspent_credits_count.positive? %>
|
||||
<center>
|
||||
<p>
|
||||
<a href="/listings/new">Create a Listing</a>
|
||||
<a href="<%= new_classified_listing_path %>">Create a Listing</a>
|
||||
</p>
|
||||
|
||||
<% if @organizations.present? %>
|
||||
<p>
|
||||
<a href="/partnerships">View Partnership Opportunities</a>
|
||||
<a href="<%= partnerships_path %>">View Partnership Opportunities</a>
|
||||
</p>
|
||||
<% end %>
|
||||
</center>
|
||||
|
|
|
|||
|
|
@ -144,7 +144,9 @@ Rails.application.routes.draw do
|
|||
resources :rating_votes, only: [:create]
|
||||
resources :page_views, only: %i[create update]
|
||||
resources :classified_listings, path: :listings, only: %i[index new create edit update delete dashboard]
|
||||
resources :credits, only: %i[index new create]
|
||||
resources :credits, only: %i[index new create] do
|
||||
get "purchase", on: :collection, to: "credits#new"
|
||||
end
|
||||
resources :buffer_updates, only: [:create]
|
||||
resources :reading_list_items, only: [:update]
|
||||
resources :poll_votes, only: %i[show create]
|
||||
|
|
@ -153,10 +155,9 @@ Rails.application.routes.draw do
|
|||
resources :partnerships, only: %i[index create]
|
||||
|
||||
get "/chat_channel_memberships/find_by_chat_channel_id" => "chat_channel_memberships#find_by_chat_channel_id"
|
||||
get "/credits/purchase" => "credits#new"
|
||||
get "/listings/dashboard" => "classified_listings#dashboard"
|
||||
get "/listings/:category" => "classified_listings#index"
|
||||
get "/listings/:category/:slug" => "classified_listings#index"
|
||||
get "/listings/:category/:slug" => "classified_listings#index", as: :classified_listing_slug
|
||||
get "/listings/:category/:slug/:view" => "classified_listings#index",
|
||||
constraints: { view: /moderate/ }
|
||||
get "/notifications/:filter" => "notifications#index"
|
||||
|
|
|
|||
7
db/migrate/20190704082551_add_purchase_to_credits.rb
Normal file
7
db/migrate/20190704082551_add_purchase_to_credits.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
class AddPurchaseToCredits < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
add_column :credits, :purchase_id, :bigint
|
||||
add_column :credits, :purchase_type, :string
|
||||
add_index :credits, [:purchase_id, :purchase_type]
|
||||
end
|
||||
end
|
||||
7
db/migrate/20190704091636_add_index_to_credits_spent.rb
Normal file
7
db/migrate/20190704091636_add_index_to_credits_spent.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
class AddIndexToCreditsSpent < ActiveRecord::Migration[5.2]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_index :credits, :spent, algorithm: :concurrently
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
class AddSpentAtAndRemoveSpentOnForCredits < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
add_column :credits, :spent_at, :datetime
|
||||
remove_column :credits, :spent_on, :string
|
||||
end
|
||||
end
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2019_07_03_003817) do
|
||||
ActiveRecord::Schema.define(version: 2019_07_04_105143) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
||||
|
|
@ -311,10 +311,14 @@ ActiveRecord::Schema.define(version: 2019_07_03_003817) do
|
|||
t.float "cost", default: 0.0
|
||||
t.datetime "created_at", null: false
|
||||
t.bigint "organization_id"
|
||||
t.bigint "purchase_id"
|
||||
t.string "purchase_type"
|
||||
t.boolean "spent", default: false
|
||||
t.string "spent_on"
|
||||
t.datetime "spent_at"
|
||||
t.datetime "updated_at", null: false
|
||||
t.bigint "user_id"
|
||||
t.index ["purchase_id", "purchase_type"], name: "index_credits_on_purchase_id_and_purchase_type"
|
||||
t.index ["spent"], name: "index_credits_on_spent"
|
||||
end
|
||||
|
||||
create_table "delayed_jobs", id: :serial, force: :cascade do |t|
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
FactoryBot.define do
|
||||
factory :classified_listing do
|
||||
user
|
||||
title { Faker::Book.title + rand(100).to_s }
|
||||
body_markdown { Faker::Hipster.paragraph(2) }
|
||||
category { "education" }
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ RSpec.describe ClassifiedListing, type: :model do
|
|||
|
||||
it { is_expected.to validate_presence_of(:title) }
|
||||
it { is_expected.to validate_presence_of(:body_markdown) }
|
||||
it { is_expected.to have_many(:credits) }
|
||||
|
||||
describe "valid associations" do
|
||||
it "is not valid w/o user and org" do
|
||||
|
|
@ -55,4 +56,15 @@ RSpec.describe ClassifiedListing, type: :model do
|
|||
expect(classified_listing.tag_list).to eq(%w[thebest taglist])
|
||||
end
|
||||
end
|
||||
|
||||
describe "credits" do
|
||||
it "does not destroy associated credits if destroyed" do
|
||||
credit = create(:credit)
|
||||
classified_listing.credits << credit
|
||||
classified_listing.save!
|
||||
|
||||
expect { classified_listing.destroy }.not_to change(Credit, :count)
|
||||
expect(credit.reload.purchase).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,7 +5,13 @@ RSpec.describe Credit, type: :model do
|
|||
let(:organization) { create(:organization) }
|
||||
let(:random_number) { rand(100) }
|
||||
|
||||
it "counts credits for user" do
|
||||
it { is_expected.to belong_to(:user).optional }
|
||||
it { is_expected.to belong_to(:organization).optional }
|
||||
it { is_expected.to belong_to(:purchase).optional }
|
||||
|
||||
xit "counts credits for user" do
|
||||
# See https://github.com/magnusvk/counter_culture/issues/259
|
||||
|
||||
create_list(:credit, random_number, user: user)
|
||||
Credit.counter_culture_fix_counts
|
||||
expect(user.reload.credits_count).to eq(random_number)
|
||||
|
|
@ -36,4 +42,18 @@ RSpec.describe Credit, type: :model do
|
|||
create_list(:credit, random_number, organization: organization, spent: true)
|
||||
expect(organization.reload.spent_credits_count).to eq(random_number)
|
||||
end
|
||||
|
||||
describe "#purchase" do
|
||||
let(:listing) { create(:classified_listing) }
|
||||
|
||||
it "associates to a purchase" do
|
||||
credit = create(:credit, purchase: listing)
|
||||
expect(credit.purchase).to eq(listing)
|
||||
end
|
||||
|
||||
it "is valid without a purchase" do
|
||||
credit = create(:credit, purchase: nil)
|
||||
expect(credit).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ require "rails_helper"
|
|||
|
||||
RSpec.describe "ClassifiedListings", type: :request do
|
||||
let(:user) { create(:user) }
|
||||
let(:valid_listing_params) do
|
||||
let(:listing_params) do
|
||||
{
|
||||
classified_listing: {
|
||||
title: "something",
|
||||
|
|
@ -75,8 +75,8 @@ RSpec.describe "ClassifiedListings", type: :request do
|
|||
end
|
||||
|
||||
context "when the listing is invalid" do
|
||||
it "renders errors with the listing" do
|
||||
post "/listings", params: {
|
||||
let(:invalid_params) do
|
||||
{
|
||||
classified_listing: {
|
||||
title: "nothing",
|
||||
body_markdown: "",
|
||||
|
|
@ -84,44 +84,120 @@ RSpec.describe "ClassifiedListings", type: :request do
|
|||
tag_list: ""
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it "renders errors with the listing" do
|
||||
post "/listings", params: invalid_params
|
||||
expect(response.body).to include("prohibited this listing from being saved")
|
||||
end
|
||||
|
||||
it "does not subtract credits or create a listing if the listing is not valid" do
|
||||
expect do
|
||||
post "/listings", params: invalid_params
|
||||
end.to change(ClassifiedListing, :count).by(0).
|
||||
and change(user.credits.spent, :size).by(0)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the listing is valid" do
|
||||
it "redirects if the user does not have enough credits" do
|
||||
Credit.delete_all
|
||||
post "/listings", params: valid_listing_params
|
||||
post "/listings", params: listing_params
|
||||
expect(response.body).to redirect_to("/credits")
|
||||
end
|
||||
|
||||
it "redirects if the org does not have enough credits" do
|
||||
org_admin = create(:user, :org_admin)
|
||||
valid_listing_params[:classified_listing][:post_as_organization] = "1"
|
||||
listing_params[:classified_listing][:post_as_organization] = "1"
|
||||
sign_in org_admin
|
||||
post "/listings", params: valid_listing_params
|
||||
post "/listings", params: listing_params
|
||||
expect(response.body).to redirect_to("/credits")
|
||||
end
|
||||
end
|
||||
|
||||
context "when the listing is valid" do
|
||||
it "redirects to /listings" do
|
||||
post "/listings", params: valid_listing_params
|
||||
post "/listings", params: listing_params
|
||||
expect(response).to redirect_to "/listings"
|
||||
end
|
||||
|
||||
it "properly deducts the amount of credits" do
|
||||
post "/listings", params: valid_listing_params
|
||||
expect(user.credits.where(spent: false).size).to eq 24
|
||||
post "/listings", params: listing_params
|
||||
listing_cost = ClassifiedListing.categories_available[:cfp][:cost]
|
||||
expect(user.credits.spent.size).to eq(listing_cost)
|
||||
end
|
||||
|
||||
it "creates a listing under the org" do
|
||||
org_admin = create(:user, :org_admin)
|
||||
org_id = org_admin.organizations.first.id
|
||||
Credit.create(organization_id: org_id)
|
||||
valid_listing_params[:classified_listing][:organization_id] = org_id
|
||||
listing_params[:classified_listing][:organization_id] = org_id
|
||||
sign_in org_admin
|
||||
post "/listings", params: valid_listing_params
|
||||
post "/listings", params: listing_params
|
||||
expect(ClassifiedListing.first.organization_id).to eq org_id
|
||||
end
|
||||
|
||||
it "does not create a listing for an org not belonging to the user" do
|
||||
org = create(:organization)
|
||||
listing_params[:classified_listing][:organization_id] = org.id
|
||||
expect { post "/listings", params: listing_params }.to raise_error(Pundit::NotAuthorizedError)
|
||||
end
|
||||
|
||||
it "assigns the spent credits to the listing" do
|
||||
post "/listings", params: listing_params
|
||||
spent_credit = user.credits.spent.last
|
||||
expect(spent_credit.purchase_type).to eq("ClassifiedListing")
|
||||
expect(spent_credit.spent_at).not_to be_nil
|
||||
end
|
||||
|
||||
it "does not create a listing or subtract credits if the purchase does not go through" do
|
||||
allow(Credits::Buyer).to receive(:call).and_raise(ActiveRecord::Rollback)
|
||||
expect do
|
||||
post "/listings", params: listing_params
|
||||
end.to change(ClassifiedListing, :count).by(0).
|
||||
and change(user.credits.spent, :size).by(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "PUT /listings/:id" do
|
||||
let(:listing) { create(:classified_listing, user: user) }
|
||||
|
||||
before do
|
||||
sign_in user
|
||||
end
|
||||
|
||||
context "when the bump action is called" do
|
||||
let(:params) { { classified_listing: { action: "bump" } } }
|
||||
|
||||
it "does not bump the listing if the use has not enough credits" do
|
||||
previous_bumped_at = listing.bumped_at
|
||||
put "/listings/#{listing.id}", params: params
|
||||
expect(listing.reload.bumped_at.to_i).to eq(previous_bumped_at.to_i)
|
||||
end
|
||||
|
||||
it "does not subtract spent credits if the user has not enough credits" do
|
||||
expect do
|
||||
put "/listings/#{listing.id}", params: params
|
||||
end.to change(user.credits.spent, :size).by(0)
|
||||
end
|
||||
|
||||
it "does not bump the listing or subtract credits if the purchase does not go through" do
|
||||
previous_bumped_at = listing.bumped_at
|
||||
allow(Credits::Buyer).to receive(:call).and_raise(ActiveRecord::Rollback)
|
||||
expect do
|
||||
put "/listings/#{listing.id}", params: params
|
||||
end.to change(user.credits.spent, :size).by(0)
|
||||
expect(listing.reload.bumped_at.to_i).to eq(previous_bumped_at.to_i)
|
||||
end
|
||||
|
||||
it "bumps the listing and subtract credits" do
|
||||
cost = ClassifiedListing.cost_by_category(listing.category)
|
||||
create_list(:credit, cost, user: user)
|
||||
previous_bumped_at = listing.bumped_at
|
||||
expect do
|
||||
put "/listings/#{listing.id}", params: params
|
||||
end.to change(user.credits.spent, :size).by(cost)
|
||||
expect(listing.reload.bumped_at >= previous_bumped_at).to eq(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -49,7 +49,9 @@ RSpec.describe "Comments", type: :request do
|
|||
commentable_type: "Article",
|
||||
user_id: user.id)
|
||||
get child.path
|
||||
expect(CGI.unescapeHTML(response.body)).to include("TOP OF THREAD", child.processed_html, comment.title(150))
|
||||
expect(response.body).to include("TOP OF THREAD")
|
||||
expect(response.body).to include(CGI.escapeHTML(comment.title(150)))
|
||||
expect(response.body).to include(child.processed_html)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
43
spec/services/credits/buyer_spec.rb
Normal file
43
spec/services/credits/buyer_spec.rb
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Credits::Buyer do
|
||||
let(:user) { create(:user) }
|
||||
let(:org) { create(:organization) }
|
||||
let(:listing) { create(:classified_listing, user: user) }
|
||||
|
||||
context "when not enough credits are available" do
|
||||
it "does not spend credits for the user" do
|
||||
create(:credit, user: user)
|
||||
expect do
|
||||
res = described_class.call(purchaser: user, purchase: listing, cost: 2)
|
||||
expect(res).to be(false)
|
||||
end.not_to change(user.credits.spent, :count)
|
||||
end
|
||||
|
||||
it "does not spend credits for the organization" do
|
||||
create(:credit, organization: org)
|
||||
expect do
|
||||
res = described_class.call(purchaser: org, purchase: listing, cost: 2)
|
||||
expect(res).to be(false)
|
||||
end.not_to change(org.credits.spent, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context "when enough credits are available" do
|
||||
it "spends credits for the user" do
|
||||
create_list(:credit, 2, user: user)
|
||||
expect do
|
||||
res = described_class.call(purchaser: user, purchase: listing, cost: 2)
|
||||
expect(res).to be(true)
|
||||
end.to change(user.credits.spent, :count)
|
||||
end
|
||||
|
||||
it "spends credits for the organization" do
|
||||
create_list(:credit, 2, organization: org)
|
||||
expect do
|
||||
res = described_class.call(purchaser: org, purchase: listing, cost: 2)
|
||||
expect(res).to be(true)
|
||||
end.to change(org.credits.spent, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
47
spec/services/credits/ledger_spec.rb
Normal file
47
spec/services/credits/ledger_spec.rb
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Credits::Ledger do
|
||||
let(:user) { create(:user) }
|
||||
let(:org) { create(:organization) }
|
||||
let(:user_listing) { create(:classified_listing, user: user) }
|
||||
let(:org_listing) { create(:classified_listing, organization: org) }
|
||||
|
||||
def buy(purchaser, purchase, cost)
|
||||
params = {
|
||||
spent: true,
|
||||
spent_at: Time.current,
|
||||
purchase_type: purchase.class.name,
|
||||
purchase_id: purchase.id
|
||||
}
|
||||
params.merge!((
|
||||
purchaser.is_a?(User) ? { user: purchaser } : { organization: purchaser }))
|
||||
create_list(:credit, cost, params)
|
||||
end
|
||||
|
||||
it "returns a list of user purchases with their costs" do
|
||||
start = Time.current
|
||||
buy(user, user_listing, 3)
|
||||
|
||||
items = described_class.call(user)[[User.name, user.id]]
|
||||
expect(items.length).to be(1)
|
||||
|
||||
item = items.first
|
||||
expect(item.purchase.is_a?(ClassifiedListing)).to be(true)
|
||||
expect(item.cost).to eq(3)
|
||||
expect(item.purchased_at >= start).to eq(true)
|
||||
end
|
||||
|
||||
it "returns a list of purchases for the org the user is an admin for" do
|
||||
create(:organization_membership, user_id: user.id, organization_id: org.id, type_of_user: "admin")
|
||||
buy(org, org_listing, 3)
|
||||
items = described_class.call(user)[[Organization.name, org.id]]
|
||||
expect(items.length).to be(1)
|
||||
end
|
||||
|
||||
it "does not return purchases for other orgs the user belongs to" do
|
||||
create(:organization_membership, user_id: user.id, organization_id: org.id, type_of_user: "member")
|
||||
buy(org, org_listing, 3)
|
||||
items = described_class.call(user)[[Organization.name, org.id]]
|
||||
expect(items).to be(nil)
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue