-
Notifications
You must be signed in to change notification settings - Fork 0
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
base: master
Are you sure you want to change the base?
Conversation
Co-authored-by: DioFun <[email protected]> Co-authored-by: Nymous <[email protected]>
todo: fix tests
Codecov ReportAll modified and coverable lines are covered by tests ✅
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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
First batch of comments, I didn't finish the review yet. I didn't review the views because the focus here is more on the sales logic than adding some forms. I still need to review the sale/refund logic + the invoice generation
# create_join_table :sales, :articles, column_options: { foreign_key: true } do |t| | ||
# t.integer :quantity, null: false | ||
# end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this commented out?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
At first, we tried to use the create_join_table
method but that do not create a primary, auto-increment key thus when we tried to delete a record from the join table it was a fail. So I decided to switch to a classic table creation with create_table
in order to have such a key.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current config for active storage in production is to store the uploaded files on disk (see https://github.com/rezoleo/lea5/blob/master/config/environments/production.rb#L41-L42). Would that give us enough persistence for what we need to store?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes we want to store less than 100 MB of pdf (pdf are very light)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What happens if we lose the disk? Do we have any backup mechanism in place to restore the pdfs (or regenerate them)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bad things I guess. The pdf can be regenerated using the json stored in the invoice object, but in case we also lose those, i think it would be a good idea to also send the invoices in a folder on another server when we create them
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would surely be host on the PVE which is backed up but that are consideration when we will deploy it.
|
||
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should the price be strictly greater than 0?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's something that should be discuss. In a near or far future if we want to manage stocks of products maybe we'd like to sell things for free.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we are not sure it's something we need now, I would suggest allowing a price only strictly greater than 0. I think it would simplify some logic (not handling division by zero and a total price of zero). And that won't block us from adapting the logic in the future to support a price of 0
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' } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we only allow integers with only_integer: true
, what does 2 numbers after comma
mean here?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah the price is in cents, I didn't catch that, makes sense! So we have two different validations:
- in the backend, we only expect integers
- in the frontend, we only expect float with a maximum of 2 decimals
In this case, I would change the error message in the backend to something like Must be a positive integer
because it shouldn't be aware of how this integer is computed in the frontend. And have some logic in the frontend to display an error when the price is more than 2 decimals. What do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes I agree but I'm not sure on how to deal with that to change the error in front.
# frozen_string_literal: true | ||
|
||
class Invoice < ApplicationRecord | ||
has_one :sale, dependent: :restrict_with_exception |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we use restrict_with_exception
here and restrict_with_error
in other places? Should we prefer one to the other?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The place we use restrict_with_error is because when we try to delete the record we don't know if it will fail or not and if it fail we soft delete it. But here, it's not planned to be deleted in the code so it's with an exception.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Got it! I think it's worth adding some comments on that, and some tests on the destroy
code path (not only soft_delete
). Would use restrict_with_exception
everywhere and explicitly handle the exception and soft delete when it happens be more explicit? Not sure if it's very ruby-like though, I will defer to @nymous for this
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thinking about it again, if it's expected to try to delete an article but we should block the deletion when it's currently used by a sale, having an error instead of an exception makes more sense
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes that has been raised by @nymous in one of his comment
app/models/sale.rb
Outdated
def update_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 | ||
self.total_price = total | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it's a bit of a code smell here because we need to call update_total_price
before getting the total price. we could compute this value every time we get the total price instead, we would have the guarantee always to have the correct total price.
one benefit of being explicit on the update of the total price is that we don't compute it every time we get the total price but we don't need this kind of performance optimization (and we would need to adapt the logic anyway to guarantee that the total price is always up-to-date)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We call for update_total_price
when there is no subscription offers nor articles to be add to the sale so there will not have any update of the price after. I agree that is not quite convincing but I did not find anything else to do so.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need to have the total price in the database? The total price can be calculated at runtime by summing the price of articles and subscriptions, keeping it as a column in the database is redundant and we might have consistency issues (the total price of the sale is different from the sum of the articles' and subscriptions' price because we forgot to update the total price somewhere). And we can change update_total_price
to be a getter (renaming it to total_price
and returning the computed total price)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree for the getter, it sounds more idiomatic, but I believe that storing the total price is still a good idea : first, if articles are somehow lost, or an unexpected change happen (should not happen but who knows), we still have the accurate value of one of the most important thing : how much we were paied, without relying on some logic.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The fact is each time we would like to show a sale we need to calculate again the total price of the sale. That would be redundant because once a sale is created we want it to be immutable so after the creation the price couldn't be changed so there is no need to update the price later we just have to calculate it when we create it and it enables to avoid recalculate it each time we want to display which is the case when we show a user, we'd like to show every sales and their total price.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think recalculating it every time is an issue. We certainly don't need that kind of optimization (we wouldn't even notice it). Making the code more resilient to bugs is more important in my opinion.
Regarding storage issues, we have a redundancy with the pdf if we need to know the price. I would say it's good enough to make sure we can't lose the invoices.
My comment is not blocking in any way, I don't mind keeping it like this
There was a problem hiding this 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 |
There was a problem hiding this comment.
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):
@article.soft_delete unless @article.destroy | |
@article.destroy or @article.soft_delete |
module Admin | ||
class DashboardController < ApplicationController | ||
def index | ||
authorize! :manage, :all |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
def destroy | ||
@payment_method = PaymentMethod.find(params[:id]) | ||
authorize! :destroy, @payment_method | ||
@payment_method.soft_delete unless @payment_method.destroy |
There was a problem hiding this comment.
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
@payment_method.soft_delete unless @payment_method.destroy | |
@payment_method.destroy or @payment_method.soft_delete |
def destroy | ||
@subscription_offer = SubscriptionOffer.find(params[:id]) | ||
authorize! :destroy, @subscription_offer | ||
@subscription_offer.soft_delete unless @subscription_offer.destroy |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same, comment or
@subscription_offer.soft_delete unless @subscription_offer.destroy | |
@subscription_offer.destroy or @subscription_offer.soft_delete |
app/controllers/sales_controller.rb
Outdated
params.require(:sale).permit(:duration, :payment_method_id, articles_sales_attributes: [:article_id, :quantity]) | ||
end | ||
|
||
def reformated_params |
There was a problem hiding this comment.
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 ^^'
app/controllers/sales_controller.rb
Outdated
# rubocop:disable Metrics/AbcSize | ||
def create | ||
@sale = @owner.sales_as_client.new(reformated_params) | ||
@sale.update_total_price |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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' } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
…ash generation for sale
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? |
There was a problem hiding this comment.
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?
// newArticle.getElementById("sale_article_id_new").id = `sale_article_id_${this.nextId}` | ||
// newArticle.getElementById("sale_quantity_new").id = `sale_quantity_${this.nextId}` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can this be removed?
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 |
There was a problem hiding this comment.
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
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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
# 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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
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