diff --git a/Gemfile b/Gemfile index ea39aa78..7629df06 100644 --- a/Gemfile +++ b/Gemfile @@ -55,6 +55,9 @@ gem 'cancancan', '~> 3.6' # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] # gem "image_processing", "~> 1.2" +# Generate pdf files [https://github.com/gettalong/hexapdf] +gem 'hexapdf', '~> 0.40.0' + group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem 'debug', platforms: [:mri, :mswin, :mswin64, :mingw, :x64_mingw] diff --git a/Gemfile.lock b/Gemfile.lock index 2cdd2dd1..9c3902ed 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -101,6 +101,7 @@ GEM regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) childprocess (5.0.0) + cmdparse (3.0.7) coderay (1.1.3) concurrent-ruby (1.3.3) connection_pool (2.4.1) @@ -126,6 +127,7 @@ GEM net-http ffi (1.16.3) formatador (1.1.0) + geom2d (0.4.1) globalid (1.2.1) activesupport (>= 6.1) guard (2.18.1) @@ -143,6 +145,10 @@ GEM minitest (>= 3.0) hashdiff (1.1.0) hashie (5.0.0) + hexapdf (0.40.0) + cmdparse (~> 3.0, >= 3.0.3) + geom2d (~> 0.4, >= 0.4.1) + openssl (>= 2.2.1) i18n (1.14.5) concurrent-ruby (~> 1.0) importmap-rails (1.2.3) @@ -232,6 +238,7 @@ GEM tzinfo validate_url webfinger (~> 2.0) + openssl (3.2.0) overcommit (0.63.0) childprocess (>= 0.6.3, < 6) iniparse (~> 1.4) @@ -423,6 +430,7 @@ DEPENDENCIES error_highlight (>= 0.4.0) guard (~> 2.18) guard-minitest (~> 2.4) + hexapdf (~> 0.40.0) importmap-rails (~> 1.2) ipaddress (~> 0.8.3) jbuilder (~> 2.12) diff --git a/app/assets/fonts/DejaVuSans-Bold.ttf b/app/assets/fonts/DejaVuSans-Bold.ttf new file mode 100644 index 00000000..6d65fa7d Binary files /dev/null and b/app/assets/fonts/DejaVuSans-Bold.ttf differ diff --git a/app/assets/fonts/DejaVuSans.ttf b/app/assets/fonts/DejaVuSans.ttf new file mode 100644 index 00000000..e5f7eecc Binary files /dev/null and b/app/assets/fonts/DejaVuSans.ttf differ diff --git a/app/assets/images/rezoleo_logo.png b/app/assets/images/rezoleo_logo.png new file mode 100644 index 00000000..3634a66c Binary files /dev/null and b/app/assets/images/rezoleo_logo.png differ diff --git a/app/controllers/admin/articles_controller.rb b/app/controllers/admin/articles_controller.rb new file mode 100644 index 00000000..37f3e50e --- /dev/null +++ b/app/controllers/admin/articles_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Admin + class ArticlesController < ApplicationController + def new + @article = Article.new + authorize! :new, @article + end + + def create + @article = Article.new(article_params) + authorize! :create, @article + if @article.save + flash[:success] = "Article #{article_params[:name]} created!" + redirect_to admin_path + else + render 'new', status: :unprocessable_entity + end + end + + def destroy + @article = Article.find(params[:id]) + authorize! :destroy, @article + @article.soft_delete unless @article.destroy + redirect_to admin_path + end + + def article_params + params.require(:article).permit(:price, :name) + end + end +end diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb new file mode 100644 index 00000000..59a2df73 --- /dev/null +++ b/app/controllers/admin/dashboard_controller.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Admin + class DashboardController < ApplicationController + def index + authorize! :manage, :all + @articles = Article.order(created_at: :desc) + @subscription_offers = SubscriptionOffer.order(created_at: :desc) + @payment_methods = PaymentMethod.order(created_at: :desc) + end + end +end diff --git a/app/controllers/admin/payment_methods_controller.rb b/app/controllers/admin/payment_methods_controller.rb new file mode 100644 index 00000000..6a19540b --- /dev/null +++ b/app/controllers/admin/payment_methods_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Admin + class PaymentMethodsController < ApplicationController + def new + @payment_method = PaymentMethod.new + authorize! :new, @payment_method + end + + def create + @payment_method = PaymentMethod.new(payment_method_params) + authorize! :create, @payment_method + if @payment_method.save + flash[:success] = "Payment method #{payment_method_params[:name]} created!" + redirect_to admin_path + else + render 'new', status: :unprocessable_entity + end + end + + def destroy + @payment_method = PaymentMethod.find(params[:id]) + authorize! :destroy, @payment_method + @payment_method.soft_delete unless @payment_method.destroy + redirect_to admin_path + end + + def payment_method_params + params.require(:payment_method).permit(:name, :auto_verify) + end + end +end diff --git a/app/controllers/admin/subscription_offers_controller.rb b/app/controllers/admin/subscription_offers_controller.rb new file mode 100644 index 00000000..84eeed1d --- /dev/null +++ b/app/controllers/admin/subscription_offers_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Admin + class SubscriptionOffersController < ApplicationController + def new + @subscription_offer = SubscriptionOffer.new + authorize! :new, @subscription_offer + end + + def create + @subscription_offer = SubscriptionOffer.new(subscription_offer_params) + authorize! :create, @subscription_offer + if @subscription_offer.save + flash[:success] = "Subscription offer for #{subscription_offer_params[:duration]} months created!" + redirect_to admin_path + else + render 'new', status: :unprocessable_entity + end + end + + def destroy + @subscription_offer = SubscriptionOffer.find(params[:id]) + authorize! :destroy, @subscription_offer + @subscription_offer.soft_delete unless @subscription_offer.destroy + redirect_to admin_path + end + + def subscription_offer_params + params.require(:subscription_offer).permit(:price, :duration) + end + end +end diff --git a/app/controllers/refunds_controller.rb b/app/controllers/refunds_controller.rb new file mode 100644 index 00000000..a86279b3 --- /dev/null +++ b/app/controllers/refunds_controller.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class RefundsController < ApplicationController +end diff --git a/app/controllers/sales_controller.rb b/app/controllers/sales_controller.rb new file mode 100644 index 00000000..f14a3d7f --- /dev/null +++ b/app/controllers/sales_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class SalesController < ApplicationController + before_action :owner, only: [:new, :create] + + def new + @sale = @owner.sales_as_client.new + @sale.articles_sales.new + @articles = Article.all + @subscription_offers = SubscriptionOffer.order(duration: :desc) + @payment_methods = PaymentMethod.all + authorize! :new, @sale + end + + def create + @sale = @owner.sales_as_client.new(sales_params) + 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? + + authorize! :create, @sale + if @sale.save + flash[:success] = 'Sale was successfully created.' + redirect_to @owner + else + redirect_to :new_user_sale, user: @user, status: :unprocessable_entity + end + end + + private + + def owner + @owner = User.find(params[:user_id]) + end + + def sales_params + params.require(:sale).permit(:payment_method_id, articles_sales_attributes: [:article_id, :quantity]) + end +end diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb deleted file mode 100644 index 572cbc5c..00000000 --- a/app/controllers/subscriptions_controller.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -class SubscriptionsController < ApplicationController - before_action :owner, only: [:create, :new, :destroy] - - def new - @subscription = @owner.subscriptions.new - authorize! :new, @subscription - end - - def create - @subscription = @owner.extend_subscription(duration: Integer(subscription_params[:duration])) - authorize! :create, @subscription - if @subscription.save - flash[:success] = 'New subscription added!' - redirect_to @owner - else - render 'new', status: :unprocessable_entity - end - end - - def destroy - authorize! :destroy, @owner.current_subscription - owner.cancel_current_subscription! - flash[:success] = 'Last subscription cancelled!' - redirect_to owner - end - - private - - def subscription_params - params.require(:subscription).permit(:duration) - end - - def owner - @owner = User.find(params[:user_id]) - end -end diff --git a/app/helpers/admin/articles_helper.rb b/app/helpers/admin/articles_helper.rb new file mode 100644 index 00000000..e9f28d5c --- /dev/null +++ b/app/helpers/admin/articles_helper.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Admin + module ArticlesHelper + end +end diff --git a/app/helpers/admin/dashboard_helper.rb b/app/helpers/admin/dashboard_helper.rb new file mode 100644 index 00000000..25813da3 --- /dev/null +++ b/app/helpers/admin/dashboard_helper.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Admin + module DashboardHelper + end +end diff --git a/app/helpers/admin/payment_methods_helper.rb b/app/helpers/admin/payment_methods_helper.rb new file mode 100644 index 00000000..76402e32 --- /dev/null +++ b/app/helpers/admin/payment_methods_helper.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Admin + module PaymentMethodsHelper + end +end diff --git a/app/helpers/admin/subscription_offers_helper.rb b/app/helpers/admin/subscription_offers_helper.rb new file mode 100644 index 00000000..a77d8251 --- /dev/null +++ b/app/helpers/admin/subscription_offers_helper.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Admin + module SubscriptionOffersHelper + end +end diff --git a/app/helpers/subscriptions_helper.rb b/app/helpers/refunds_helper.rb similarity index 56% rename from app/helpers/subscriptions_helper.rb rename to app/helpers/refunds_helper.rb index 78373352..efb6a4ff 100644 --- a/app/helpers/subscriptions_helper.rb +++ b/app/helpers/refunds_helper.rb @@ -1,4 +1,4 @@ # frozen_string_literal: true -module SubscriptionsHelper +module RefundsHelper end diff --git a/app/helpers/sales_helper.rb b/app/helpers/sales_helper.rb new file mode 100644 index 00000000..b30d766a --- /dev/null +++ b/app/helpers/sales_helper.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +module SalesHelper +end diff --git a/app/javascript/controllers/sales_controller.js b/app/javascript/controllers/sales_controller.js new file mode 100644 index 00000000..52a6e4f6 --- /dev/null +++ b/app/javascript/controllers/sales_controller.js @@ -0,0 +1,71 @@ +// Example: https://gorails.com/episodes/dynamic-nested-forms-with-stimulus-js +// https://github.com/gorails-screencasts/dynamic-nested-forms-with-stimulusjs/commit/06a69e33f81ee24b1042931adcd56148b44e88d8 + +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="sales" +export default class extends Controller { + static targets = ["articleTemplate", "articles", "totalPrice", "subPrice", "subscription"] + + initialize() { + this.nextId = 1; + } + + connect() { + this.articles = {} + const articles = JSON.parse(this.element.dataset.articles) + articles.forEach(e => { + this.articles[e.id] = e.price + }) + this.subscription_offers = JSON.parse(this.element.dataset.subscriptions) + } + + addArticle() { + const newArticle = this.articleTemplateTarget//.content.cloneNode(true) + // newArticle.getElementById("sale_article_id_new").id = `sale_article_id_${this.nextId}` + // newArticle.getElementById("sale_quantity_new").id = `sale_quantity_${this.nextId}` + const content = newArticle.innerHTML.replace(/NEW_ARTICLE/g, this.nextId) + let insertAfter = this.articlesTargets.length !== 0 ? this.articlesTargets : this.subscriptionTargets + insertAfter.at(-1).insertAdjacentHTML("afterend", content) + this.nextId++ + } + + /** + * @param event {Event} + */ + removeArticle(event) { + event.currentTarget.closest("div").remove() + this.updateTotalPrice() + } + + updateTotalPrice() { + let price = 0 + let articles = this.articlesTargets + articles.forEach(e => { + let id = Number.parseInt(e.querySelector('select').value) + let quantity = Number.parseInt(e.querySelector('input').value) + if (id && quantity) price += this.articles[id] * quantity + }) + price += Number.parseFloat(this.subPriceTarget.textContent) * 100 + this.totalPriceTarget.textContent = (price / 100).toFixed(2).toLocaleString() + } + + /** + * @param event {Event} + */ + updateSubPrice(event) { + let sub = Number.parseInt(event.currentTarget.value) + let price = 0 + this.subscription_offers.forEach(e => { + let quantity = Math.floor(sub / e.duration) + sub -= quantity * e.duration + price += e.price * quantity + }) + this.subPriceTarget.textContent = (price / 100).toFixed(2).toLocaleString() + this.updateTotalPrice() + } + + testUpdate() { + console.log("test") + } +} diff --git a/app/models/ability.rb b/app/models/ability.rb index c8c19ad7..a071561c 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -14,7 +14,10 @@ def initialize(user) end can [:read], Subscription, user: user + can [:read], Sale, user: user + can [:read], Refund, user: user can [:read], FreeAccess, user: user + can [:read], Invoice, user: user return unless user.admin? diff --git a/app/models/article.rb b/app/models/article.rb new file mode 100644 index 00000000..6931f84d --- /dev/null +++ b/app/models/article.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Article < ApplicationRecord + has_many :articles_sales, dependent: :restrict_with_error + has_many :sales, through: :articles_sales + has_many :articles_refunds, dependent: :restrict_with_error + has_many :refunds, through: :articles_refunds + + 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' } + + default_scope { where(deleted_at: nil) } + + def soft_delete + update(deleted_at: Time.zone.now) if deleted_at.nil? + end +end diff --git a/app/models/articles_refund.rb b/app/models/articles_refund.rb new file mode 100644 index 00000000..daac656e --- /dev/null +++ b/app/models/articles_refund.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class ArticlesRefund < ApplicationRecord + belongs_to :refund + belongs_to :article +end diff --git a/app/models/articles_sale.rb b/app/models/articles_sale.rb new file mode 100644 index 00000000..e1383c92 --- /dev/null +++ b/app/models/articles_sale.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class ArticlesSale < ApplicationRecord + belongs_to :sale + belongs_to :article + + validates :quantity, presence: true, numericality: { only_integer: true, greater_than: 0 } + + before_create :consolidate_duplication + + private + + def consolidate_duplication + duplicate = ArticlesSale.where(article_id: article_id, sale_id: sale_id).where.not(id: id).first + return unless duplicate + + errors.add(:base, 'Please merge the quantities of the same articles !') + throw :abort + end +end diff --git a/app/models/invoice.rb b/app/models/invoice.rb new file mode 100644 index 00000000..f022071a --- /dev/null +++ b/app/models/invoice.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +class Invoice < ApplicationRecord + has_one :sale, dependent: :restrict_with_exception + has_one_attached :pdf + + before_create :create_invoice + + def user + sale.client + end + + def generate_from(sale) + self.generation_json = generate_hash(sale).to_json if generation_json.nil? + self.id = generate_invoice_id if id.nil? + end + + private + + def create_invoice + self.id = generate_invoice_id if id.nil? + pdf_stream = InvoicePdfGenerator.new(JSON.parse(generation_json).deep_symbolize_keys).generate_pdf + pdf.attach(io: pdf_stream, filename: id, content_type: 'application/pdf') + end + + def generate_invoice_id + json = JSON.parse(generation_json).symbolize_keys + json[:invoice_id] = Setting.next_invoice_id + self.generation_json = json.to_json + end + + def generate_hash(sale) + { + sale_date: Time.zone.today, + issue_date: Time.zone.today, + client_name: sale.client.display_name, + client_address: sale.client.display_address, + payment_amount_cent: sale.verified_at.nil? ? 0 : sale.total_price, + payment_method: sale.payment_method&.name, + payment_date: sale.verified_at, + items: sales_itemized(sale) + }.compact + end + + def sales_itemized(sale) + articles_itemized(sale) + subscriptions_offers_itemized(sale) + end + + def articles_itemized(sale) + sale.articles_sales.map do |e| + { + item_name: e.article.name, + price_cents: e.article.price, + quantity: e.quantity + } + end + end + + def subscriptions_offers_itemized(sale) + sale.sales_subscription_offers.map do |e| + { + item_name: "Abonnement - #{e.subscription_offer.duration} mois", + price_cents: e.subscription_offer.price, + quantity: e.quantity + } + end + end +end diff --git a/app/models/payment_method.rb b/app/models/payment_method.rb new file mode 100644 index 00000000..d9a075c1 --- /dev/null +++ b/app/models/payment_method.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class PaymentMethod < ApplicationRecord + has_many :sales, dependent: :restrict_with_error + has_many :refunds, foreign_key: 'refund_method_id', dependent: :restrict_with_error, inverse_of: :refund_method + + validates :name, presence: true, allow_blank: false + validates :auto_verify, inclusion: { in: [true, false] } + + default_scope { where(deleted_at: nil) } + + def soft_delete + update(deleted_at: Time.zone.now) if deleted_at.nil? + end +end diff --git a/app/models/refund.rb b/app/models/refund.rb new file mode 100644 index 00000000..e3a937bc --- /dev/null +++ b/app/models/refund.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class Refund < ApplicationRecord + belongs_to :refunder, class_name: 'User', optional: true + belongs_to :refund_method, class_name: 'PaymentMethod' + belongs_to :sale + belongs_to :invoice + has_many :articles_refunds, dependent: :destroy + has_many :articles, through: :articles_refunds + has_many :refunds_subscription_offers, dependent: :destroy + has_many :subscription_offers, through: :refunds_subscription_offers +end diff --git a/app/models/refunds_subscription_offer.rb b/app/models/refunds_subscription_offer.rb new file mode 100644 index 00000000..93a69a23 --- /dev/null +++ b/app/models/refunds_subscription_offer.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class RefundsSubscriptionOffer < ApplicationRecord + belongs_to :refund + belongs_to :subscription_offer +end diff --git a/app/models/sale.rb b/app/models/sale.rb new file mode 100644 index 00000000..eeddd7d3 --- /dev/null +++ b/app/models/sale.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +class Sale < ApplicationRecord + belongs_to :seller, class_name: 'User', optional: true + belongs_to :client, class_name: 'User' + belongs_to :payment_method + belongs_to :invoice + has_one :subscription, dependent: :destroy + has_many :refunds, dependent: :destroy + has_many :articles_sales, dependent: :destroy + has_many :articles, through: :articles_sales + has_many :sales_subscription_offers, dependent: :destroy + has_many :subscription_offers, through: :sales_subscription_offers + + accepts_nested_attributes_for :articles_sales + + validates :total_price, presence: true + + def verify + self.verified_at = Time.zone.now if verified_at.nil? + end + + def generate(duration:, seller:) + return false if duration.to_i.negative? + + return false unless generate_sales_subscription_offers duration.to_i + + self.seller = seller + create_associated_subscription duration.to_i if duration.to_i.positive? + self.total_price = compute_total_price + verify if payment_method&.auto_verify + generate_invoice + true + end + + 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 + + def generate_invoice + return if invoice + + self.invoice = Invoice.new + invoice.generate_from(self) + end + + def empty? + articles_sales.empty? && sales_subscription_offers.empty? + end + + private + + 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 + + def create_associated_subscription(duration) + self.subscription = client.extend_subscription(duration: duration) + end +end diff --git a/app/models/sales_subscription_offer.rb b/app/models/sales_subscription_offer.rb new file mode 100644 index 00000000..8a60c82c --- /dev/null +++ b/app/models/sales_subscription_offer.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class SalesSubscriptionOffer < ApplicationRecord + belongs_to :sale + belongs_to :subscription_offer + + validates :quantity, presence: true, numericality: { only_integer: true, greater_than: 0 } +end diff --git a/app/models/setting.rb b/app/models/setting.rb new file mode 100644 index 00000000..af3ef46f --- /dev/null +++ b/app/models/setting.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Setting < ApplicationRecord + validates :key, presence: true, uniqueness: true + validates :value, presence: true + + def self.next_invoice_id + record = lock(true).get_or_create(key: 'next_invoice_id', default: 1) + next_id = record.value.to_i + record.update!(value: next_id + 1) + next_id + end + + def self.get_or_create(key:, default: nil) + record = find_or_create_by(key: key) + record.value = default unless record.persisted? + record + end +end diff --git a/app/models/subscription.rb b/app/models/subscription.rb index d42a7bc6..d4b8aab9 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -1,22 +1,26 @@ # frozen_string_literal: true class Subscription < ApplicationRecord - belongs_to :user + belongs_to :sale validates :start_at, presence: true validates :end_at, comparison: { greater_than: :start_at } - validate :cannot_change_after_cancelled, on: :update + # 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 end diff --git a/app/models/subscription_offer.rb b/app/models/subscription_offer.rb new file mode 100644 index 00000000..fc2a98b1 --- /dev/null +++ b/app/models/subscription_offer.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class SubscriptionOffer < ApplicationRecord + has_many :sales_subscription_offers, dependent: :restrict_with_error + has_many :sales, through: :sales_subscription_offers + has_many :refunds_subscription_offers, dependent: :restrict_with_error + has_many :refunds, through: :refunds_subscription_offers + + validates :duration, presence: true, allow_blank: false, + numericality: { only_integer: true, greater_than_or_equal_to: 0 } + 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' } + + default_scope { where(deleted_at: nil) } + + def soft_delete + update(deleted_at: Time.zone.now) if deleted_at.nil? + end +end diff --git a/app/models/user.rb b/app/models/user.rb index cda8884d..271192ac 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2,8 +2,11 @@ class User < ApplicationRecord has_many :machines, dependent: :destroy - has_many :subscriptions, dependent: :destroy has_many :free_accesses, dependent: :destroy + has_many :sales_as_client, class_name: 'Sale', foreign_key: 'client_id', dependent: :destroy, inverse_of: :client + has_many :sales_as_seller, class_name: 'Sale', foreign_key: 'seller_id', dependent: :nullify, inverse_of: :seller + has_many :refunds, foreign_key: 'refunder_id', dependent: :destroy, inverse_of: :refunder + has_many :subscriptions, through: :sales_as_client, dependent: :destroy normalizes :email, with: ->(email) { email.strip.downcase } normalizes :room, with: ->(room) { room.downcase.upcase_first } @@ -19,6 +22,14 @@ class User < ApplicationRecord # @return [Array] attr_accessor :groups + def display_name + "#{firstname.capitalize} #{lastname.upcase}" + end + + def display_address + "Appartement #{room}\nRésidence Léonard de Vinci\nAvenue Paul Langevin\n59650 Villeneuve-d'Ascq" + end + def current_subscription subscriptions.where(cancelled_at: nil).order(end_at: :desc).first end @@ -43,12 +54,6 @@ def extend_subscription(duration:) subscriptions.new(start_at: start_at, end_at: start_at + duration.months) end - def cancel_current_subscription! - current_subscription&.cancel! - - save! - end - def self.upsert_from_auth_hash(auth_hash) user = find_or_initialize_by("#{auth_hash[:provider]}_id": auth_hash[:uid]) user.update_from_sso(firstname: auth_hash[:info][:first_name], diff --git a/app/services/invoice_pdf_generator.rb b/app/services/invoice_pdf_generator.rb new file mode 100644 index 00000000..3b4b26c3 --- /dev/null +++ b/app/services/invoice_pdf_generator.rb @@ -0,0 +1,231 @@ +# frozen_string_literal: true + +require 'hexapdf' + +class InvoiceLib + FOOTER = <<~FOOTER + Le délai de paiement est de 45 jours + fin du mois, à partir de la date de facturation (date d'émission de la facture). + En cas de retard de paiement, seront exigibles, conformément à l'article L 441-6 du code de commerce, une indemnité calculée sur la base de trois fois le taux de l'intérêt légal en vigueur ainsi qu'une indemnité forfaitaire pour frais de recouvrement de 40 euros. + FOOTER + + CONDITIONS = <<~CONDITIONS + Conditions de règlement : Prix comptant sans escompte + Moyen de paiement : Chèque (ordre : Rézoléo), Espèces ou Virement + Conditions de vente : Prix de départ + + (1) TVA non applicable, article 293 B du CGI + CONDITIONS + + INFO_REZOLEO = <<~INFOS + École Centrale de Lille - Avenue Paul Langevin - 59650 Villeneuve d'Ascq + rezoleo@rezoleo.fr + SIRET : 831 134 804 00010 + IBAN : FR76 1670 6050 8753 9414 0728 132 + INFOS + + class PDFMetadata + attr_reader :title, :author, :subject, :creation_date + + def initialize(invoice_id:) + @title = "Facture Rézoléo #{invoice_id}" + @author = 'Association Rézoléo' + @subject = "Facture #{invoice_id}" + @creation_date = Time.now # rubocop:disable Rails/TimeZone + end + end +end + +class InvoicePdfGenerator + BASE_FONT_SIZE = 12 + + # @input should be a hash with the following keys: + # - :invoice_id (String) - ID of the invoice + # - :sale_date (String) - Date of sale + # - :issue_date (String) - Date the invoice was issued + # - :client_name (String) - Name of the client + # - :client_address (String) - Address of the client + # - :items (Array of Hashes) - List of items, each item being a hash with keys: + # - :item_name (String) - Name of the item + # - :price_cents (Integer) - Price of the item in cents + # - :quantity (Integer) - Quantity of the item + # - :payment_amount_cents (Integer, optional) - Amount already paid in cents + # - :payment_date (String, optional) - Date of payment + # - :payment_method (String, optional) - Method of payment + def initialize(input) + @input = input + @doc_metadata = InvoiceLib::PDFMetadata.new(invoice_id: input[:invoice_id]) + @total_price_in_cents = input[:items].sum { |it| it[:price_cents] * it[:quantity] } + @composer = InvoiceComposer.new + setup_document + end + + # @return [StringIO] A stream containing the generated PDF file data + def generate_pdf + add_invoice_header + add_client_info + add_items_table + add_totals + add_payment_info + + # return the result directly as a file stream + pdf_stream = StringIO.new + @composer.write(pdf_stream) + pdf_stream.rewind + pdf_stream + end + + private + + def setup_document + @composer.document.metadata.title(@doc_metadata.title) + @composer.document.metadata.author(@doc_metadata.author) + @composer.document.metadata.subject(@doc_metadata.subject) + @composer.document.metadata.creation_date(@doc_metadata.creation_date) + end + + def add_invoice_header + invoice_header = <<~HEADER + Facture n°#{@input[:invoice_id]} + Date de vente : #{@input[:sale_date]} + Date d'émission : #{@input[:issue_date]} + HEADER + + @composer.text(invoice_header, style: :base, text_align: :center, margin: margin_bottom(2)) + @composer.text('Association Rézoléo (Trésorerie)', style: :bold, margin: margin_bottom(1)) + @composer.text(InvoiceLib::INFO_REZOLEO, margin: margin_bottom(2)) + end + + def add_client_info + @composer.text('Client', style: :bold, margin: margin_bottom(1)) + @composer.text(@input[:client_name], margin: margin_bottom(1)) + @composer.text(@input[:client_address], margin: margin_bottom(3)) + end + + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def add_items_table + header = lambda do |_tb| + [ + { background_color: 'C0C0C0' }, + [ + table('ID', style: :small), + table('Désignation article', style: :small), + table('Prix unit. HT', style: :small, text_align: :right), + table('Quantité', style: :small, text_align: :right), + table('TVA (1)', style: :small, text_align: :right), + table('Total TTC', style: :small, text_align: :right) + ] + ] + end + + data = @input[:items].each_with_index.map do |item, i| + item_id = i + 1 + item_name = item[:item_name] + price_in_cents = item[:price_cents] + quantity = item[:quantity] + + [ + item_id.to_s, + item_name, + table(format_cents(price_in_cents), text_align: :right), + table(quantity.to_s, text_align: :right), + table('0%', text_align: :right), + table(format_cents(price_in_cents * quantity), text_align: :right) + ] + end + + data << [ + { content: 'Total', col_span: 5 }, + table(format_cents(@total_price_in_cents), text_align: :right) + ] + + @composer.table(data, column_widths: [-1, -9, -3, -2, -2, -3], header: header, margin: margin_bottom(2)) + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + def add_totals + ht_s = "Somme totale hors taxes (en euros, HT) : #{format_cents(@total_price_in_cents)}" + ttc_s = "Somme totale à payer toutes taxes comprises (en euros, TTC) : #{format_cents(@total_price_in_cents)}" + + @composer.text(ht_s) + @composer.text(ttc_s, margin: margin_bottom(1)) + @composer.text(InvoiceLib::CONDITIONS, style: :conditions, margin: margin_bottom(3)) + end + + def add_payment_info + header = lambda do |_tb| + [{ background_color: 'C0C0C0' }, + [ + table('Date', style: :small), + table('Règlement', style: :small), + table('Montant', style: :small, text_align: :right), + table('À payer', style: :small, text_align: :right) + ]] + end + + payed_in_cents = @input[:payment_amount_cents] || 0 + left_to_pay_in_cents = @total_price_in_cents - payed_in_cents + + data = [[ + table(@input[:payment_date] || '', style: :small), + table(@input[:payment_method] || '', style: :small), + table(format_cents(payed_in_cents), style: :small, text_align: :right), + table(format_cents(left_to_pay_in_cents), style: :small, text_align: :right) + ]] + + @composer.table(data, column_widths: [-3, -5, -2, -2], header: header, width: 300) + end + + def margin_bottom(lines) + [0, 0, lines * BASE_FONT_SIZE] + end + + def format_cents(cents) + "#{format('%.2f', cents.to_f / 100)}€" + end + + def table(text, style: :base, text_align: :left) + @composer.document.layout.text(text, style: style, text_align: text_align) + end + + class InvoiceComposer < HexaPDF::Composer + def initialize(page_size: :A4, page_orientation: :portrait, margin: 36) + super + + document.task(:pdfa) + end + + def new_page + super + + config_font(font_name: 'DejaVu Sans', + font_file: Rails.root.join('app/assets/fonts/DejaVuSans.ttf').to_s, + bold_font_file: Rails.root.join('app/assets/fonts/DejaVuSans-Bold.ttf').to_s) + + config_font_style(font: 'DejaVu Sans') + + image(Rails.root.join('app/assets/images/rezoleo_logo.png').to_s, width: 75, position: :float, mask_mode: :none) + text('Facture Rézoléo', style: :header, margin: [0, 0, 2 * BASE_FONT_SIZE]) + text(InvoiceLib::FOOTER, style: :footer, position: [0, 0]) + end + + private + + def config_font(font_name:, font_file:, bold_font_file:) + document.config['font.map'] = { + font_name.to_s => { + none: font_file.to_s, + bold: bold_font_file.to_s + } + } + end + + def config_font_style(font:) + style(:base, font: font, font_size: BASE_FONT_SIZE, line_spacing: 1.2) + style(:bold, font: [font, { variant: :bold }]) + style(:header, font: [font, { variant: :bold }], font_size: BASE_FONT_SIZE * 7 / 6, text_align: :center) + style(:footer, font: font, font_size: BASE_FONT_SIZE / 2, text_align: :center) + style(:small, font: font, font_size: BASE_FONT_SIZE * 0.75) + style(:conditions, font: font, font_size: BASE_FONT_SIZE * 5 / 6, fill_color: '3C3C3C') + end + end +end diff --git a/app/views/admin/articles/_article.html.erb b/app/views/admin/articles/_article.html.erb new file mode 100644 index 00000000..6a86ec52 --- /dev/null +++ b/app/views/admin/articles/_article.html.erb @@ -0,0 +1,11 @@ +<%# locals: (article:) %> + +
+ <%= article.name %> + <%= article.price.to_f / 100 %> € + <% if can?(:destroy, article) %> + <%= button_to(article, method: :delete, data: { turbo_confirm: "Are you sure ?" }, 'aria-label': 'Delete this article') do %> + <%= svg_icon_tag 'icon_delete' %> + <% end %> + <% end %> +
\ No newline at end of file diff --git a/app/views/admin/articles/new.html.erb b/app/views/admin/articles/new.html.erb new file mode 100644 index 00000000..6e59cf60 --- /dev/null +++ b/app/views/admin/articles/new.html.erb @@ -0,0 +1,20 @@ +<%# locals: () %> + +<% provide :button_text, "Create" %> + +

Add a new article

+ +<%= form_with(model: @article, class: 'form') do |f| %> + <%= render 'utils/error_messages', object: f.object %> +
+ <%= f.label :name %> + <%= f.text_field :name %> +
+
+ <%= f.label :price %> + <%= f.number_field :price %> +
+
+ <%= f.submit yield(:button_text) %> +
+<% end %> \ No newline at end of file diff --git a/app/views/admin/dashboard/index.html.erb b/app/views/admin/dashboard/index.html.erb new file mode 100644 index 00000000..b0a3e8ec --- /dev/null +++ b/app/views/admin/dashboard/index.html.erb @@ -0,0 +1,36 @@ +<%# locals: () %> + +

Admin dashboard

+ +<% if can?(:read, Article) %> +

Articles

+ <% if can?(:create, Article) %> + <%= button_to new_article_path, method: :get, class: 'button-primary' do %> + <%= 'Create article' %> + <%= svg_icon_tag 'icon_plus' %> + <% end %> + <% end %> + <%= render(@articles) || "No articles" %> +<% end %> + +<% if can?(:read, SubscriptionOffer) %> +

Subscription offers

+ <% if can?(:create, SubscriptionOffer) %> + <%= button_to new_subscription_offer_path, method: :get, class: 'button-primary' do %> + <%= 'Create Subscription Offer' %> + <%= svg_icon_tag 'icon_plus' %> + <% end %> + <% end %> + <%= render(@subscription_offers) || "No subscription offers" %> +<% end %> + +<% if can?(:read, PaymentMethod) %> +

Payment Methods

+ <% if can?(:create, PaymentMethod) %> + <%= button_to new_payment_method_path, method: :get, class: 'button-primary' do %> + <%= 'Create Payment Method' %> + <%= svg_icon_tag 'icon_plus' %> + <% end %> + <% end %> + <%= render(@payment_methods) || "No payment methods" %> +<% end %> \ No newline at end of file diff --git a/app/views/admin/payment_methods/_payment_method.html.erb b/app/views/admin/payment_methods/_payment_method.html.erb new file mode 100644 index 00000000..6f868a6b --- /dev/null +++ b/app/views/admin/payment_methods/_payment_method.html.erb @@ -0,0 +1,11 @@ +<%# locals: (payment_method:) %> + +
+ <%= payment_method.name %> + <%= payment_method.auto_verify.to_s %> + <% if can?(:destroy, payment_method) %> + <%= button_to(payment_method, method: :delete, data: { turbo_confirm: "Are you sure ?" }, 'aria-label': 'Delete this payment method') do %> + <%= svg_icon_tag 'icon_delete' %> + <% end %> + <% end %> +
\ No newline at end of file diff --git a/app/views/admin/payment_methods/new.html.erb b/app/views/admin/payment_methods/new.html.erb new file mode 100644 index 00000000..dd61473c --- /dev/null +++ b/app/views/admin/payment_methods/new.html.erb @@ -0,0 +1,20 @@ +<%# locals: () %> + +<% provide :button_text, "Create" %> + +

Add a new payment method

+ +<%= form_with(model: @payment_method, class: 'form') do |f| %> + <%= render 'utils/error_messages', object: f.object %> +
+ <%= f.label :name %> + <%= f.text_field :name %> +
+
+ <%= f.label :auto_verify %> + <%= f.check_box :auto_verify %> +
+
+ <%= f.submit yield(:button_text) %> +
+<% end %> \ No newline at end of file diff --git a/app/views/admin/subscription_offers/_subscription_offer.html.erb b/app/views/admin/subscription_offers/_subscription_offer.html.erb new file mode 100644 index 00000000..495b27f1 --- /dev/null +++ b/app/views/admin/subscription_offers/_subscription_offer.html.erb @@ -0,0 +1,11 @@ +<%# locals: (subscription_offer:) %> + +
+ <%= subscription_offer.duration %> months + <%= subscription_offer.price.to_f / 100 %> € + <% if can?(:destroy, subscription_offer) %> + <%= button_to(subscription_offer, method: :delete, data: { turbo_confirm: "Are you sure ?" }, 'aria-label': 'Delete this subscription offer') do %> + <%= svg_icon_tag 'icon_delete' %> + <% end %> + <% end %> +
\ No newline at end of file diff --git a/app/views/admin/subscription_offers/new.html.erb b/app/views/admin/subscription_offers/new.html.erb new file mode 100644 index 00000000..23724b9d --- /dev/null +++ b/app/views/admin/subscription_offers/new.html.erb @@ -0,0 +1,20 @@ +<%# locals: () %> + +<% provide :button_text, "Create" %> + +

Add a new subscription offer

+ +<%= form_with(model: @subscription_offer, class: 'form') do |f| %> + <%= render 'utils/error_messages', object: f.object %> +
+ <%= f.label :duration %> + <%= f.text_field :duration %> +
+
+ <%= f.label :price %> + <%= f.number_field :price %> +
+
+ <%= f.submit yield(:button_text) %> +
+<% end %> \ No newline at end of file diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index cae3c4a1..a54f60a1 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -11,6 +11,11 @@ <%= link_to "Users", users_path %> <% end %> + <% if can?(:manage, :all) %> +
  • + <%= link_to "Admin", admin_path %> +
  • + <% end %> diff --git a/app/views/sales/new.html.erb b/app/views/sales/new.html.erb new file mode 100644 index 00000000..be7ab482 --- /dev/null +++ b/app/views/sales/new.html.erb @@ -0,0 +1,40 @@ +<%# locals: () %> + +<% provide :button_text, "Create" %> + +

    Create new sale

    + +<%= form_with(model: [@owner, @sale], class: 'form', data: { controller: 'sales', articles: @articles, subscriptions: @subscription_offers }) do |f| %> + <%= render 'utils/error_messages', object: f.object %> + + Total price : 0.00 + +
    + <%= f.label :duration %> + Sub price : 0.00 + <%= f.number_field :duration, step: 1, min: 1, data: { action: 'change->sales#updateSubPrice' } %> +
    + + + + + +
    + <%= f.label :payment_method_id %> + <%= f.select :payment_method_id, @payment_methods.collect { |p| [p.name, p.id] }, prompt: 'Select payment method' %> +
    + +
    + <%= f.submit yield(:button_text) %> +
    +<% end %> diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 1ed73fc9..9a2a425e 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -11,14 +11,15 @@
    + <% if can?(:create, Sale) %> + <%= render "components/buttons/button_primary_create", text: "Sell", path: new_user_sale_path(@user), aria_label: "Create a sell" %> + <% end %>
    -
    <%= svg_icon_tag 'profile' %>

    Personal Data

    - <%= render "components/buttons/button_primary_edit", text: "edit", path: edit_user_path(@user), aria_label: "Edit your profile" %>
    @@ -75,13 +76,6 @@

    Subscriptions

    - - <% if can?(:create, Subscription) %> - <%= render "components/buttons/button_primary_create", text: "new subscription", path: new_user_subscription_path(@user), aria_label: "Add a new subscription" %> - <% end %> - <% if can?(:destroy, Subscription) && @user.current_subscription %> - <%= render "components/buttons/button_secondary_delete", text: "cancel last subscription", path: user_last_subscription_path(@user), aria_label: "Cancel last subscription" %> - <% end %>
    diff --git a/config/routes.rb b/config/routes.rb index 767c301a..cb876056 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -8,11 +8,19 @@ resources :users do resources :machines, shallow: true, except: [:index] - resources :subscriptions, shallow: true, only: [:new, :create] - delete '/last_subscription', to: 'subscriptions#destroy', as: 'last_subscription' + resources :sales, shallow: true, only: [:new, :create] do + resources :refunds, shallow: true, only: [:new, :create] + end resources :free_accesses, shallow: true, except: [:index, :show] end + scope module: :admin do + get '/admin', as: 'admin', to: 'dashboard#index' + resources :articles, only: [:new, :create, :destroy] + resources :subscription_offers, only: [:new, :create, :destroy] + resources :payment_methods, only: [:new, :create, :destroy] + end + get '/search', as: 'search', to: 'search#search' # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. diff --git a/db/migrate/20240620123401_create_payment_methods.rb b/db/migrate/20240620123401_create_payment_methods.rb new file mode 100644 index 00000000..696e66c1 --- /dev/null +++ b/db/migrate/20240620123401_create_payment_methods.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreatePaymentMethods < ActiveRecord::Migration[7.0] + def change + create_table :payment_methods do |t| + t.string :name, null: false + t.boolean :auto_verify, default: false, null: false + t.datetime :deleted_at + + t.timestamps + end + end +end diff --git a/db/migrate/20240620123448_create_invoices.rb b/db/migrate/20240620123448_create_invoices.rb new file mode 100644 index 00000000..31d457d3 --- /dev/null +++ b/db/migrate/20240620123448_create_invoices.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class CreateInvoices < ActiveRecord::Migration[7.0] + def change + create_table :invoices do |t| + t.jsonb :generation_json + + t.timestamps + end + end +end diff --git a/db/migrate/20240620123547_create_sales.rb b/db/migrate/20240620123547_create_sales.rb new file mode 100644 index 00000000..fd953f6a --- /dev/null +++ b/db/migrate/20240620123547_create_sales.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateSales < ActiveRecord::Migration[7.0] + def change + create_table :sales do |t| + t.references :seller, null: true, foreign_key: { to_table: :users } + t.references :client, null: false, foreign_key: { to_table: :users } + t.references :payment_method, null: false, foreign_key: true + t.references :invoice, null: false, foreign_key: true + t.integer :total_price + t.datetime :verified_at + + t.timestamps + end + end +end diff --git a/db/migrate/20240620123719_create_refunds.rb b/db/migrate/20240620123719_create_refunds.rb new file mode 100644 index 00000000..1455d5e3 --- /dev/null +++ b/db/migrate/20240620123719_create_refunds.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateRefunds < ActiveRecord::Migration[7.0] + def change + create_table :refunds do |t| + t.references :refunder, null: true, foreign_key: { to_table: :users } + t.references :refund_method, null: false, foreign_key: { to_table: :payment_methods } + t.references :sale, null: false, foreign_key: true + t.references :invoice, null: false, foreign_key: true + t.integer :total_price + t.string :reason + t.datetime :verified_at + + t.timestamps + end + end +end diff --git a/db/migrate/20240620123752_create_articles.rb b/db/migrate/20240620123752_create_articles.rb new file mode 100644 index 00000000..12519921 --- /dev/null +++ b/db/migrate/20240620123752_create_articles.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreateArticles < ActiveRecord::Migration[7.0] + def change + create_table :articles do |t| + t.string :name, null: false + t.integer :price, null: false + t.datetime :deleted_at + + t.timestamps + end + end +end diff --git a/db/migrate/20240620123928_create_subscription_offers.rb b/db/migrate/20240620123928_create_subscription_offers.rb new file mode 100644 index 00000000..b2eedb6b --- /dev/null +++ b/db/migrate/20240620123928_create_subscription_offers.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreateSubscriptionOffers < ActiveRecord::Migration[7.0] + def change + create_table :subscription_offers do |t| + t.integer :duration, null: false + t.integer :price, null: false + t.datetime :deleted_at + + t.timestamps + end + end +end diff --git a/db/migrate/20240620180613_add_sale_id_to_subscriptions.rb b/db/migrate/20240620180613_add_sale_id_to_subscriptions.rb new file mode 100644 index 00000000..7102a0fa --- /dev/null +++ b/db/migrate/20240620180613_add_sale_id_to_subscriptions.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddSaleIdToSubscriptions < ActiveRecord::Migration[7.0] + def change + add_reference :subscriptions, :sale, null: false, foreign_key: true # rubocop:disable Rails/NotNullColumn + end +end diff --git a/db/migrate/20240701184949_remove_user_from_subscriptions.rb b/db/migrate/20240701184949_remove_user_from_subscriptions.rb new file mode 100644 index 00000000..52cadc17 --- /dev/null +++ b/db/migrate/20240701184949_remove_user_from_subscriptions.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class RemoveUserFromSubscriptions < ActiveRecord::Migration[7.0] + def change + remove_column :subscriptions, :user_id, :integer + end +end diff --git a/db/migrate/20240720124420_create_sale_article_details.rb b/db/migrate/20240720124420_create_sale_article_details.rb new file mode 100644 index 00000000..91a6507f --- /dev/null +++ b/db/migrate/20240720124420_create_sale_article_details.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateSaleArticleDetails < ActiveRecord::Migration[7.0] + def change + create_table :articles_sales do |t| + t.references :sale, null: false, foreign_key: true + t.references :article, null: false, foreign_key: true + t.integer :quantity + + t.timestamps + end + # create_join_table :sales, :articles, column_options: { foreign_key: true } do |t| + # t.integer :quantity, null: false + # end + end +end diff --git a/db/migrate/20240720124517_create_sale_subscription_details.rb b/db/migrate/20240720124517_create_sale_subscription_details.rb new file mode 100644 index 00000000..afad1435 --- /dev/null +++ b/db/migrate/20240720124517_create_sale_subscription_details.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateSaleSubscriptionDetails < ActiveRecord::Migration[7.0] + def change + # create_join_table :sales, :subscription_offers, column_options: { foreign_key: true } do |t| + # t.integer :duration, null: false + # end + create_table :sales_subscription_offers do |t| + t.references :sale, null: false, foreign_key: true + t.references :subscription_offer, null: false, foreign_key: true + t.integer :duration + + t.timestamps + end + end +end diff --git a/db/migrate/20240720124542_create_refund_article_details.rb b/db/migrate/20240720124542_create_refund_article_details.rb new file mode 100644 index 00000000..f5444214 --- /dev/null +++ b/db/migrate/20240720124542_create_refund_article_details.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateRefundArticleDetails < ActiveRecord::Migration[7.0] + def change + # create_join_table :refunds, :articles, column_options: { foreign_key: true } do |t| + # t.integer :quantity, null: false + # end + create_table :articles_refunds do |t| + t.references :refund, null: false, foreign_key: true + t.references :article, null: false, foreign_key: true + t.integer :quantity + + t.timestamps + end + end +end diff --git a/db/migrate/20240720124608_create_refund_subscription_details.rb b/db/migrate/20240720124608_create_refund_subscription_details.rb new file mode 100644 index 00000000..d359567e --- /dev/null +++ b/db/migrate/20240720124608_create_refund_subscription_details.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateRefundSubscriptionDetails < ActiveRecord::Migration[7.0] + def change + # create_join_table :refunds, :subscription_offers, column_options: { foreign_key: true } do |t| + # t.integer :duration, null: false + # end + create_table :refunds_subscription_offers do |t| + t.references :refund, null: false, foreign_key: true + t.references :subscription_offer, null: false, foreign_key: true + t.integer :duration + + t.timestamps + end + end +end diff --git a/db/migrate/20240720124609_rename_duration_to_quantity_in_subscription_offers_join_table.rb b/db/migrate/20240720124609_rename_duration_to_quantity_in_subscription_offers_join_table.rb new file mode 100644 index 00000000..01fb509a --- /dev/null +++ b/db/migrate/20240720124609_rename_duration_to_quantity_in_subscription_offers_join_table.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class RenameDurationToQuantityInSubscriptionOffersJoinTable < ActiveRecord::Migration[7.0] + def change + rename_column :sales_subscription_offers, :duration, :quantity + rename_column :refunds_subscription_offers, :duration, :quantity + end +end diff --git a/db/migrate/20240720124610_create_active_storage_tables.active_storage.rb b/db/migrate/20240720124610_create_active_storage_tables.active_storage.rb new file mode 100644 index 00000000..70ab4a8b --- /dev/null +++ b/db/migrate/20240720124610_create_active_storage_tables.active_storage.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# This migration comes from active_storage (originally 20170806125915) +class CreateActiveStorageTables < ActiveRecord::Migration[7.0] + # rubocop:disable Metrics/MethodLength + # rubocop:disable Metrics/AbcSize + def change + # Use Active Record's configured type for primary and foreign keys + primary_key_type, foreign_key_type = primary_and_foreign_key_types + + create_table :active_storage_blobs, id: primary_key_type do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.string :service_name, null: false + t.bigint :byte_size, null: false + t.string :checksum + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [:key], unique: true + end + + create_table :active_storage_attachments, id: primary_key_type do |t| + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type + t.references :blob, null: false, type: foreign_key_type + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [:record_type, :record_id, :name, :blob_id], name: :index_active_storage_attachments_uniqueness, + unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + + create_table :active_storage_variant_records, id: primary_key_type do |t| + t.belongs_to :blob, null: false, index: false, type: foreign_key_type + t.string :variation_digest, null: false + + t.index [:blob_id, :variation_digest], name: :index_active_storage_variant_records_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end + # rubocop:enable Metrics/MethodLength + # rubocop:enable Metrics/AbcSize + + private + + def primary_and_foreign_key_types + config = Rails.configuration.generators + setting = config.options[config.orm][:primary_key_type] + primary_key_type = setting || :primary_key + foreign_key_type = setting || :bigint + [primary_key_type, foreign_key_type] + end +end diff --git a/db/migrate/20240720124611_create_settings.rb b/db/migrate/20240720124611_create_settings.rb new file mode 100644 index 00000000..ab55d712 --- /dev/null +++ b/db/migrate/20240720124611_create_settings.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CreateSettings < ActiveRecord::Migration[7.1] + def change + create_table :settings do |t| + t.string :key, null: false + t.string :value, null: false + t.timestamps + end + add_index :settings, :key, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 727a9128..f8fdbf53 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,66 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_05_31_195758) do +ActiveRecord::Schema[7.1].define(version: 2024_07_20_124611) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + create_table "active_storage_attachments", force: :cascade do |t| + t.string "name", null: false + t.string "record_type", null: false + t.bigint "record_id", null: false + t.bigint "blob_id", null: false + t.datetime "created_at", null: false + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + end + + create_table "active_storage_blobs", force: :cascade do |t| + t.string "key", null: false + t.string "filename", null: false + t.string "content_type" + t.text "metadata" + t.string "service_name", null: false + t.bigint "byte_size", null: false + t.string "checksum" + t.datetime "created_at", null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + + create_table "active_storage_variant_records", force: :cascade do |t| + t.bigint "blob_id", null: false + t.string "variation_digest", null: false + t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true + end + + create_table "articles", force: :cascade do |t| + t.string "name", null: false + t.integer "price", null: false + t.datetime "deleted_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "articles_refunds", force: :cascade do |t| + t.bigint "refund_id", null: false + t.bigint "article_id", null: false + t.integer "quantity" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["article_id"], name: "index_articles_refunds_on_article_id" + t.index ["refund_id"], name: "index_articles_refunds_on_refund_id" + end + + create_table "articles_sales", force: :cascade do |t| + t.bigint "sale_id", null: false + t.bigint "article_id", null: false + t.integer "quantity" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["article_id"], name: "index_articles_sales_on_article_id" + t.index ["sale_id"], name: "index_articles_sales_on_sale_id" + end + create_table "free_accesses", force: :cascade do |t| t.bigint "user_id", null: false t.datetime "start_at", null: false @@ -24,6 +80,12 @@ t.index ["user_id"], name: "index_free_accesses_on_user_id" end + create_table "invoices", force: :cascade do |t| + t.jsonb "generation_json" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "ips", force: :cascade do |t| t.inet "ip", null: false t.bigint "machine_id" @@ -43,15 +105,90 @@ t.index ["user_id"], name: "index_machines_on_user_id" end + create_table "payment_methods", force: :cascade do |t| + t.string "name", null: false + t.boolean "auto_verify", default: false, null: false + t.datetime "deleted_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "refunds", force: :cascade do |t| + t.bigint "refunder_id" + t.bigint "refund_method_id", null: false + t.bigint "sale_id", null: false + t.bigint "invoice_id", null: false + t.integer "total_price" + t.string "reason" + t.datetime "verified_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["invoice_id"], name: "index_refunds_on_invoice_id" + t.index ["refund_method_id"], name: "index_refunds_on_refund_method_id" + t.index ["refunder_id"], name: "index_refunds_on_refunder_id" + t.index ["sale_id"], name: "index_refunds_on_sale_id" + end + + create_table "refunds_subscription_offers", force: :cascade do |t| + t.bigint "refund_id", null: false + t.bigint "subscription_offer_id", null: false + t.integer "quantity" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["refund_id"], name: "index_refunds_subscription_offers_on_refund_id" + t.index ["subscription_offer_id"], name: "index_refunds_subscription_offers_on_subscription_offer_id" + end + + create_table "sales", force: :cascade do |t| + t.bigint "seller_id" + t.bigint "client_id", null: false + t.bigint "payment_method_id", null: false + t.bigint "invoice_id", null: false + t.integer "total_price" + t.datetime "verified_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["client_id"], name: "index_sales_on_client_id" + t.index ["invoice_id"], name: "index_sales_on_invoice_id" + t.index ["payment_method_id"], name: "index_sales_on_payment_method_id" + t.index ["seller_id"], name: "index_sales_on_seller_id" + end + + create_table "sales_subscription_offers", force: :cascade do |t| + t.bigint "sale_id", null: false + t.bigint "subscription_offer_id", null: false + t.integer "quantity" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["sale_id"], name: "index_sales_subscription_offers_on_sale_id" + t.index ["subscription_offer_id"], name: "index_sales_subscription_offers_on_subscription_offer_id" + end + + create_table "settings", force: :cascade do |t| + t.string "key", null: false + t.string "value", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["key"], name: "index_settings_on_key", unique: true + end + + create_table "subscription_offers", force: :cascade do |t| + t.integer "duration", null: false + t.integer "price", null: false + t.datetime "deleted_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "subscriptions", force: :cascade do |t| t.datetime "cancelled_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.bigint "user_id", null: false t.datetime "start_at", null: false t.datetime "end_at", null: false t.virtual "duration", type: :integer, comment: "Duration in months", as: "((EXTRACT(year FROM age(date_trunc('months'::text, end_at), date_trunc('months'::text, start_at))) * (12)::numeric) + EXTRACT(month FROM age(date_trunc('months'::text, end_at), date_trunc('months'::text, start_at))))", stored: true - t.index ["user_id"], name: "index_subscriptions_on_user_id" + t.bigint "sale_id", null: false + t.index ["sale_id"], name: "index_subscriptions_on_sale_id" end create_table "users", force: :cascade do |t| @@ -67,8 +204,26 @@ t.index ["room"], name: "index_users_on_room", unique: true end + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" + add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "articles_refunds", "articles" + add_foreign_key "articles_refunds", "refunds" + add_foreign_key "articles_sales", "articles" + add_foreign_key "articles_sales", "sales" add_foreign_key "free_accesses", "users" add_foreign_key "ips", "machines" add_foreign_key "machines", "users" - add_foreign_key "subscriptions", "users" + add_foreign_key "refunds", "invoices" + add_foreign_key "refunds", "payment_methods", column: "refund_method_id" + add_foreign_key "refunds", "sales" + add_foreign_key "refunds", "users", column: "refunder_id" + add_foreign_key "refunds_subscription_offers", "refunds" + add_foreign_key "refunds_subscription_offers", "subscription_offers" + add_foreign_key "sales", "invoices" + add_foreign_key "sales", "payment_methods" + add_foreign_key "sales", "users", column: "client_id" + add_foreign_key "sales", "users", column: "seller_id" + add_foreign_key "sales_subscription_offers", "sales" + add_foreign_key "sales_subscription_offers", "subscription_offers" + add_foreign_key "subscriptions", "sales" end diff --git a/test/controllers/admin/articles_controller_test.rb b/test/controllers/admin/articles_controller_test.rb new file mode 100644 index 00000000..d7b4ec9d --- /dev/null +++ b/test/controllers/admin/articles_controller_test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Admin + class ArticlesControllerTest < ActionDispatch::IntegrationTest + def setup + @articles = articles(:one) + @user = users(:ironman) + sign_in_as @user, ['rezoleo'] + end + + test 'should get new' do + get new_article_path + assert_template 'admin/articles/new' + end + + test 'should create article' do + assert_difference 'Article.unscoped.count', 1 do + post articles_path, params: { article: { name: 'test_name', price: 1456 } } + end + assert_redirected_to admin_path + end + + test 'should re-render if missing article information' do + assert_no_difference 'Article.unscoped.count' do + post articles_path, params: { article: { name: nil } } + end + + assert_template 'admin/articles/new' + end + + test 'should soft_delete article' do + assert_no_difference 'Article.unscoped.count' do + delete article_path(@articles) + end + assert_redirected_to admin_path + end + end +end diff --git a/test/controllers/admin/dashboard_controller_test.rb b/test/controllers/admin/dashboard_controller_test.rb new file mode 100644 index 00000000..221beaed --- /dev/null +++ b/test/controllers/admin/dashboard_controller_test.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Admin + class DashboardControllerTest < ActionDispatch::IntegrationTest + def setup + @user = users(:ironman) + sign_in_as @user, ['rezoleo'] + end + + test 'should show index' do + get admin_path + assert_template 'admin/dashboard/index' + end + end +end diff --git a/test/controllers/admin/payment_methods_controller_test.rb b/test/controllers/admin/payment_methods_controller_test.rb new file mode 100644 index 00000000..28e58d7b --- /dev/null +++ b/test/controllers/admin/payment_methods_controller_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Admin + class PaymentMethodsControllerTest < ActionDispatch::IntegrationTest + def setup + @payment_method = payment_methods(:one) + @user = users(:ironman) + sign_in_as @user, ['rezoleo'] + end + + test 'should get new' do + get new_payment_method_path + assert_template 'admin/payment_methods/new' + end + + test 'should create payment_method' do + assert_difference 'PaymentMethod.unscoped.count', 1 do + post payment_methods_path, params: { payment_method: { name: 'Credit Card', auto_verify: true } } + end + assert_redirected_to admin_path + end + + test 'should re-render if missing payment_method information' do + assert_no_difference 'PaymentMethod.unscoped.count' do + post payment_methods_path, params: { payment_method: { name: nil } } + end + + assert_template 'admin/payment_methods/new' + end + + test 'should not destroy payment_method if soft_delete' do + assert_no_difference 'PaymentMethod.unscoped.count' do + @payment_method.soft_delete + end + end + + test 'should soft_delete payment_method' do + assert_no_difference 'PaymentMethod.unscoped.count' do + delete payment_method_path(@payment_method) + end + assert_redirected_to admin_path + end + end +end diff --git a/test/controllers/admin/subscription_offers_controller_test.rb b/test/controllers/admin/subscription_offers_controller_test.rb new file mode 100644 index 00000000..3b91e109 --- /dev/null +++ b/test/controllers/admin/subscription_offers_controller_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Admin + class SubscriptionOffersControllerTest < ActionDispatch::IntegrationTest + def setup + @subscription_offer = subscription_offers(:one) + @user = users(:ironman) + sign_in_as @user, ['rezoleo'] + end + + test 'should get new' do + get new_subscription_offer_path + assert_template 'admin/subscription_offers/new' + end + + test 'should create offer' do + assert_difference 'SubscriptionOffer.unscoped.count', 1 do + post subscription_offers_path, params: { subscription_offer: { duration: 10, price: 1456 } } + end + assert_redirected_to admin_path + end + + test 'should re-render if missing offer information' do + assert_no_difference 'SubscriptionOffer.unscoped.count' do + post subscription_offers_path, params: { subscription_offer: { duration: nil } } + end + + assert_template 'admin/subscription_offers/new' + end + + test 'should not destroy offer if soft_delete' do + assert_no_difference 'SubscriptionOffer.unscoped.count' do + @subscription_offer.soft_delete + end + end + + test 'should soft_delete offer' do + assert_no_difference 'SubscriptionOffer.unscoped.count' do + delete subscription_offer_path(@subscription_offer) + end + assert_redirected_to admin_path + end + end +end diff --git a/test/controllers/refunds_controller_test.rb b/test/controllers/refunds_controller_test.rb new file mode 100644 index 00000000..9537a457 --- /dev/null +++ b/test/controllers/refunds_controller_test.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'test_helper' + +class RefundsControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/controllers/sales_controller_test.rb b/test/controllers/sales_controller_test.rb new file mode 100644 index 00000000..7f808979 --- /dev/null +++ b/test/controllers/sales_controller_test.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'test_helper' + +class SalesControllerTest < ActionDispatch::IntegrationTest + def setup + @user = users(:pepper) + @admin = users(:ironman) + @payment_method = payment_methods(:one) + @article = articles(:one) + @sale_params = { + payment_method_id: @payment_method.id, + duration: 30, + articles_sales_attributes: [ + { article_id: @article.id, quantity: 2 } + ] + } + sign_in_as @admin, ['rezoleo'] + end + + test 'should get new sale form' do + get new_user_sale_path(user_id: @user.id) + assert_response :success + assert_template 'sales/new' + assert_select 'form' + end + + test 'should create sale and redirect if sale is valid' do + assert_difference 'Sale.count', 1 do + post user_sales_path(user_id: @user.id, format: :html), params: { sale: @sale_params } + end + assert_redirected_to @user + assert_equal 'Sale was successfully created.', flash[:success] + end + + test 'should not create sale and redirect if duration is negative' do + @sale_params[:duration] = -5 + assert_no_difference 'Sale.count' do + post user_sales_path(user_id: @user.id, format: :html), params: { sale: @sale_params } + end + assert_response :unprocessable_entity + end + + test 'should not create sale and redirect if sale is invalid' do + @sale_params[:payment_method_id] = PaymentMethod.last.id + 1 + assert_no_difference 'Sale.count' do + post user_sales_path(user_id: @user.id, format: :html), params: { sale: @sale_params } + end + assert_response :unprocessable_entity + end +end diff --git a/test/controllers/subscriptions_controller_test.rb b/test/controllers/subscriptions_controller_test.rb deleted file mode 100644 index f6a58be2..00000000 --- a/test/controllers/subscriptions_controller_test.rb +++ /dev/null @@ -1,71 +0,0 @@ -# frozen_string_literal: true - -require 'test_helper' - -class SubscriptionsControllerTest < ActionDispatch::IntegrationTest - def setup - @subscription = subscriptions(:subscription1) - @owner = @subscription.user - sign_in_as @owner, ['rezoleo'] - end - - test 'should get new' do - get new_user_subscription_path @owner - assert_template 'subscriptions/new' - end - - test 'should create a subscription and redirect if subscription is valid' do - assert_difference 'Subscription.count', 1 do - post user_subscriptions_url @owner, params: { subscription: { duration: 8 } } - end - - assert_redirected_to @owner - end - - test 'should extend user subscription expiration on create' do - freeze_time - @owner.extend_subscription(duration: 2) - @owner.save - old_subscription_expiration = @owner.subscription_expiration - - post user_subscriptions_url @owner, params: { subscription: { duration: 8 } } - - assert_equal old_subscription_expiration + 8.months, @owner.subscription_expiration - end - - test 'should re-render new if subscription is invalid' do - post user_subscriptions_url @owner, params: { subscription: { duration: -1 } } - assert_template 'subscriptions/new' - end - - test 'should cancel without deleting a subscription and redirect to owner' do - assert_no_difference 'Subscription.count' do - delete user_last_subscription_url @owner - end - assert_redirected_to user_url @owner - end - - test 'should do nothing when no subscriptions' do - @owner.subscriptions.destroy_all - @owner.reload - - assert_no_difference 'Subscription.count' do - delete user_last_subscription_url @owner - end - - assert_redirected_to user_url @owner - end - - test 'should reduce user subscription expiration on cancel' do - freeze_time - @owner.extend_subscription(duration: 2) - @owner.save - @owner.extend_subscription(duration: 3) # subscription to be cancelled - @owner.save - assert_equal 5.months.from_now, @owner.subscription_expiration - - delete user_last_subscription_url @owner - - assert_equal 2.months.from_now, @owner.subscription_expiration - end -end diff --git a/test/controllers/subscriptions_controller_user_right_test.rb b/test/controllers/subscriptions_controller_user_right_test.rb deleted file mode 100644 index 3e667ccf..00000000 --- a/test/controllers/subscriptions_controller_user_right_test.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -require 'test_helper' - -class SubscriptionsControllerUserRightTest < ActionDispatch::IntegrationTest - def setup - @user = users(:pepper) - @admin = users(:ironman) - sign_in_as @user - end - - test 'non-admin user should not see subscription creation page' do - assert_raises CanCan::AccessDenied do - get new_user_subscription_path @user - end - end - - test 'non-admin user should not create a new subscription' do - assert_raises CanCan::AccessDenied do - post user_subscriptions_url @user, params: { subscription: { duration: 8 } } - end - end - - test 'non-admin user should not delete a new subscription' do - assert_raises CanCan::AccessDenied do - delete user_last_subscription_url @user - end - end -end diff --git a/test/fixtures/articles.yml b/test/fixtures/articles.yml new file mode 100644 index 00000000..61ea4f54 --- /dev/null +++ b/test/fixtures/articles.yml @@ -0,0 +1,9 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyString + price: 1 + +two: + name: MyString + price: 1 diff --git a/test/fixtures/articles_refunds.yml b/test/fixtures/articles_refunds.yml new file mode 100644 index 00000000..835ab590 --- /dev/null +++ b/test/fixtures/articles_refunds.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + refund: one + article: one + quantity: 1 + +two: + refund: two + article: two + quantity: 1 diff --git a/test/fixtures/articles_sales.yml b/test/fixtures/articles_sales.yml new file mode 100644 index 00000000..9dea8be8 --- /dev/null +++ b/test/fixtures/articles_sales.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + sale: one + article: one + quantity: 1 + +two: + sale: two + article: two + quantity: 1 diff --git a/test/fixtures/invoices.yml b/test/fixtures/invoices.yml new file mode 100644 index 00000000..bc64380d --- /dev/null +++ b/test/fixtures/invoices.yml @@ -0,0 +1,7 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + generation_json: + +two: + generation_json: diff --git a/test/fixtures/payment_methods.yml b/test/fixtures/payment_methods.yml new file mode 100644 index 00000000..ad0a1fc8 --- /dev/null +++ b/test/fixtures/payment_methods.yml @@ -0,0 +1,9 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyString + auto_verify: false + +two: + name: MyString + auto_verify: false diff --git a/test/fixtures/refunds.yml b/test/fixtures/refunds.yml new file mode 100644 index 00000000..c459733b --- /dev/null +++ b/test/fixtures/refunds.yml @@ -0,0 +1,19 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + refunder: ironman + refund_method: one + sale: one + invoice: one + total_price: 1 + reason: MyString + verified_at: 2024-06-20 14:37:19 + +two: + refunder: pepper + refund_method: two + sale: two + invoice: two + total_price: 1 + reason: MyString + verified_at: 2024-06-20 14:37:19 diff --git a/test/fixtures/refunds_subscription_offers.yml b/test/fixtures/refunds_subscription_offers.yml new file mode 100644 index 00000000..f9030c7e --- /dev/null +++ b/test/fixtures/refunds_subscription_offers.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + refund: one + subscription_offer: one + quantity: 1 + +two: + refund: two + subscription_offer: two + quantity: 1 diff --git a/test/fixtures/sales.yml b/test/fixtures/sales.yml new file mode 100644 index 00000000..6814b8a2 --- /dev/null +++ b/test/fixtures/sales.yml @@ -0,0 +1,17 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + seller: pepper + client: ironman + payment_method: one + invoice: one + total_price: 1 + verified_at: 2024-06-20 14:35:47 + +two: + seller: ironman + client: pepper + payment_method: two + invoice: two + total_price: 1 + verified_at: 2024-06-20 14:35:47 diff --git a/test/fixtures/sales_subscription_offers.yml b/test/fixtures/sales_subscription_offers.yml new file mode 100644 index 00000000..b91946a3 --- /dev/null +++ b/test/fixtures/sales_subscription_offers.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + sale: one + subscription_offer: one + quantity: 1 + +two: + sale: two + subscription_offer: two + quantity: 1 diff --git a/test/fixtures/settings.yml b/test/fixtures/settings.yml new file mode 100644 index 00000000..5242c814 --- /dev/null +++ b/test/fixtures/settings.yml @@ -0,0 +1,9 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +# This model initially had no columns defined. If you add columns to the +# model remove the "{}" from the fixture names and add the columns immediately +# below each fixture, per the syntax in the comments below +# +last_invoice_id: + key: "last_invoice_id" + value: "0" diff --git a/test/fixtures/subscription_offers.yml b/test/fixtures/subscription_offers.yml new file mode 100644 index 00000000..b7868f5b --- /dev/null +++ b/test/fixtures/subscription_offers.yml @@ -0,0 +1,9 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + duration: 1 + price: 5 + +two: + duration: 12 + price: 50 diff --git a/test/fixtures/subscriptions.yml b/test/fixtures/subscriptions.yml index 6d86f4a1..296fd14d 100644 --- a/test/fixtures/subscriptions.yml +++ b/test/fixtures/subscriptions.yml @@ -1,11 +1,11 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html subscription1: - user: ironman start_at: 2023-01-01 12:00:00 end_at: 2023-02-01 12:00:00 + sale: one subscription2: - user: pepper start_at: 2023-01-07 12:00:00 end_at: 2023-02-07 12:00:00 + sale: two diff --git a/test/models/article_test.rb b/test/models/article_test.rb new file mode 100644 index 00000000..c645b1b0 --- /dev/null +++ b/test/models/article_test.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ArticleTest < ActiveSupport::TestCase + def setup + @article = articles(:one) + end + + test 'should be valid' do + assert_predicate @article, :valid? + end + + test 'should not be valid without name' do + @article.name = nil + assert_not_predicate @article, :valid? + end + + test 'should not be valid without price' do + @article.price = nil + assert_not_predicate @article, :valid? + end + + test 'price should be integer' do + @article.price = 10.56 + assert_not_predicate @article, :valid? + end + + test 'price should be positive' do + @article.price = -5 + assert_not_predicate @article, :valid? + end + + test 'article should soft delete' do + @article.deleted_at = nil + assert_no_difference 'Article.unscoped.count' do + @article.soft_delete + end + assert_not_predicate @article.deleted_at, :nil? + end + + test 'soft_delete should not change deleted_at date' do + @article.deleted_at = 3.days.ago + before_test = @article.deleted_at + @article.soft_delete + assert_equal @article.deleted_at, before_test + end + + test 'article should be destroyed if no sales' do + @article.sales.destroy_all + @article.refunds.destroy_all + assert_difference 'Article.unscoped.count', -1 do + @article.destroy + end + end + + test 'article should be destroyable' do + @article.sales.destroy_all + @article.refunds.destroy_all + assert_predicate @article, :destroy + end + + test 'should not destroy article if soft_delete' do + assert_no_difference 'Article.unscoped.count' do + @article.soft_delete + end + end + + test 'article should not destroy if dependant' do + assert_no_difference 'Article.unscoped.count' do + assert_not_predicate @article, :destroy + end + + assert_predicate @article, :persisted? + assert_includes @article.errors[:base], 'Cannot delete record because dependent articles sales exist' + end +end diff --git a/test/models/articles_sale_test.rb b/test/models/articles_sale_test.rb new file mode 100644 index 00000000..df809ddd --- /dev/null +++ b/test/models/articles_sale_test.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ArticlesSaleTest < ActiveSupport::TestCase + def setup + @sale = sales(:one) + @article = articles(:one) + end + + test 'should throw an error if multiple articles_sale of the same article' do + ArticlesSale.destroy_all + ArticlesSale.new(sale_id: @sale.id, article_id: @article.id, quantity: 2).save + assert_throws :abort do + ArticlesSale.new(sale_id: @sale.id, article_id: @article.id, quantity: 2).send(:consolidate_duplication) + end + end +end diff --git a/test/models/invoice_test.rb b/test/models/invoice_test.rb new file mode 100644 index 00000000..7a1986dc --- /dev/null +++ b/test/models/invoice_test.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'test_helper' + +class InvoiceTest < ActiveSupport::TestCase + def setup + @client = users(:ironman) + @sale = sales(:one) + @sale.client = @client + @invoice = invoices(:one) + @invoice.sale = @sale + end + + test 'should be valid' do + assert_predicate @invoice, :valid? + end + + test 'should generate correct user' do + assert_equal @invoice.user, @client + end + + test 'should generate correct hash' do + expected_hash = { + sale_date: Time.zone.today, + issue_date: Time.zone.today, + client_name: @sale.client.display_name, + client_address: @sale.client.display_address, + payment_amount_cent: @sale.verified_at.nil? ? 0 : @sale.total_price, + payment_method: @sale.payment_method.name, + payment_date: @sale.verified_at, + items: @invoice.send(:sales_itemized, @sale) + }.compact + + assert_equal @invoice.send(:generate_hash, @sale), expected_hash + end + + test 'should generate json and id from sale' do + @invoice.generation_json = nil + @invoice.id = nil + + @invoice.generate_from(@sale) + + assert_not_nil @invoice.generation_json + assert_not_nil @invoice.id + end + + test 'should create invoice with pdf' do + @invoice.generation_json = @invoice.send(:generate_hash, @sale).to_json + + assert_difference('ActiveStorage::Attachment.count', 1) do + @invoice.send(:create_invoice) + @invoice.save! + end + + assert_predicate @invoice.pdf, :attached? + end + + test 'should set id if nil on create invoice' do + @invoice.generation_json = @invoice.send(:generate_hash, @sale).to_json + @invoice.id = nil + @invoice.send(:create_invoice) + assert_not_nil @invoice.id + end + + test 'should not destroy invoice if sale exists' do + assert_raises(ActiveRecord::DeleteRestrictionError) do + @invoice.destroy + end + end +end diff --git a/test/models/payment_method_test.rb b/test/models/payment_method_test.rb new file mode 100644 index 00000000..c779cfe4 --- /dev/null +++ b/test/models/payment_method_test.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PaymentMethodTest < ActiveSupport::TestCase + def setup + @payment_method = payment_methods(:one) + end + + test 'should be valid' do + assert_predicate @payment_method, :valid? + end + + test 'should not be valid without name' do + @payment_method.name = nil + assert_not_predicate @payment_method, :valid? + end + + test 'should not be valid without auto-verify' do + @payment_method.auto_verify = nil + assert_not_predicate @payment_method, :valid? + end + + test 'payment method should soft delete' do + @payment_method.deleted_at = nil + assert_no_difference 'PaymentMethod.unscoped.count' do + @payment_method.soft_delete + end + assert_not_predicate @payment_method.deleted_at, :nil? + end + + test 'soft_delete should not change deleted_at date' do + @payment_method.deleted_at = 3.days.ago + before_test = @payment_method.deleted_at + @payment_method.soft_delete + assert_equal @payment_method.deleted_at, before_test + end + + test 'payment_method should be destroyed if no sales' do + @payment_method.sales.destroy_all + @payment_method.refunds.destroy_all + assert_difference 'PaymentMethod.unscoped.count', -1 do + @payment_method.destroy + end + end + + test 'article should be destroyable' do + @payment_method.sales.destroy_all + @payment_method.refunds.destroy_all + assert_predicate @payment_method, :destroy + end + + test 'payment_method should not destroy if dependant' do + assert_no_difference 'PaymentMethod.unscoped.count' do + assert_not_predicate @payment_method, :destroy + end + + assert_predicate @payment_method, :persisted? + assert_includes @payment_method.errors[:base], 'Cannot delete record because dependent sales exist' + end +end diff --git a/test/models/refund_test.rb b/test/models/refund_test.rb new file mode 100644 index 00000000..134d7ba5 --- /dev/null +++ b/test/models/refund_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'test_helper' + +class RefundTest < ActiveSupport::TestCase + def setup + @refund = refunds(:one) + end + + test 'destroy refund should destroy articles_refunds' do + assert_difference 'ArticlesRefund.count', -1 do + @refund.destroy + end + end + + test 'destroy refun should destroy refunds_subscription_offers' do + assert_difference 'RefundsSubscriptionOffer.count', -1 do + @refund.destroy + end + end +end diff --git a/test/models/sale_test.rb b/test/models/sale_test.rb new file mode 100644 index 00000000..81a7a693 --- /dev/null +++ b/test/models/sale_test.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'test_helper' + +class SaleTest < ActiveSupport::TestCase + def setup + @sale = sales(:one) + @offer1 = subscription_offers(:one) + @offer12 = subscription_offers(:two) + end + + test 'should be valid' do + assert_predicate @sale, :valid? + end + + test 'client_id should be present' do + @sale.client_id = nil + assert_not_predicate @sale, :valid? + end + + test 'seller_id can be null' do + @sale.seller_id = nil + assert_predicate @sale, :valid? + end + + test 'payment_method should be present' do + @sale.payment_method_id = nil + assert_not_predicate @sale, :valid? + end + + test 'invoice_id should be present' do + @sale.invoice_id = nil + assert_not_predicate @sale, :valid? + end + + test 'total_price should be present' do + @sale.total_price = nil + assert_not_predicate @sale, :valid? + end + + test 'destroy sale should destroy articles_sales' do + assert_difference 'ArticlesSale.count', -1 do + @sale.destroy + end + end + + test 'destroy sale should destroy sales_subscription_offers' do + assert_difference 'SalesSubscriptionOffer.count', -1 do + @sale.destroy + end + end + + test 'destroy sale should destroy refunds' do + assert_difference 'Refund.count', -1 do + @sale.destroy + end + end + + test 'verify method should set the date if nil' do + @sale.verified_at = nil + @sale.verify + assert_in_delta Time.zone.now, @sale.verified_at, 1.second + end + + test 'verify method should not change date is not nil' do + @sale.verified_at = 3.days.ago + @sale.verify + assert_in_delta 3.days.ago, @sale.verified_at, 1.second + end + + test 'should return false if no subscription offer' do + Sale.destroy_all + SubscriptionOffer.destroy_all + assert_not @sale.send :generate_sales_subscription_offers, 30 + end + + test 'should return if not exhaustive' do + Sale.destroy_all + SubscriptionOffer.destroy_all + SubscriptionOffer.create!(duration: 2, price: 50) + assert_not @sale.send :generate_sales_subscription_offers, 11 + end + + test 'should not present offer12' do + @sale.sales_subscription_offers.destroy_all + @sale.send :generate_sales_subscription_offers, 11 + assert_equal 11, @sale.sales_subscription_offers.first.quantity + assert_equal @sale.sales_subscription_offers.last, @sale.sales_subscription_offers.first + end +end diff --git a/test/models/setting_test.rb b/test/models/setting_test.rb new file mode 100644 index 00000000..a3457bd1 --- /dev/null +++ b/test/models/setting_test.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'test_helper' + +class SettingTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/subscription_offer_test.rb b/test/models/subscription_offer_test.rb new file mode 100644 index 00000000..897d4ae2 --- /dev/null +++ b/test/models/subscription_offer_test.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'test_helper' + +class SubscriptionOfferTest < ActiveSupport::TestCase + def setup + @subscription_offer = subscription_offers(:one) + end + + test 'should be valid' do + assert_predicate @subscription_offer, :valid? + end + + test 'should not be valid without duration' do + @subscription_offer.duration = nil + assert_not_predicate @subscription_offer, :valid? + end + + test 'duration should be integer' do + @subscription_offer.duration = 10.56 + assert_not_predicate @subscription_offer, :valid? + end + + test 'duration should be positive' do + @subscription_offer.duration = -5 + assert_not_predicate @subscription_offer, :valid? + end + + test 'should not be valid without price' do + @subscription_offer.price = nil + assert_not_predicate @subscription_offer, :valid? + end + + test 'price should be integer' do + @subscription_offer.price = 10.56 + assert_not_predicate @subscription_offer, :valid? + end + + test 'price should be positive' do + @subscription_offer.price = -5 + assert_not_predicate @subscription_offer, :valid? + end + + test 'offer should soft delete' do + @subscription_offer.deleted_at = nil + assert_no_difference 'SubscriptionOffer.unscoped.count' do + @subscription_offer.soft_delete + end + assert_not_predicate @subscription_offer.deleted_at, :nil? + end + + test 'soft_delete should not change deleted_at date' do + @subscription_offer.deleted_at = 3.days.ago + before_test = @subscription_offer.deleted_at + @subscription_offer.soft_delete + assert_equal @subscription_offer.deleted_at, before_test + 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 + @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 not destroy if dependant' do + assert_no_difference 'SubscriptionOffer.unscoped.count' do + assert_not_predicate @subscription_offer, :destroy + end + + assert_predicate @subscription_offer, :persisted? + assert_includes @subscription_offer.errors[:base], + 'Cannot delete record because dependent sales subscription offers exist' + end +end diff --git a/test/models/subscription_test.rb b/test/models/subscription_test.rb index b621d606..ba95aa65 100644 --- a/test/models/subscription_test.rb +++ b/test/models/subscription_test.rb @@ -30,58 +30,59 @@ def setup assert_not_predicate @subscription, :valid? end - test "cancelled_at can't be changed when not nil" do - subscription = @user.subscriptions.new(start_at: Time.current, end_at: 1.month.from_now, cancelled_at: Time.current) - assert_predicate subscription, :valid? - subscription.save! - - subscription.cancelled_at = subscription.cancelled_at + 1.day - assert_not_predicate subscription, :valid? - - subscription.cancelled_at = nil - assert_not_predicate subscription, :valid? - end - - test 'cancelled_at can be changed when nil' do - subscription = @user.subscriptions.new(start_at: Time.current, end_at: 1.month.from_now) - subscription.save - - subscription.cancelled_at = Time.current - assert_predicate subscription, :valid? - end - - test 'subscription should be destroyed when the user is destroyed' do - assert_difference 'Subscription.count', -1 do - @user.destroy - end - end - - test 'when cancelling a subscription, cancelled_at should be updated' do - freeze_time - - @subscription.cancel! - assert_equal Time.current, @subscription.cancelled_at - end - - test 'duration should give months' do - freeze_time - - subscription = @user.subscriptions.create(start_at: Time.current, end_at: 8.months.from_now) - subscription.reload - assert_equal 8, subscription.duration - - subscription = @user.subscriptions.create(start_at: Time.current, end_at: 13.months.from_now) - subscription.reload - assert_equal 13, subscription.duration - - travel_to Time.zone.local(2024, 5, 31) - - subscription = @user.subscriptions.create(start_at: Time.current, end_at: 8.months.from_now) - subscription.reload - assert_equal 8, subscription.duration - - subscription = @user.subscriptions.create(start_at: Time.current, end_at: 13.months.from_now) - subscription.reload - assert_equal 13, subscription.duration - end + # test "cancelled_at can't be changed when not nil" do + # subscription = @user.subscriptions.new(start_at: Time.current, + # end_at: 1.month.from_now, cancelled_at: Time.current) + # assert_predicate subscription, :valid? + # subscription.save! + # + # subscription.cancelled_at = subscription.cancelled_at + 1.day + # assert_not_predicate subscription, :valid? + # + # subscription.cancelled_at = nil + # assert_not_predicate subscription, :valid? + # end + # + # test 'cancelled_at can be changed when nil' do + # subscription = @user.subscriptions.new(start_at: Time.current, end_at: 1.month.from_now) + # subscription.save + # + # subscription.cancelled_at = Time.current + # assert_predicate subscription, :valid? + # end + # + # test 'subscription should be destroyed when the user is destroyed' do + # assert_difference 'Subscription.count', -1 do + # @user.destroy + # end + # end + + # test 'when cancelling a subscription, cancelled_at should be updated' do + # freeze_time + # + # @subscription.cancel! + # assert_equal Time.current, @subscription.cancelled_at + # end + + # test 'duration should give months' do + # freeze_time + # + # subscription = @user.subscriptions.create(start_at: Time.current, end_at: 8.months.from_now) + # subscription.reload + # assert_equal 8, subscription.duration + # + # subscription = @user.subscriptions.create(start_at: Time.current, end_at: 13.months.from_now) + # subscription.reload + # assert_equal 13, subscription.duration + # + # travel_to Time.zone.local(2024, 5, 31) + # + # subscription = @user.subscriptions.create(start_at: Time.current, end_at: 8.months.from_now) + # subscription.reload + # assert_equal 8, subscription.duration + # + # subscription = @user.subscriptions.create(start_at: Time.current, end_at: 13.months.from_now) + # subscription.reload + # assert_equal 13, subscription.duration + # end end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 73dc3832..50b432a0 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -141,176 +141,177 @@ def setup end test 'current_subscription is nil when user has no subscriptions' do + @user.sales_as_client.destroy_all @user.subscriptions.destroy_all @user.save assert_nil @user.current_subscription end - test 'current_subscription is nil when user has cancelled subscription' do - @user.subscriptions.destroy_all - @user.save - - @user.subscriptions.create!(start_at: Time.current, end_at: 1.month.from_now, cancelled_at: Time.current) - assert_nil @user.current_subscription - end - - test 'current_subscription is last non cancelled subscription' do - @user.subscriptions.destroy_all - @user.save - - current_subscription = @user.subscriptions.create!(start_at: 2.months.ago, end_at: 1.month.ago) - @user.subscriptions.create!(start_at: Time.current, end_at: 1.month.from_now, cancelled_at: Time.current) - assert_equal current_subscription, @user.current_subscription - end - - test 'current_subscription is correct when user has valid, cancelled and expired subscriptions' do - @user.subscriptions.destroy_all - @user.save - - # expired - @user.subscriptions.create!(start_at: 2.months.ago, end_at: 1.month.ago) - current = @user.subscriptions.create!(start_at: Time.current, end_at: 1.month.from_now) - # cancelled - @user.subscriptions.create!(start_at: 1.month.from_now, end_at: 2.months.from_now, cancelled_at: Time.current) - assert_equal @user.current_subscription, current - end - - test 'current_subscription is the last valid subscription' do - @user.subscriptions.destroy_all - @user.save - - @user.subscriptions.create!(start_at: Time.current, end_at: 2.months.from_now) - last_valid = @user.subscriptions.create!(start_at: 2.months.from_now, end_at: 4.months.from_now) - assert_equal @user.current_subscription, last_valid - end - - test 'when extending a nil subscription expiration, it should be now + duration' do - freeze_time - @user.subscriptions.destroy_all - @user.save - - assert_nil @user.subscription_expiration - - assert_difference 'Subscription.count', 1 do - @user.extend_subscription(duration: 3) - @user.save - end - assert_equal 3.months.from_now, @user.subscription_expiration - end - - test 'when extending a valid subscription expiration, it should be extend by duration' do - freeze_time - @user.subscriptions.destroy_all - @user.save - - old_expiration = 1.month.from_now - @user.subscriptions.create(start_at: 1.month.ago, end_at: old_expiration) - - assert_equal old_expiration, @user.subscription_expiration - - assert_difference 'Subscription.count', 1 do - @user.extend_subscription(duration: 3) - @user.save - end - assert_equal old_expiration + 3.months, @user.subscription_expiration - end - - test 'when extending an expired subscription expiration, it should be now + duration' do - freeze_time - @user.subscriptions.destroy_all - @user.save - - expired_expiration = 1.month.ago - @user.subscriptions.create(start_at: 2.months.ago, end_at: expired_expiration) - - assert_equal expired_expiration, @user.subscription_expiration - - assert_difference 'Subscription.count', 1 do - @user.extend_subscription(duration: 3) - @user.save - end - assert_equal 3.months.from_now, @user.subscription_expiration - end - - test 'when cancelling user with zero subscriptions, do nothing' do - freeze_time - - @user.subscriptions.destroy_all - @user.save - - @user.cancel_current_subscription! - assert_nil @user.subscription_expiration - end - - test 'when cancelling an expired subscription, it should cancel it' do - freeze_time - @user.subscriptions.destroy_all - @user.save - - expired_subscription = @user.subscriptions.create(start_at: 2.months.ago, end_at: 1.month.ago) - assert_equal expired_subscription.end_at, @user.subscription_expiration - - @user.cancel_current_subscription! - assert_nil @user.subscription_expiration - end - - test 'when cancelling a valid subscription, it should be reduced by duration' do - freeze_time - @user.subscriptions.destroy_all - @user.save - - previous_valid_subscription = @user.subscriptions.create(start_at: 2.months.ago, end_at: 1.month.from_now) - current_subscription = @user.subscriptions.create(start_at: 1.month.from_now, end_at: 3.months.from_now) - - assert_equal current_subscription.end_at, @user.subscription_expiration - - @user.cancel_current_subscription! - assert_equal previous_valid_subscription.end_at, @user.subscription_expiration - end - - test 'when cancelling a subscription, it should not be deleted' do - freeze_time - @user.subscriptions.create(start_at: 1.month.from_now, end_at: 3.months.from_now) - - assert_no_difference 'Subscription.count' do - @user.cancel_current_subscription! - end - end - - test 'free_access should not impact starting date of subscription' do - freeze_time - @user.subscriptions.destroy_all - @user.free_accesses.destroy_all - @user.reload - assert_nil @user.current_subscription - assert_nil @user.current_free_access - - @user.free_accesses.create(start_at: Time.current, end_at: 5.months.from_now, reason: 'Good doggo') - @user.extend_subscription(duration: 3) - @user.save - - assert_equal 5.months.from_now, @user.current_free_access.end_at - assert_equal 3.months.from_now, @user.subscription_expiration - end - - test 'internet expiration should be the max between free_access and subscription' do - freeze_time - @user.subscriptions.destroy_all - @user.free_accesses.destroy_all - @user.reload - assert_nil @user.current_subscription - assert_nil @user.current_free_access - - @user.free_accesses.create(start_at: Time.current, end_at: 5.months.from_now, reason: 'Good kitty') - @user.extend_subscription(duration: 3) - @user.save - - assert_equal 5.months.from_now, @user.internet_expiration - end + # test 'current_subscription is nil when user has cancelled subscription' do + # @user.subscriptions.destroy_all + # @user.save + # + # @user.subscriptions.create!(start_at: Time.current, end_at: 1.month.from_now, cancelled_at: Time.current) + # assert_nil @user.current_subscription + # end + # + # test 'current_subscription is last non cancelled subscription' do + # @user.subscriptions.destroy_all + # @user.save + # + # current_subscription = @user.subscriptions.create!(start_at: 2.months.ago, end_at: 1.month.ago) + # @user.subscriptions.create!(start_at: Time.current, end_at: 1.month.from_now, cancelled_at: Time.current) + # assert_equal current_subscription, @user.current_subscription + # end + # + # test 'current_subscription is correct when user has valid, cancelled and expired subscriptions' do + # @user.subscriptions.destroy_all + # @user.save + # + # # expired + # @user.subscriptions.create!(start_at: 2.months.ago, end_at: 1.month.ago) + # current = @user.subscriptions.create!(start_at: Time.current, end_at: 1.month.from_now) + # # cancelled + # @user.subscriptions.create!(start_at: 1.month.from_now, end_at: 2.months.from_now, cancelled_at: Time.current) + # assert_equal @user.current_subscription, current + # end + # + # test 'current_subscription is the last valid subscription' do + # @user.subscriptions.destroy_all + # @user.save + # + # @user.subscriptions.create!(start_at: Time.current, end_at: 2.months.from_now) + # last_valid = @user.subscriptions.create!(start_at: 2.months.from_now, end_at: 4.months.from_now) + # assert_equal @user.current_subscription, last_valid + # end + # + # test 'when extending a nil subscription expiration, it should be now + duration' do + # freeze_time + # @user.subscriptions.destroy_all + # @user.save + # + # assert_nil @user.subscription_expiration + # + # assert_difference 'Subscription.count', 1 do + # @user.extend_subscription(duration: 3) + # @user.save + # end + # assert_equal 3.months.from_now, @user.subscription_expiration + # end + # + # test 'when extending a valid subscription expiration, it should be extend by duration' do + # freeze_time + # @user.subscriptions.destroy_all + # @user.save + # + # old_expiration = 1.month.from_now + # @user.subscriptions.create(start_at: 1.month.ago, end_at: old_expiration) + # + # assert_equal old_expiration, @user.subscription_expiration + # + # assert_difference 'Subscription.count', 1 do + # @user.extend_subscription(duration: 3) + # @user.save + # end + # assert_equal old_expiration + 3.months, @user.subscription_expiration + # end + # + # test 'when extending an expired subscription expiration, it should be now + duration' do + # freeze_time + # @user.subscriptions.destroy_all + # @user.save + # + # expired_expiration = 1.month.ago + # @user.subscriptions.create(start_at: 2.months.ago, end_at: expired_expiration) + # + # assert_equal expired_expiration, @user.subscription_expiration + # + # assert_difference 'Subscription.count', 1 do + # @user.extend_subscription(duration: 3) + # @user.save + # end + # assert_equal 3.months.from_now, @user.subscription_expiration + # end + # + # test 'when cancelling user with zero subscriptions, do nothing' do + # freeze_time + # + # @user.subscriptions.destroy_all + # @user.save + # + # @user.cancel_current_subscription! + # assert_nil @user.subscription_expiration + # end + # + # test 'when cancelling an expired subscription, it should cancel it' do + # freeze_time + # @user.subscriptions.destroy_all + # @user.save + # + # expired_subscription = @user.subscriptions.create(start_at: 2.months.ago, end_at: 1.month.ago) + # assert_equal expired_subscription.end_at, @user.subscription_expiration + # + # @user.cancel_current_subscription! + # assert_nil @user.subscription_expiration + # end + # + # test 'when cancelling a valid subscription, it should be reduced by duration' do + # freeze_time + # @user.subscriptions.destroy_all + # @user.save + # + # previous_valid_subscription = @user.subscriptions.create(start_at: 2.months.ago, end_at: 1.month.from_now) + # current_subscription = @user.subscriptions.create(start_at: 1.month.from_now, end_at: 3.months.from_now) + # + # assert_equal current_subscription.end_at, @user.subscription_expiration + # + # @user.cancel_current_subscription! + # assert_equal previous_valid_subscription.end_at, @user.subscription_expiration + # end + # + # test 'when cancelling a subscription, it should not be deleted' do + # freeze_time + # @user.subscriptions.create(start_at: 1.month.from_now, end_at: 3.months.from_now) + # + # assert_no_difference 'Subscription.count' do + # @user.cancel_current_subscription! + # end + # end + # + # test 'free_access should not impact starting date of subscription' do + # freeze_time + # @user.subscriptions.destroy_all + # @user.free_accesses.destroy_all + # @user.reload + # assert_nil @user.current_subscription + # assert_nil @user.current_free_access + # + # @user.free_accesses.create(start_at: Time.current, end_at: 5.months.from_now, reason: 'Good doggo') + # @user.extend_subscription(duration: 3) + # @user.save + # + # assert_equal 5.months.from_now, @user.current_free_access.end_at + # assert_equal 3.months.from_now, @user.subscription_expiration + # end + + # test 'internet expiration should be the max between free_access and subscription' do + # freeze_time + # @user.subscriptions.destroy_all + # @user.free_accesses.destroy_all + # @user.reload + # assert_nil @user.current_subscription + # assert_nil @user.current_free_access + # + # @user.free_accesses.create(start_at: Time.current, end_at: 5.months.from_now, reason: 'Good kitty') + # @user.extend_subscription(duration: 3) + # @user.save + # + # assert_equal 5.months.from_now, @user.internet_expiration + # end test 'internet expiration should be nil when no free_access or subscription' do - @user.subscriptions.destroy_all + @user.sales_as_client.destroy_all @user.free_accesses.destroy_all @user.reload assert_nil @user.current_subscription diff --git a/test/services/invoice_pdf_generator_test.rb b/test/services/invoice_pdf_generator_test.rb new file mode 100644 index 00000000..0249a8df --- /dev/null +++ b/test/services/invoice_pdf_generator_test.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'test_helper' + +class InvoicePdfGeneratorTest < ActiveSupport::TestCase + def setup + @id = '4269' + input = { + invoice_id: @id, + sale_date: '2024-07-21', + issue_date: '2024-07-22', + client_name: users(:ironman).display_name, + client_address: users(:ironman).display_address, + items: [ + { item_name: 'Article 1', price_cents: 1000, quantity: 2 }, + { item_name: 'Article 2', price_cents: 500, quantity: 3 } + ], + payment_amount_cents: 2500, + payment_date: '2024-07-21', + payment_method: 'Carte Bancaire' + } + @generator = InvoicePdfGenerator.new(input) + end + + test 'generate_pdf should return a StringIO object' do + pdf = @generator.generate_pdf + assert_instance_of StringIO, pdf + end + + test 'generate_pdf should generate a valid PDF file' do + pdf = @generator.generate_pdf + assert_nothing_raised do + HexaPDF::Document.new(io: pdf) + end + end + + test 'generate_pdf should include invoice header' do + pdf = @generator.generate_pdf + pdf_text = extract_text_from_pdf(pdf) + assert_includes pdf_text, 'Facture Rézoléo' + assert_includes pdf_text, '2024-07-21' + assert_includes pdf_text, '2024-07-22' + end + + test 'generate_pdf should include client information' do + pdf = @generator.generate_pdf + pdf_text = extract_text_from_pdf(pdf) + assert_includes pdf_text, users(:ironman).display_name + users(:ironman).display_address.split("\n").each do |address_line| + assert_includes pdf_text, address_line + end + end + + test 'generate_pdf should include items table' do + pdf = @generator.generate_pdf + pdf_text = extract_text_from_pdf(pdf) + assert_includes pdf_text, 'Article 1' + assert_includes pdf_text, 'Article 2' + assert_includes pdf_text, '2' + assert_includes pdf_text, '3' + assert_includes pdf_text, '10.00€' + assert_includes pdf_text, '5.00€' + end + + test 'generate_pdf should include total' do + pdf = @generator.generate_pdf + pdf_text = extract_text_from_pdf(pdf) + assert_includes pdf_text, '35.00€' + end + + test 'generate_pdf should include payment information' do + pdf = @generator.generate_pdf + pdf_text = extract_text_from_pdf(pdf) + assert_includes pdf_text, '25.00€' + assert_includes pdf_text, '2024-07-21' + assert_includes pdf_text, 'Carte Bancaire' + end + + test 'generate_pdf should include correct metadata' do + pdf = @generator.generate_pdf + metadata = extract_metadata_from_pdf(pdf) + + assert_equal "Facture Rézoléo #{@id}", metadata[:Title] + assert_equal 'Association Rézoléo', metadata[:Author] + assert_equal "Facture #{@id}", metadata[:Subject] + assert_in_delta Time.now, metadata[:CreationDate], 5.minutes # rubocop:disable Rails/TimeZone + end + + class CollectTextProcessor < HexaPDF::Content::Processor + def initialize(page, content) + super() + @canvas = page.canvas(type: :overlay) + @content = content + end + + def show_text(str) + boxes = decode_text_with_positioning(str) + @content << boxes.string unless boxes.string.nil? + end + alias show_text_with_positioning show_text + end + + def extract_text_from_pdf(pdf) + doc = HexaPDF::Document.new(io: pdf) + content = [] + + doc.pages.each do |page| + processor = CollectTextProcessor.new(page, content) + page.process_contents(processor) + end + + content.join + end + + def extract_metadata_from_pdf(pdf) + doc = HexaPDF::Document.new(io: pdf) + doc.trailer.info + end +end