Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

wip: paid subscriptions system and articles #467

Open
wants to merge 63 commits into
base: master
Choose a base branch
from
Open

Conversation

DioFun
Copy link
Contributor

@DioFun DioFun commented Jul 4, 2024

Le but est d'avoir un suivi et des correctifs aux fur et à mesure pour le moment quant au système d'abonnement payant.
Développement de la gestion des articles et abonnements payants.
La gestion des remboursements sera effectuée dans une autre PR

links to #455

@nymous nymous marked this pull request as draft July 4, 2024 18:11
Copy link

codecov bot commented Jul 5, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 100.00%. Comparing base (01e1374) to head (1a799a5).
Report is 1 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##            master      #467    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files           26        48    +22     
  Lines          337       663   +326     
  Branches        35        66    +31     
==========================================
+ Hits           337       663   +326     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@DioFun DioFun self-assigned this Jul 5, 2024
@DioFun DioFun requested a review from nymous July 8, 2024 22:38
Copy link
Member

@nymous nymous left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same, just posting what I wrote for now (also you pushed after I started to review so some comments might be outdated ^^')

def destroy
@article = Article.find(params[:id])
authorize! :destroy, @article
@article.soft_delete unless @article.destroy
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a comment to explain why we do this.
Alternative way to write it, not really convinced by it but at least it reads from left to right (and there isn't a side-effect in the conditional):

Suggested change
@article.soft_delete unless @article.destroy
@article.destroy or @article.soft_delete

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment still stands

module Admin
class DashboardController < ApplicationController
def index
authorize! :manage, :all
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a temporary permission and we will check more granularly, or is the admin dashboard only intended for superadmins?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is temporary permission for now

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a todo if it's temporary

Suggested change
authorize! :manage, :all
authorize! :manage, :all # TODO: use finer grained permissions

def destroy
@payment_method = PaymentMethod.find(params[:id])
authorize! :destroy, @payment_method
@payment_method.soft_delete unless @payment_method.destroy
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same thing here, maybe comment, maybe write it differently

Suggested change
@payment_method.soft_delete unless @payment_method.destroy
@payment_method.destroy or @payment_method.soft_delete

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bump comment

def destroy
@subscription_offer = SubscriptionOffer.find(params[:id])
authorize! :destroy, @subscription_offer
@subscription_offer.soft_delete unless @subscription_offer.destroy
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same, comment or

Suggested change
@subscription_offer.soft_delete unless @subscription_offer.destroy
@subscription_offer.destroy or @subscription_offer.soft_delete

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bump comment

app/views/sales/new.html.erb Outdated Show resolved Hide resolved
params.require(:sale).permit(:duration, :payment_method_id, articles_sales_attributes: [:article_id, :quantity])
end

def reformated_params
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The params methods could probably do with some comments and vertical space to separate blocks ^^'

# rubocop:disable Metrics/AbcSize
def create
@sale = @owner.sales_as_client.new(reformated_params)
@sale.update_total_price
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooooh, it's dangerous to have to think to do this before saving, can this be moved to a before_save or something?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has been moved but not sure if it's enough

validates :name, presence: true, allow_blank: false
validates :price, presence: true, allow_blank: false,
numericality: { greater_than_or_equal_to: 0, only_integer: true, message: 'Must be a positive
number. Maximum 2 numbers after comma' }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure the error message is up-to-date with the fact that we store it as cents.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the UI, the user will be ask to enter a decimal number and then it'll be multiply by 100

app/models/sale.rb Show resolved Hide resolved
app/controllers/sales_controller.rb Outdated Show resolved Hide resolved
@DioFun DioFun mentioned this pull request Jul 18, 2024
23 tasks
@DioFun DioFun marked this pull request as ready for review August 3, 2024 09:36
@DioFun DioFun requested review from nymous and Letiste August 3, 2024 09:37
Comment on lines +17 to +20
unless @sale.generate(duration: params[:sale][:duration], seller: current_user)
return redirect_to :new_user_sale, user: @user, status: :unprocessable_entity
end
return redirect_to :new_user_sale, user: @user, status: :unprocessable_entity if @sale.empty?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happens if we try to call generate on an empty sale? Before generating it, should we check if the sale isn't empty?

Comment on lines +25 to +26
// newArticle.getElementById("sale_article_id_new").id = `sale_article_id_${this.nextId}`
// newArticle.getElementById("sale_quantity_new").id = `sale_quantity_${this.nextId}`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this be removed?

app/models/articles_sale.rb Show resolved Hide resolved
Comment on lines +36 to +45
def compute_total_price
total = 0
articles_sales.each do |rec|
total += rec.quantity * Article.find(rec.article_id).price
end
sales_subscription_offers.each do |rec|
total += rec.quantity * SubscriptionOffer.find(rec.subscription_offer.id).price
end
total
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't care about that at our scale because we are talking about one or two articles, but I think here we would make a request to the database per article/subscription. That can be a problem once you get to dozens of articles.
We don't need to change anything for our case though, it's simpler like that

Comment on lines +60 to +79
def generate_sales_subscription_offers(duration)
subscription_offers = SubscriptionOffer.order(duration: :desc)
if subscription_offers.empty?
errors.add(:base, 'There are no subscription offers registered!')
return false
end
subscription_offers.each do |offer|
break if duration.zero?

quantity = duration / offer.duration
if quantity.positive?
sales_subscription_offers.new(subscription_offer_id: offer.id, quantity: quantity)
duration -= quantity * offer.duration
end
end
return true if duration.zero?

errors.add(:base, 'Subscription offers are not exhaustive!')
false
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit lost trying to understand what this function is doing. What does the duration parameter represent? Why do we leave the loop once the duration is zero? Why do we return false when there is an error, should we instead throw if it's unexpected?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function aims to generate the subiscription_offer for a given duration, duration is the number of months the subscription last. Example : an adherent want to pay for 13 months and we have two different offer 12 months is 50€ and 1 month is 5€ then it will create a sales_subscription_offers of 12 months and of 1 months. So we leave the loop when reach 0 because we do not need to continue as we already split the sale into the different offers we have. Lastly, we return false, because it's quite obscure the error process and the throw in my case i'd like just to send a message to the user not crash the website.

app/models/setting.rb Show resolved Hide resolved
Comment on lines +8 to +25
# validate :cannot_change_after_cancelled, on: :update

def cancel!
self.cancelled_at = Time.current
save!
def user
sale.client
end

private
# def cancel!
# self.cancelled_at = Time.current
# save!
# end

def cannot_change_after_cancelled
return if cancelled_at_was.nil?
# private

errors.add(:cancelled_at, 'Subscription has already been cancelled')
end
# def cannot_change_after_cancelled
# return if cancelled_at_was.nil?
#
# errors.add(:cancelled_at, 'Subscription has already been cancelled')
# end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we delete the comments if it's not used?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just commented it out for now because we would surely use it again when we'll make the refund module.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will need to add the font license if we use it (available in the font archive), probably as a app/assets/fronts/dejavu-license.txt.

def destroy
@article = Article.find(params[:id])
authorize! :destroy, @article
@article.soft_delete unless @article.destroy
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment still stands

module Admin
class DashboardController < ApplicationController
def index
authorize! :manage, :all
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a todo if it's temporary

Suggested change
authorize! :manage, :all
authorize! :manage, :all # TODO: use finer grained permissions

def destroy
@payment_method = PaymentMethod.find(params[:id])
authorize! :destroy, @payment_method
@payment_method.soft_delete unless @payment_method.destroy
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bump comment

def destroy
@subscription_offer = SubscriptionOffer.find(params[:id])
authorize! :destroy, @subscription_offer
@subscription_offer.soft_delete unless @subscription_offer.destroy
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bump comment

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With all the validation logic that I moved to the Sale model, this test will probably need more cases:

  • test if sale is empty
  • test if sale has duplicate articles

assert_not_predicate @subscription_offer, :valid?
end

test 'price should be positive' do
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also test for strictly positive? (a subscription offer that costs 0 is weird)

end

test 'duration should be positive' do
@subscription_offer.duration = -5
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, test for strictly positive as well

Comment on lines +59 to +71
test 'offer should be destroyed if no sales' do
@subscription_offer.sales.destroy_all
@subscription_offer.refunds.destroy_all
assert_difference 'SubscriptionOffer.unscoped.count', -1 do
@subscription_offer.destroy
end
end

test 'offer should be destroyable' do
@subscription_offer.sales.destroy_all
@subscription_offer.refunds.destroy_all
assert_predicate @subscription_offer, :destroy
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, should this be merged?

Suggested change
test 'offer should be destroyed if no sales' do
@subscription_offer.sales.destroy_all
@subscription_offer.refunds.destroy_all
assert_difference 'SubscriptionOffer.unscoped.count', -1 do
@subscription_offer.destroy
end
end
test 'offer should be destroyable' do
@subscription_offer.sales.destroy_all
@subscription_offer.refunds.destroy_all
assert_predicate @subscription_offer, :destroy
end
test 'offer should be destroyed if no sales' do
@subscription_offer.sales.destroy_all
@subscription_offer.refunds.destroy_all
assert_difference 'SubscriptionOffer.unscoped.count', -1 do
assert_predicate @subscription_offer, :destroy
end
end

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow very nice tests on a hard-to-test feature!

@nymous
Copy link
Member

nymous commented Nov 2, 2024

Just to show you what the errors look like in the reworked sale form:
image
The duplicate articles are shown in red

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: Review in progress
Development

Successfully merging this pull request may close these issues.

Subscription management modelisation
5 participants