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

@Letiste Letiste left a 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

Comment on lines +12 to +14
# create_join_table :sales, :articles, column_options: { foreign_key: true } do |t|
# t.integer :quantity, null: 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.

Why is this commented out?

Copy link
Contributor Author

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.

Copy link
Member

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?

Copy link
Contributor Author

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)

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 lose the disk? Do we have any backup mechanism in place to restore the pdfs (or regenerate them)?

Copy link
Member

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

Copy link
Contributor Author

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
Copy link
Member

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?

Copy link
Contributor Author

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.

Copy link
Member

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' }
Copy link
Member

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?

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

Copy link
Member

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?

Copy link
Contributor Author

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
Copy link
Member

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?

Copy link
Contributor Author

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.

Copy link
Member

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

Copy link
Member

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

Copy link
Contributor Author

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/user.rb Show resolved Hide resolved
test/controllers/admin/articles_controller_test.rb Outdated Show resolved Hide resolved
test/models/article_test.rb Outdated Show resolved Hide resolved
test/models/refund_test.rb Outdated Show resolved Hide resolved
Comment on lines 34 to 43
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
Copy link
Member

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)

Copy link
Contributor Author

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.

Copy link
Member

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)

Copy link
Member

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.

Copy link
Contributor Author

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.

Copy link
Member

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

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

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

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

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

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.

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

Successfully merging this pull request may close these issues.

Subscription management modelisation
5 participants