From b9c02742a18490a4dc54fe66332b950fc377eb79 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Mon, 29 Aug 2022 17:34:09 +0200 Subject: [PATCH] (bug) unable to generate statistics --- .rubocop.yml | 1 + CHANGELOG.md | 7 + app/models/stats/machine.rb | 15 +- app/services/statistic_service.rb | 504 ------------------ app/services/statistics/builder_service.rb | 16 + .../builders/members_builder_service.rb | 26 + .../builders/projects_builder_service.rb | 19 + .../builders/reservations_builder_service.rb | 45 ++ .../builders/subscriptions_builder_service.rb | 23 + app/services/statistics/cleaner_service.rb | 20 + .../statistics/concerns/compute_concern.rb | 40 ++ .../statistics/concerns/helpers_concern.rb | 47 ++ .../statistics/concerns/projects_concern.rb | 60 +++ app/services/statistics/fetcher_service.rb | 214 ++++++++ app/workers/period_statistics_worker.rb | 4 +- app/workers/statistic_worker.rb | 2 +- lib/tasks/fablab/maintenance.rake | 17 +- test/helpers/archive_helper.rb | 62 +++ test/helpers/invoice_helper.rb | 81 +++ test/services/statistic_service_test.rb | 47 +- test/test_helper.rb | 116 +--- 21 files changed, 722 insertions(+), 644 deletions(-) delete mode 100644 app/services/statistic_service.rb create mode 100644 app/services/statistics/builder_service.rb create mode 100644 app/services/statistics/builders/members_builder_service.rb create mode 100644 app/services/statistics/builders/projects_builder_service.rb create mode 100644 app/services/statistics/builders/reservations_builder_service.rb create mode 100644 app/services/statistics/builders/subscriptions_builder_service.rb create mode 100644 app/services/statistics/cleaner_service.rb create mode 100644 app/services/statistics/concerns/compute_concern.rb create mode 100644 app/services/statistics/concerns/helpers_concern.rb create mode 100644 app/services/statistics/concerns/projects_concern.rb create mode 100644 app/services/statistics/fetcher_service.rb create mode 100644 test/helpers/archive_helper.rb create mode 100644 test/helpers/invoice_helper.rb diff --git a/.rubocop.yml b/.rubocop.yml index b037a56b2..f423c323f 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -21,6 +21,7 @@ Metrics/BlockLength: - 'config/routes.rb' - 'app/pdfs/pdf/*.rb' - 'test/**/*.rb' + - '**/*_concern.rb' Metrics/ParameterLists: CountKeywordArgs: false Style/RegexpLiteral: diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b9730493..5b61b4405 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog Fab-manager +- Improved automated test on statistics generation +- Refactored statistics generation +- Refactored test helpers +- Fix a bug: unable to generate statistics +- Fix a bug: the automated test on statistics generation was not running +- [TODO DEPLOY] `rails fablab:maintenance:regenerate_statistics[2022,07]` + ## v5.4.16 2022 August 24 - Updated user's manual for v5.4 (fr) diff --git a/app/models/stats/machine.rb b/app/models/stats/machine.rb index 39c0704e0..96aa3bd8b 100644 --- a/app/models/stats/machine.rb +++ b/app/models/stats/machine.rb @@ -1,9 +1,10 @@ -module Stats - class Machine - include Elasticsearch::Persistence::Model - include StatConcern - include StatReservationConcern +# frozen_string_literal: true - attribute :machineId, Integer - end +# This is a statistical data saved in ElasticSearch, about a machine reservation +class Stats::Machine + include Elasticsearch::Persistence::Model + include StatConcern + include StatReservationConcern + + attribute :machineId, Integer end diff --git a/app/services/statistic_service.rb b/app/services/statistic_service.rb deleted file mode 100644 index 45f752f93..000000000 --- a/app/services/statistic_service.rb +++ /dev/null @@ -1,504 +0,0 @@ -# frozen_string_literal: true - -# This will generate statistics indicators for ElasticSearch database -class StatisticService - def generate_statistic(options = default_options) - # remove data exists - clean_stat(options) - - # subscription month/year list - subscriptions_list(options).each do |s| - Stats::Subscription.create({ - date: format_date(s.date), - type: s.duration, - subType: s.slug, - stat: 1, - ca: s.ca, - planId: s.plan_id, - subscriptionId: s.subscription_id, - invoiceItemId: s.invoice_item_id, - groupName: s.plan_group_name - }.merge(user_info_stat(s))) - end - - # machine list - reservations_machine_list(options).each do |r| - %w[booking hour].each do |type| - stat = Stats::Machine.new({ - date: format_date(r.date), - type: type, - subType: r.machine_type, - ca: r.ca, - machineId: r.machine_id, - name: r.machine_name, - reservationId: r.reservation_id - }.merge(user_info_stat(r))) - stat.stat = (type == 'booking' ? 1 : r.nb_hours) - stat.save - end - end - - # space list - reservations_space_list(options).each do |r| - %w[booking hour].each do |type| - stat = Stats::Space.new({ - date: format_date(r.date), - type: type, - subType: r.space_type, - ca: r.ca, - spaceId: r.space_id, - name: r.space_name, - reservationId: r.reservation_id - }.merge(user_info_stat(r))) - stat.stat = (type == 'booking' ? 1 : r.nb_hours) - stat.save - end - end - - # training list - reservations_training_list(options).each do |r| - %w[booking hour].each do |type| - stat = Stats::Training.new({ - date: format_date(r.date), - type: type, - subType: r.training_type, - ca: r.ca, - trainingId: r.training_id, - name: r.training_name, - trainingDate: r.training_date, - reservationId: r.reservation_id - }.merge(user_info_stat(r))) - stat.stat = (type == 'booking' ? 1 : r.nb_hours) - stat.save - end - end - - # event list - reservations_event_list(options).each do |r| - %w[booking hour].each do |type| - stat = Stats::Event.new({ - date: format_date(r.date), - type: type, - subType: r.event_type, - ca: r.ca, - eventId: r.event_id, - name: r.event_name, - eventDate: r.event_date, - reservationId: r.reservation_id, - eventTheme: r.event_theme, - ageRange: r.age_range - }.merge(user_info_stat(r))) - stat.stat = (type == 'booking' ? r.nb_places : r.nb_hours) - stat.save - end - end - - # account list - members_list(options).each do |m| - Stats::Account.create({ - date: format_date(m.date), - type: 'member', - subType: 'created', - stat: 1 - }.merge(user_info_stat(m))) - end - - # project list - projects_list(options).each do |p| - Stats::Project.create({ - date: format_date(p.date), - type: 'project', - subType: 'published', - stat: 1 - }.merge(user_info_stat(p)).merge(project_info_stat(p))) - end - - # member ca list - members_ca_list(options).each do |m| - Stats::User.create({ - date: format_date(m.date), - type: 'revenue', - subType: m.group, - stat: m.ca - }.merge(user_info_stat(m))) - end - end - - def subscriptions_list(options = default_options) - result = [] - InvoiceItem.where("object_type = '#{Subscription.name}' AND invoice_items.created_at >= :start_date AND invoice_items.created_at <= :end_date", options) - .eager_load(invoice: [:coupon]).each do |i| - next if i.invoice.is_a?(Avoir) - - sub = i.object - - ca = i.amount.to_i - cs = CouponService.new - ca = cs.ventilate(cs.invoice_total_no_coupon(i.invoice), ca, i.invoice.coupon) unless i.invoice.coupon_id.nil? - ca /= 100.00 - profile = sub.statistic_profile - p = sub.plan - result.push OpenStruct.new({ - date: options[:start_date].to_date, - plan: p.group.slug, - plan_id: p.id, - plan_interval: p.interval, - plan_interval_count: p.interval_count, - plan_group_name: p.group.name, - slug: p.slug, - duration: p.find_statistic_type.key, - subscription_id: sub.id, - invoice_item_id: i.id, - ca: ca - }.merge(user_info(profile))) - end - result - end - - def reservations_machine_list(options = default_options) - result = [] - Reservation - .where("reservable_type = 'Machine' AND slots.canceled_at IS NULL AND " \ - 'reservations.created_at >= :start_date AND reservations.created_at <= :end_date', options) - .eager_load(:slots, :invoice_items, statistic_profile: [:group]) - .each do |r| - next unless r.reservable - - profile = r.statistic_profile - result.push OpenStruct.new({ - date: options[:start_date].to_date, - reservation_id: r.id, - machine_id: r.reservable.id, - machine_type: r.reservable.friendly_id, - machine_name: r.reservable.name, - nb_hours: r.slots.size, - ca: calcul_ca(r.original_invoice) - }.merge(user_info(profile))) - end - result - end - - def reservations_space_list(options = default_options) - result = [] - Reservation - .where("reservable_type = 'Space' AND slots.canceled_at IS NULL AND " \ - 'reservations.created_at >= :start_date AND reservations.created_at <= :end_date', options) - .eager_load(:slots, :invoice_items, statistic_profile: [:group]) - .each do |r| - next unless r.reservable - - profile = r.statistic_profile - result.push OpenStruct.new({ - date: options[:start_date].to_date, - reservation_id: r.id, - space_id: r.reservable.id, - space_name: r.reservable.name, - space_type: r.reservable.slug, - nb_hours: r.slots.size, - ca: calcul_ca(r.original_invoice) - }.merge(user_info(profile))) - end - result - end - - def reservations_training_list(options = default_options) - result = [] - Reservation - .where("reservable_type = 'Training' AND slots.canceled_at IS NULL AND " \ - 'reservations.created_at >= :start_date AND reservations.created_at <= :end_date', options) - .eager_load(:slots, :invoice_items, statistic_profile: [:group]) - .each do |r| - next unless r.reservable - - profile = r.statistic_profile - slot = r.slots.first - result.push OpenStruct.new({ - date: options[:start_date].to_date, - reservation_id: r.id, - training_id: r.reservable.id, - training_type: r.reservable.friendly_id, - training_name: r.reservable.name, - training_date: slot.start_at.to_date, - nb_hours: difference_in_hours(slot.start_at, slot.end_at), - ca: calcul_ca(r.original_invoice) - }.merge(user_info(profile))) - end - result - end - - def reservations_event_list(options = default_options) - result = [] - Reservation - .where("reservable_type = 'Event' AND slots.canceled_at IS NULL AND " \ - 'reservations.created_at >= :start_date AND reservations.created_at <= :end_date', options) - .eager_load(:slots, :invoice_items, statistic_profile: [:group]) - .each do |r| - next unless r.reservable - - profile = r.statistic_profile - slot = r.slots.first - result.push OpenStruct.new({ - date: options[:start_date].to_date, - reservation_id: r.id, - event_id: r.reservable.id, - event_type: r.reservable.category.slug, - event_name: r.reservable.name, - event_date: slot.start_at.to_date, - event_theme: (r.reservable.event_themes.first ? r.reservable.event_themes.first.name : ''), - age_range: (r.reservable.age_range_id ? r.reservable.age_range.name : ''), - nb_places: r.total_booked_seats, - nb_hours: difference_in_hours(slot.start_at, slot.end_at), - ca: calcul_ca(r.original_invoice) - }.merge(user_info(profile))) - end - result - end - - def members_ca_list(options = default_options) - subscriptions_ca_list = subscriptions_list(options) - reservations_ca_list = [] - avoirs_ca_list = [] - result = [] - Reservation.where('reservations.created_at >= :start_date AND reservations.created_at <= :end_date', options) - .eager_load(:slots, :invoice_items, statistic_profile: [:group]) - .each do |r| - next unless r.reservable - - reservations_ca_list.push OpenStruct.new({ - date: options[:start_date].to_date, - ca: calcul_ca(r.original_invoice) - }.merge(user_info(r.statistic_profile))) - end - Avoir.where('invoices.created_at >= :start_date AND invoices.created_at <= :end_date', options) - .eager_load(:invoice_items, statistic_profile: [:group]) - .each do |i| - # the following line is a workaround for issue #196 - profile = i.statistic_profile || i.main_item.object&.wallet&.user&.statistic_profile - avoirs_ca_list.push OpenStruct.new({ - date: options[:start_date].to_date, - ca: calcul_avoir_ca(i) - }.merge(user_info(profile))) - end - reservations_ca_list.concat(subscriptions_ca_list).concat(avoirs_ca_list).each do |e| - profile = StatisticProfile.find(e.statistic_profile_id) - u = find_or_create_user_info_info_list(profile, result) - u.date = options[:start_date].to_date - e.ca = 0 unless e.ca - if u.ca - u.ca = u.ca + e.ca - else - u.ca = 0 - u.ca = u.ca + e.ca - result.push u - end - end - result - end - - def members_list(options = default_options) - result = [] - member = Role.find_by(name: 'member') - StatisticProfile.where('role_id = :member AND created_at >= :start_date AND created_at <= :end_date', options.merge(member: member.id)) - .each do |sp| - next if sp.user&.need_completion? - - result.push OpenStruct.new({ - date: options[:start_date].to_date - }.merge(user_info(sp))) - end - result - end - - def projects_list(options = default_options) - result = [] - Project.where('projects.published_at >= :start_date AND projects.published_at <= :end_date', options) - .eager_load(:licence, :themes, :components, :machines, :project_users, author: [:group]) - .each do |p| - result.push OpenStruct.new({ - date: options[:start_date].to_date - }.merge(user_info(p.author)).merge(project_info(p))) - end - result - end - - # return always yesterday's sum of comment of each project - # def projects_comment_nb_list - # result = [] - # Project.where(state: 'published') - # .eager_load(:licence, :themes, :components, :machines, :project_users, author: %i[profile group]) - # .each do |p| - # result.push OpenStruct.new({ - # date: 1.day.ago.to_date, - # project_comments: get_project_comment_nb(p) - # }.merge(user_info(p.author)).merge(project_info(p))) - # end - # result - # end - - def clean_stat(options = default_options) - client = Elasticsearch::Model.client - %w[Account Event Machine Project Subscription Training User Space].each do |o| - model = "Stats::#{o}".constantize - client.delete_by_query( - index: model.index_name, - type: model.document_type, - body: { query: { match: { date: format_date(options[:start_date]) } } } - ) - end - end - - private - - def default_options - yesterday = 1.day.ago - { - start_date: yesterday.beginning_of_day, - end_date: yesterday.end_of_day - } - end - - def format_date(date) - if date.is_a?(String) - Date.strptime(date, '%Y%m%d').strftime('%Y-%m-%d') - else - date.strftime('%Y-%m-%d') - end - end - - def user_info(statistic_profile) - return {} unless statistic_profile - - { - statistic_profile_id: statistic_profile.id, - user_id: statistic_profile.user_id, - gender: statistic_profile.str_gender, - age: statistic_profile.age, - group: statistic_profile.group ? statistic_profile.group.slug : nil - } - end - - def user_info_stat(s) - { - userId: s.user_id, - gender: s.gender, - age: s.age, - group: s.group - } - end - - def calcul_ca(invoice) - return nil unless invoice - - ca = 0 - # sum each items in the invoice (+ for invoices/- for refunds) - invoice.invoice_items.each do |ii| - next if ii.object_type == 'Subscription' - - ca = if invoice.is_a?(Avoir) - ca - ii.amount.to_i - else - ca + ii.amount.to_i - end - end - # subtract coupon discount from invoices and refunds - cs = CouponService.new - ca = cs.ventilate(cs.invoice_total_no_coupon(invoice), ca, invoice.coupon) unless invoice.coupon_id.nil? - # divide the result by 100 to convert from centimes to monetary unit - ca.zero? ? ca : ca / 100.0 - end - - def calcul_avoir_ca(invoice) - ca = 0 - invoice.invoice_items.each do |ii| - ca -= ii.amount.to_i - end - # subtract coupon discount from the refund - cs = CouponService.new - ca = cs.ventilate(cs.invoice_total_no_coupon(invoice), ca, invoice.coupon) unless invoice.coupon_id.nil? - ca.zero? ? ca : ca / 100.0 - end - - def difference_in_hours(start_at, end_at) - if start_at.to_date == end_at.to_date - ((end_at - start_at) / 60 / 60).to_i - else - end_at_to_start_date = end_at.change(year: start_at.year, month: start_at.month, day: start_at.day) - hours = ((end_at_to_start_date - start_at) / 60 / 60).to_i - hours = ((end_at.to_date - start_at.to_date).to_i + 1) * hours if end_at.to_date > start_at.to_date - hours - end - end - - def get_project_themes(project) - project.themes.map do |t| - { id: t.id, name: t.name } - end - end - - def get_projects_components(project) - project.components.map do |c| - { id: c.id, name: c.name } - end - end - - def get_projects_machines(project) - project.machines.map do |m| - { id: m.id, name: m.name } - end - end - - def get_project_users(project) - sum = 0 - project.project_users.each do |pu| - sum += 1 if pu.is_valid - end - sum - end - - # def get_project_comment_nb(project) - # project_comment_info = @projects_comment_info.select do |p| - # p['identifiers'].first == "project_#{project.id}" - # end.first - # project_comment_info ? project_comment_info['posts'] : 0 - # end - - def project_info(project) - { - project_id: project.id, - project_name: project.name, - project_created_at: project.created_at, - project_published_at: project.published_at, - project_licence: {}, - project_themes: get_project_themes(project), - project_components: get_projects_components(project), - project_machines: get_projects_machines(project), - project_users: get_project_users(project) - } - end - - def project_info_stat(project) - { - projectId: project.project_id, - name: project.project_name, - licence: project.project_licence, - themes: project.project_themes, - components: project.project_components, - machines: project.project_machines, - users: project.project_users - } - end - - # def get_user_subscription_ca(user, subscriptions_ca_list) - # user_subscription_ca = subscriptions_ca_list.select do |ca| - # ca.user_id == user.id - # end - # user_subscription_ca.inject {|sum,x| sum.ca + x.ca } || 0 - # end - - def find_or_create_user_info_info_list(profile, list) - found = list.select do |l| - l.statistic_profile_id == profile.id - end.first - found || OpenStruct.new(user_info(profile)) - end -end diff --git a/app/services/statistics/builder_service.rb b/app/services/statistics/builder_service.rb new file mode 100644 index 000000000..78e3a829a --- /dev/null +++ b/app/services/statistics/builder_service.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# This will generate statistics indicators. Those will be saved in the ElasticSearch database +class Statistics::BuilderService + class << self + def generate_statistic(options = default_options) + # remove data exists + Statistics::CleanerService.clean_stat(options) + + Statistics::Builders::SubscriptionsBuilderService.build(options) + Statistics::Builders::ReservationsBuilderService.build(options) + Statistics::Builders::MembersBuilderService.build(options) + Statistics::Builders::ProjectsBuilderService.build(options) + end + end +end diff --git a/app/services/statistics/builders/members_builder_service.rb b/app/services/statistics/builders/members_builder_service.rb new file mode 100644 index 000000000..0d792c9af --- /dev/null +++ b/app/services/statistics/builders/members_builder_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# Generate statistics indicators about members +class Statistics::Builders::MembersBuilderService + include Statistics::Concerns::HelpersConcern + + class << self + def build(options = default_options) + # account list + Statistics::FetcherService.members_list(options).each do |m| + Stats::Account.create({ date: format_date(m[:date]), + type: 'member', + subType: 'created', + stat: 1 }.merge(user_info_stat(m))) + end + + # member ca list + Statistics::FetcherService.members_ca_list(options).each do |m| + Stats::User.create({ date: format_date(m[:date]), + type: 'revenue', + subType: m[:group], + stat: m[:ca] }.merge(user_info_stat(m))) + end + end + end +end diff --git a/app/services/statistics/builders/projects_builder_service.rb b/app/services/statistics/builders/projects_builder_service.rb new file mode 100644 index 000000000..e4853407b --- /dev/null +++ b/app/services/statistics/builders/projects_builder_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Generate statistics indicators about projects +class Statistics::Builders::ProjectsBuilderService + include Statistics::Concerns::HelpersConcern + include Statistics::Concerns::ProjectsConcern + + class << self + def build(options = default_options) + # project list + Statistics::FetcherService.projects_list(options).each do |p| + Stats::Project.create({ date: format_date(p.date), + type: 'project', + subType: 'published', + stat: 1 }.merge(user_info_stat(p)).merge(project_info_stat(p))) + end + end + end +end diff --git a/app/services/statistics/builders/reservations_builder_service.rb b/app/services/statistics/builders/reservations_builder_service.rb new file mode 100644 index 000000000..082f4dc38 --- /dev/null +++ b/app/services/statistics/builders/reservations_builder_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# Generate statistics indicators about reservations +class Statistics::Builders::ReservationsBuilderService + include Statistics::Concerns::HelpersConcern + + class << self + def build(options = default_options) + # machine/space/training list + %w[machine space training].each do |category| + Statistics::FetcherService.send("reservations_#{category}_list", options).each do |r| + %w[booking hour].each do |type| + stat = Stats::Machine.new({ date: format_date(r[:date]), + type: type, + subType: r["#{category}_type".to_sym], + ca: r[:ca], + machineId: r["#{category}_id".to_sym], + name: r["#{category}_name".to_sym], + reservationId: r[:reservation_id] }.merge(user_info_stat(r))) + stat.stat = (type == 'booking' ? 1 : r[:nb_hours]) + stat.save + end + end + end + + # event list + Statistics::FetcherService.reservations_event_list(options).each do |r| + %w[booking hour].each do |type| + stat = Stats::Event.new({ date: format_date(r[:date]), + type: type, + subType: r[:event_type], + ca: r[:ca], + eventId: r[:event_id], + name: r[:event_name], + eventDate: r[:event_date], + reservationId: r[:reservation_id], + eventTheme: r[:event_theme], + ageRange: r[:age_range] }.merge(user_info_stat(r))) + stat.stat = (type == 'booking' ? r[:nb_places] : r[:nb_hours]) + stat.save + end + end + end + end +end diff --git a/app/services/statistics/builders/subscriptions_builder_service.rb b/app/services/statistics/builders/subscriptions_builder_service.rb new file mode 100644 index 000000000..32f769931 --- /dev/null +++ b/app/services/statistics/builders/subscriptions_builder_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# Generate statistics indicators about subscriptions +class Statistics::Builders::SubscriptionsBuilderService + include Statistics::Concerns::HelpersConcern + + class << self + def build(options = default_options) + # subscription list + Statistics::FetcherService.subscriptions_list(options).each do |s| + Stats::Subscription.create({ date: format_date(s[:date]), + type: s[:duration], + subType: s[:slug], + stat: 1, + ca: s[:ca], + planId: s[:plan_id], + subscriptionId: s[:subscription_id], + invoiceItemId: s[:invoice_item_id], + groupName: s[:plan_group_name] }.merge(user_info_stat(s))) + end + end + end +end diff --git a/app/services/statistics/cleaner_service.rb b/app/services/statistics/cleaner_service.rb new file mode 100644 index 000000000..f40a5d9e0 --- /dev/null +++ b/app/services/statistics/cleaner_service.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Clean the existing statistics +class Statistics::CleanerService + include Statistics::Concerns::HelpersConcern + + class << self + def clean_stat(options = default_options) + client = Elasticsearch::Model.client + %w[Account Event Machine Project Subscription Training User Space].each do |o| + model = "Stats::#{o}".constantize + client.delete_by_query( + index: model.index_name, + type: model.document_type, + body: { query: { match: { date: format_date(options[:start_date]) } } } + ) + end + end + end +end diff --git a/app/services/statistics/concerns/compute_concern.rb b/app/services/statistics/concerns/compute_concern.rb new file mode 100644 index 000000000..93f3a5c8d --- /dev/null +++ b/app/services/statistics/concerns/compute_concern.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# Provides methods to compute totals in statistics +module Statistics::Concerns::ComputeConcern + extend ActiveSupport::Concern + + class_methods do + def calcul_ca(invoice) + return nil unless invoice + + ca = 0 + # sum each items in the invoice (+ for invoices/- for refunds) + invoice.invoice_items.each do |ii| + next if ii.object_type == 'Subscription' + + ca = if invoice.is_a?(Avoir) + ca - ii.amount.to_i + else + ca + ii.amount.to_i + end + end + # subtract coupon discount from invoices and refunds + cs = CouponService.new + ca = cs.ventilate(cs.invoice_total_no_coupon(invoice), ca, invoice.coupon) unless invoice.coupon_id.nil? + # divide the result by 100 to convert from centimes to monetary unit + ca.zero? ? ca : ca / 100.0 + end + + def calcul_avoir_ca(invoice) + ca = 0 + invoice.invoice_items.each do |ii| + ca -= ii.amount.to_i + end + # subtract coupon discount from the refund + cs = CouponService.new + ca = cs.ventilate(cs.invoice_total_no_coupon(invoice), ca, invoice.coupon) unless invoice.coupon_id.nil? + ca.zero? ? ca : ca / 100.0 + end + end +end diff --git a/app/services/statistics/concerns/helpers_concern.rb b/app/services/statistics/concerns/helpers_concern.rb new file mode 100644 index 000000000..5ec077db1 --- /dev/null +++ b/app/services/statistics/concerns/helpers_concern.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# module grouping all statistics concerns +module Statistics::Concerns; end + +# Provides various helpers for services dealing with statistics generation +module Statistics::Concerns::HelpersConcern + extend ActiveSupport::Concern + + class_methods do + def default_options + yesterday = 1.day.ago + { + start_date: yesterday.beginning_of_day, + end_date: yesterday.end_of_day + } + end + + def format_date(date) + if date.is_a?(String) + Date.strptime(date, '%Y%m%d').strftime('%Y-%m-%d') + else + date.strftime('%Y-%m-%d') + end + end + + def user_info_stat(stat) + { + userId: stat[:user_id], + gender: stat[:gender], + age: stat[:age], + group: stat[:group] + } + end + + def difference_in_hours(start_at, end_at) + if start_at.to_date == end_at.to_date + ((end_at - start_at) / 60 / 60).to_i + else + end_at_to_start_date = end_at.change(year: start_at.year, month: start_at.month, day: start_at.day) + hours = ((end_at_to_start_date - start_at) / 60 / 60).to_i + hours = ((end_at.to_date - start_at.to_date).to_i + 1) * hours if end_at.to_date > start_at.to_date + hours + end + end + end +end diff --git a/app/services/statistics/concerns/projects_concern.rb b/app/services/statistics/concerns/projects_concern.rb new file mode 100644 index 000000000..beb71686f --- /dev/null +++ b/app/services/statistics/concerns/projects_concern.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# Provides methods to consolidate data from Projects to use in statistics +module Statistics::Concerns::ProjectsConcern + extend ActiveSupport::Concern + + class_methods do + def get_project_themes(project) + project.themes.map do |t| + { id: t.id, name: t.name } + end + end + + def get_projects_components(project) + project.components.map do |c| + { id: c.id, name: c.name } + end + end + + def get_projects_machines(project) + project.machines.map do |m| + { id: m.id, name: m.name } + end + end + + def get_project_users(project) + sum = 0 + project.project_users.each do |pu| + sum += 1 if pu.is_valid + end + sum + end + + def project_info(project) + { + project_id: project.id, + project_name: project.name, + project_created_at: project.created_at, + project_published_at: project.published_at, + project_licence: {}, + project_themes: get_project_themes(project), + project_components: get_projects_components(project), + project_machines: get_projects_machines(project), + project_users: get_project_users(project) + } + end + + def project_info_stat(project) + { + projectId: project.project_id, + name: project.project_name, + licence: project.project_licence, + themes: project.project_themes, + components: project.project_components, + machines: project.project_machines, + users: project.project_users + } + end + end +end diff --git a/app/services/statistics/fetcher_service.rb b/app/services/statistics/fetcher_service.rb new file mode 100644 index 000000000..997f937e0 --- /dev/null +++ b/app/services/statistics/fetcher_service.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true + +# Fetch data from the PostgreSQL database and prepare them +# to be used in the statistics generation +class Statistics::FetcherService + include Statistics::Concerns::HelpersConcern + include Statistics::Concerns::ComputeConcern + + class << self + def subscriptions_list(options = default_options) + result = [] + InvoiceItem.where("object_type = '#{Subscription.name}' AND invoice_items.created_at >= :start_date " \ + 'AND invoice_items.created_at <= :end_date', options) + .eager_load(invoice: [:coupon]).each do |i| + next if i.invoice.is_a?(Avoir) + + sub = i.object + + ca = i.amount.to_i + cs = CouponService.new + ca = cs.ventilate(cs.invoice_total_no_coupon(i.invoice), ca, i.invoice.coupon) unless i.invoice.coupon_id.nil? + ca /= 100.00 + profile = sub.statistic_profile + p = sub.plan + result.push({ date: options[:start_date].to_date, + plan: p.group.slug, + plan_id: p.id, + plan_interval: p.interval, + plan_interval_count: p.interval_count, + plan_group_name: p.group.name, + slug: p.slug, + duration: p.find_statistic_type.key, + subscription_id: sub.id, + invoice_item_id: i.id, + ca: ca }.merge(user_info(profile))) + end + result + end + + def reservations_machine_list(options = default_options) + result = [] + Reservation + .where("reservable_type = 'Machine' AND slots_reservations.canceled_at IS NULL AND " \ + 'reservations.created_at >= :start_date AND reservations.created_at <= :end_date', options) + .eager_load(:slots, :slots_reservations, :invoice_items, statistic_profile: [:group]) + .each do |r| + next unless r.reservable + + profile = r.statistic_profile + result.push({ date: options[:start_date].to_date, + reservation_id: r.id, + machine_id: r.reservable.id, + machine_type: r.reservable.friendly_id, + machine_name: r.reservable.name, + nb_hours: r.slots.size, + ca: calcul_ca(r.original_invoice) }.merge(user_info(profile))) + end + result + end + + def reservations_space_list(options = default_options) + result = [] + Reservation + .where("reservable_type = 'Space' AND slots_reservations.canceled_at IS NULL AND " \ + 'reservations.created_at >= :start_date AND reservations.created_at <= :end_date', options) + .eager_load(:slots, :slots_reservations, :invoice_items, statistic_profile: [:group]) + .each do |r| + next unless r.reservable + + profile = r.statistic_profile + result.push({ date: options[:start_date].to_date, + reservation_id: r.id, + space_id: r.reservable.id, + space_name: r.reservable.name, + space_type: r.reservable.slug, + nb_hours: r.slots.size, + ca: calcul_ca(r.original_invoice) }.merge(user_info(profile))) + end + result + end + + def reservations_training_list(options = default_options) + result = [] + Reservation + .where("reservable_type = 'Training' AND slots_reservations.canceled_at IS NULL AND " \ + 'reservations.created_at >= :start_date AND reservations.created_at <= :end_date', options) + .eager_load(:slots, :slots_reservations, :invoice_items, statistic_profile: [:group]) + .each do |r| + next unless r.reservable + + profile = r.statistic_profile + slot = r.slots.first + result.push({ date: options[:start_date].to_date, + reservation_id: r.id, + training_id: r.reservable.id, + training_type: r.reservable.friendly_id, + training_name: r.reservable.name, + training_date: slot.start_at.to_date, + nb_hours: difference_in_hours(slot.start_at, slot.end_at), + ca: calcul_ca(r.original_invoice) }.merge(user_info(profile))) + end + result + end + + def reservations_event_list(options = default_options) + result = [] + Reservation + .where("reservable_type = 'Event' AND slots_reservations.canceled_at IS NULL AND " \ + 'reservations.created_at >= :start_date AND reservations.created_at <= :end_date', options) + .eager_load(:slots, :slots_reservations, :invoice_items, statistic_profile: [:group]) + .each do |r| + next unless r.reservable + + profile = r.statistic_profile + slot = r.slots.first + result.push({ date: options[:start_date].to_date, + reservation_id: r.id, + event_id: r.reservable.id, + event_type: r.reservable.category.slug, + event_name: r.reservable.name, + event_date: slot.start_at.to_date, + event_theme: (r.reservable.event_themes.first ? r.reservable.event_themes.first.name : ''), + age_range: (r.reservable.age_range_id ? r.reservable.age_range.name : ''), + nb_places: r.total_booked_seats, + nb_hours: difference_in_hours(slot.start_at, slot.end_at), + ca: calcul_ca(r.original_invoice) }.merge(user_info(profile))) + end + result + end + + def members_ca_list(options = default_options) + subscriptions_ca_list = subscriptions_list(options) + reservations_ca_list = [] + avoirs_ca_list = [] + users_list = [] + Reservation.where('reservations.created_at >= :start_date AND reservations.created_at <= :end_date', options) + .eager_load(:slots, :invoice_items, statistic_profile: [:group]) + .each do |r| + next unless r.reservable + + reservations_ca_list.push( + { date: options[:start_date].to_date, ca: calcul_ca(r.original_invoice) || 0 }.merge(user_info(r.statistic_profile)) + ) + end + Avoir.where('invoices.created_at >= :start_date AND invoices.created_at <= :end_date', options) + .eager_load(:invoice_items, statistic_profile: [:group]) + .each do |i| + # the following line is a workaround for issue #196 + profile = i.statistic_profile || i.main_item.object&.wallet&.user&.statistic_profile + avoirs_ca_list.push({ date: options[:start_date].to_date, ca: calcul_avoir_ca(i) || 0 }.merge(user_info(profile))) + end + reservations_ca_list.concat(subscriptions_ca_list).concat(avoirs_ca_list).each do |e| + profile = StatisticProfile.find(e[:statistic_profile_id]) + u = find_or_create_user_info(profile, users_list) + u[:date] = options[:start_date].to_date + add_ca(u, e[:ca], users_list) + end + users_list + end + + def members_list(options = default_options) + result = [] + member = Role.find_by(name: 'member') + StatisticProfile.where('role_id = :member AND created_at >= :start_date AND created_at <= :end_date', + options.merge(member: member.id)) + .each do |sp| + next if sp.user&.need_completion? + + result.push({ date: options[:start_date].to_date }.merge(user_info(sp))) + end + result + end + + def projects_list(options = default_options) + result = [] + Project.where('projects.published_at >= :start_date AND projects.published_at <= :end_date', options) + .eager_load(:licence, :themes, :components, :machines, :project_users, author: [:group]) + .each do |p| + result.push({ date: options[:start_date].to_date }.merge(user_info(p.author)).merge(project_info(p))) + end + result + end + + private + + def add_ca(profile, new_ca, users_list) + if profile[:ca] + profile[:ca] += new_ca || 0 + else + profile[:ca] = new_ca || 0 + users_list.push profile + end + end + + def find_or_create_user_info(profile, list) + found = list.find do |l| + l[:statistic_profile_id] == profile.id + end + found || user_info(profile) + end + + def user_info(statistic_profile) + return {} unless statistic_profile + + { + statistic_profile_id: statistic_profile.id, + user_id: statistic_profile.user_id, + gender: statistic_profile.str_gender, + age: statistic_profile.age, + group: statistic_profile.group ? statistic_profile.group.slug : nil + } + end + end +end diff --git a/app/workers/period_statistics_worker.rb b/app/workers/period_statistics_worker.rb index a73a35004..53a7bdda8 100644 --- a/app/workers/period_statistics_worker.rb +++ b/app/workers/period_statistics_worker.rb @@ -10,10 +10,10 @@ class PeriodStatisticsWorker days = date_to_days(period) Rails.logger.info "\n==> generating statistics for the last #{days} days <==\n" if days.zero? - StatisticService.new.generate_statistic(start_date: DateTime.current.beginning_of_day, end_date: DateTime.current.end_of_day) + Statistics::BuilderService.generate_statistic(start_date: DateTime.current.beginning_of_day, end_date: DateTime.current.end_of_day) else days.times.each do |i| - StatisticService.new.generate_statistic(start_date: i.day.ago.beginning_of_day, end_date: i.day.ago.end_of_day) + Statistics::BuilderService.generate_statistic(start_date: i.day.ago.beginning_of_day, end_date: i.day.ago.end_of_day) end end end diff --git a/app/workers/statistic_worker.rb b/app/workers/statistic_worker.rb index dd9da8264..70fb2c94d 100644 --- a/app/workers/statistic_worker.rb +++ b/app/workers/statistic_worker.rb @@ -8,6 +8,6 @@ class StatisticWorker def perform return unless Setting.get('statistics_module') - StatisticService.new.generate_statistic + Statistics::BuilderService.generate_statistic end end diff --git a/lib/tasks/fablab/maintenance.rake b/lib/tasks/fablab/maintenance.rake index 0fde17d13..6c08b2193 100644 --- a/lib/tasks/fablab/maintenance.rake +++ b/lib/tasks/fablab/maintenance.rake @@ -10,7 +10,7 @@ namespace :fablab do start_date = Time.zone.local(year.to_i, month.to_i, 1) end_date = start_date.next_month puts "-> Start regenerate the invoices PDF between #{I18n.l start_date, format: :long} and " \ - "#{I18n.l end_date - 1.minute, format: :long}" + "#{I18n.l end_date - 1.minute, format: :long}" invoices = Invoice.where('created_at >= :start_date AND created_at < :end_date', start_date: start_date, end_date: end_date) .order(created_at: :asc) invoices.each(&:regenerate_invoice_pdf) @@ -23,7 +23,7 @@ namespace :fablab do start_date = Time.zone.local(year.to_i, month.to_i, 1) end_date = start_date.next_month puts "-> Start regenerate the payment schedules PDF between #{I18n.l start_date, format: :long} and " \ - "#{I18n.l end_date - 1.minute, format: :long}" + "#{I18n.l end_date - 1.minute, format: :long}" schedules = PaymentSchedule.where('created_at >= :start_date AND created_at < :end_date', start_date: start_date, end_date: end_date) .order(created_at: :asc) schedules.each(&:regenerate_pdf) @@ -33,9 +33,7 @@ namespace :fablab do desc 'recreate every versions of images' task build_images_versions: :environment do Project.find_each do |project| - if project.project_image.present? && project.project_image.attachment.present? - project.project_image.attachment.recreate_versions! - end + project.project_image.attachment.recreate_versions! if project.project_image.present? && project.project_image.attachment.present? end ProjectStepImage.find_each do |project_step_image| project_step_image.attachment.recreate_versions! if project_step_image.present? && project_step_image.attachment.present? @@ -59,7 +57,7 @@ namespace :fablab do count = User.where(is_active: false).count if count.positive? print "WARNING: You are about to delete #{count} users. Are you sure? (y/n) " - confirm = STDIN.gets.chomp + confirm = $stdin.gets.chomp next unless confirm == 'y' User.where(is_active: false).map(&:destroy!) @@ -89,7 +87,6 @@ namespace :fablab do desc 'clean the cron workers' task clean_workers: :environment do - Sidekiq::Cron::Job.destroy_all! Sidekiq::Queue.new('system').clear Sidekiq::Queue.new('default').clear @@ -119,8 +116,8 @@ namespace :fablab do start_date = Time.zone.local(year.to_i, month.to_i, 1) end_date = yesterday.end_of_day puts "-> Start regenerate statistics between #{I18n.l start_date, format: :long} and " \ - "#{I18n.l end_date, format: :long}" - StatisticService.new.generate_statistic( + "#{I18n.l end_date, format: :long}" + Statistics::BuilderService.generate_statistic( start_date: start_date, end_date: end_date ) @@ -134,7 +131,7 @@ namespace :fablab do start_date = Time.zone.local(year.to_i, month.to_i, 1) end_date = start_date.next_month puts "-> Start regenerate the invoices reference between #{I18n.l start_date, format: :long} and " \ - "#{I18n.l end_date - 1.minute, format: :long}" + "#{I18n.l end_date - 1.minute, format: :long}" invoices = Invoice.where('created_at >= :start_date AND created_at < :end_date', start_date: start_date, end_date: end_date) .order(created_at: :asc) invoices.each(&:update_reference) diff --git a/test/helpers/archive_helper.rb b/test/helpers/archive_helper.rb new file mode 100644 index 000000000..74755e662 --- /dev/null +++ b/test/helpers/archive_helper.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# Provides methods to help testing archives of accounting periods +module ArchiveHelper + # Force the generation of the archive now. + # Then extract it, then check its contents, then delete the archive, finally delete the extracted content + def assert_archive(accounting_period) + assert_not_nil accounting_period, 'AccountingPeriod was not created' + + archive_worker = ArchiveWorker.new + archive_worker.perform(accounting_period.id) + + assert FileTest.exist?(accounting_period.archive_file), 'ZIP archive was not generated' + + dest = extract_archive(accounting_period) + + # Check archive matches + file = check_integrity(dest) + + archive = File.read("#{dest}/#{file}") + archive_json = JSON.parse(archive) + invoices = Invoice.where( + 'created_at >= :start_date AND created_at <= :end_date', + start_date: accounting_period.start_at.to_datetime, end_date: accounting_period.end_at.to_datetime + ) + + assert_equal invoices.count, archive_json['invoices'].count + assert_equal accounting_period.footprint, archive_json['period_footprint'] + + require 'version' + assert_equal Version.current, archive_json['software']['version'] + + # we clean up the files before quitting + FileUtils.rm_rf(dest) + FileUtils.rm_rf(accounting_period.archive_folder) + end + + private + + # Extract the archive to the temporary folder + def extract_archive(accounting_period) + require 'tmpdir' + require 'fileutils' + dest = "#{Dir.tmpdir}/accounting/#{accounting_period.id}" + FileUtils.mkdir_p "#{dest}/accounting" + Zip::File.open(accounting_period.archive_file) do |zip_file| + # Handle entries one by one + zip_file.each do |entry| + # Extract to file/directory/symlink + entry.extract("#{dest}/#{entry.name}") + end + end + dest + end + + def check_integrity(extracted_path) + require 'integrity/checksum' + sumfile = File.read("#{dest}/checksum.sha256").split("\t") + assert_equal sumfile[0], Integrity::Checksum.file("#{extracted_path}/#{sumfile[1]}"), 'archive checksum does not match' + sumfile[1] + end +end diff --git a/test/helpers/invoice_helper.rb b/test/helpers/invoice_helper.rb new file mode 100644 index 000000000..0faba67e5 --- /dev/null +++ b/test/helpers/invoice_helper.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +# Provides methods to help testing invoices +module InvoiceHelper + # Force the invoice generation worker to run NOW and check the resulting file generated. + # Delete the file afterwards. + # @param invoice {Invoice} + def assert_invoice_pdf(invoice) + assert_not_nil invoice, 'Invoice was not created' + + generate_pdf(invoice) + + assert File.exist?(invoice.file), 'Invoice PDF was not generated' + + # now we check the file content + reader = PDF::Reader.new(invoice.file) + assert_equal 1, reader.page_count # single page invoice + page = reader.pages.first + lines = page.text.scan(/^.+/) + + check_amounts(invoice, lines) + check_user(invoice, lines) + + File.delete(invoice.file) + end + + private + + def generate_pdf(invoice) + invoice_worker = InvoiceWorker.new + invoice_worker.perform(invoice.id, invoice&.user&.subscription&.expired_at) + end + + # Parse a line of text read from a PDF file and return the price included inside + # Line of text should be of form 'Label $10.00' + # @returns {float} + def parse_amount_from_invoice_line(line) + line[line.rindex(' ') + 1..].tr(I18n.t('number.currency.format.unit'), '').to_f + end + + # check VAT and total excluding taxes + def check_amounts(invoice, lines) + ht_amount = invoice.total + lines.each do |line| + # check that the numbers printed into the PDF file match the total stored in DB + if line.include? I18n.t('invoices.total_amount') + assert_equal invoice.total / 100.0, parse_amount_from_invoice_line(line), 'Invoice total rendered in the PDF file does not match' + end + + # check that the VAT was correctly applied if it was configured + ht_amount = parse_amount_from_invoice_line(line) if line.include? I18n.t('invoices.including_total_excluding_taxes') + end + + vat_service = VatHistoryService.new + invoice.invoice_items.each do |item| + vat_rate = vat_service.invoice_item_vat(item) + if vat_rate.positive? + computed_ht = sprintf('%.2f', (item.amount_after_coupon / ((vat_rate / 100.00) + 1)) / 100.00).to_f + + assert_equal computed_ht, item.net_amount / 100.00, 'Total excluding taxes rendered in the PDF file is not computed correctly' + else + assert_equal item.amount_after_coupon, item.net_amount, 'VAT information was rendered in the PDF file despite that VAT was disabled' + end + end + end + + # check the recipient & the address + def check_user(invoice, lines) + if invoice.invoicing_profile.organization + assert lines.first.include?(invoice.invoicing_profile.organization.name), 'On the PDF invoice, organization name is invalid' + assert invoice.invoicing_profile.organization.address.address.include?(lines[2].split(' ').last.strip), + 'On the PDF invoice, organization address is invalid' + else + assert lines.first.include?(invoice.invoicing_profile.full_name), 'On the PDF invoice, customer name is invalid' + assert invoice.invoicing_profile.address.address.include?(lines[2].split(' ').last.strip), + 'On the PDF invoice, customer address is invalid' + end + # check the email + assert lines[1].include?(invoice.invoicing_profile.email), 'On the PDF invoice, email is invalid' + end +end diff --git a/test/services/statistic_service_test.rb b/test/services/statistic_service_test.rb index 7434ada90..db10ba4b8 100644 --- a/test/services/statistic_service_test.rb +++ b/test/services/statistic_service_test.rb @@ -2,17 +2,14 @@ require 'test_helper' -class StatisticServiceTest < ActiveSupport::TestCase +class StatisticServiceTest < ActionDispatch::IntegrationTest setup do @user = User.members.without_subscription.first @admin = User.with_role(:admin).first login_as(@admin, scope: :user) end - def test - machine_stats_count = Stats::Machine.all.count - subscription_stats_count = Stats::Subscription.all.count - + test 'build stats' do # Create a reservation to generate an invoice machine = Machine.find(1) slot = Availability.find(19).slots.first @@ -48,12 +45,40 @@ class StatisticServiceTest < ActiveSupport::TestCase }.to_json, headers: default_headers # Build the stats for today, we expect the above invoices (reservation+subscription) to appear in the resulting stats - StatisticService.new.generate_statistic( - start_date: DateTime.current.beginning_of_day, - end_date: DateTime.current.end_of_day - ) + ::Statistics::BuilderService.generate_statistic({ start_date: DateTime.current.beginning_of_day, + end_date: DateTime.current.end_of_day }) - assert_equal machine_stats_count + 1, Stats::Machine.all.count - assert_equal subscription_stats_count + 1, Stats::Subscription.all.count + Stats::Machine.refresh_index! + + stat_booking = Stats::Machine.search(query: { bool: { must: [{ term: { date: DateTime.current.to_date.iso8601 } }, + { term: { type: 'booking' } }] } }).first + assert_not_nil stat_booking + assert_equal machine.friendly_id, stat_booking['subType'] + check_statistics_on_user(stat_booking) + + stat_hour = Stats::Machine.search(query: { bool: { must: [{ term: { date: DateTime.current.to_date.iso8601 } }, + { term: { type: 'hour' } }] } }).first + + assert_not_nil stat_hour + assert_equal machine.friendly_id, stat_hour['subType'] + check_statistics_on_user(stat_hour) + + Stats::Subscription.refresh_index! + + stat_subscription = Stats::Subscription.search(query: { bool: { must: [{ term: { date: DateTime.current.to_date.iso8601 } }, + { term: { type: plan.find_statistic_type.key } }] } }).first + + assert_not_nil stat_subscription + assert_equal plan.find_statistic_type.key, stat_subscription['type'] + assert_equal plan.slug, stat_subscription['subType'] + assert_equal plan.id, stat_subscription['planId'] + assert_equal 1, stat_subscription['stat'] + check_statistics_on_user(stat_subscription) + end + + def check_statistics_on_user(stat) + assert_equal @user.statistic_profile.str_gender, stat['gender'] + assert_equal @user.statistic_profile.age.to_i, stat['age'] + assert_equal @user.statistic_profile.group.slug, stat['group'] end end diff --git a/test/test_helper.rb b/test/test_helper.rb index c6930d66d..23ff51b82 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -10,20 +10,25 @@ require 'rails/test_help' require 'vcr' require 'sidekiq/testing' require 'minitest/reporters' - -include ActionDispatch::TestProcess +require 'helpers/invoice_helper' +require 'helpers/archive_helper' VCR.configure do |config| config.cassette_library_dir = 'test/vcr_cassettes' config.hook_into :webmock config.filter_sensitive_data('sk_test_testfaketestfaketestfake') { Setting.get('stripe_secret_key') } config.filter_sensitive_data('pk_test_faketestfaketestfaketest') { Setting.get('stripe_public_key') } + config.ignore_request { |req| URI(req.uri).port == 9200 } end Sidekiq::Testing.fake! Minitest::Reporters.use! [Minitest::Reporters::DefaultReporter.new(color: true)] unless ENV['RM_INFO'] class ActiveSupport::TestCase + include ActionDispatch::TestProcess + include InvoiceHelper + include ArchiveHelper + # Add more helper methods to be used by all tests here... ActiveRecord::Migration.check_pending! fixtures :all @@ -75,60 +80,6 @@ class ActiveSupport::TestCase ).id end - # Force the invoice generation worker to run NOW and check the resulting file generated. - # Delete the file afterwards. - # @param invoice {Invoice} - def assert_invoice_pdf(invoice) - assert_not_nil invoice, 'Invoice was not created' - - invoice_worker = InvoiceWorker.new - invoice_worker.perform(invoice.id, invoice&.user&.subscription&.expired_at) - - assert File.exist?(invoice.file), 'Invoice PDF was not generated' - - # now we check the file content - reader = PDF::Reader.new(invoice.file) - assert_equal 1, reader.page_count # single page invoice - - ht_amount = invoice.total - page = reader.pages.first - lines = page.text.scan(/^.+/) - lines.each do |line| - # check that the numbers printed into the PDF file match the total stored in DB - if line.include? I18n.t('invoices.total_amount') - assert_equal invoice.total / 100.0, parse_amount_from_invoice_line(line), 'Invoice total rendered in the PDF file does not match' - end - - # check that the VAT was correctly applied if it was configured - ht_amount = parse_amount_from_invoice_line(line) if line.include? I18n.t('invoices.including_total_excluding_taxes') - end - - vat_service = VatHistoryService.new - invoice.invoice_items.each do |item| - vat_rate = vat_service.invoice_item_vat(item) - if vat_rate.positive? - computed_ht = sprintf('%.2f', (item.amount_after_coupon / (vat_rate / 100.00 + 1)) / 100.00).to_f - - assert_equal computed_ht, item.net_amount / 100.00, 'Total excluding taxes rendered in the PDF file is not computed correctly' - else - assert_equal item.amount_after_coupon, item.net_amount, 'VAT information was rendered in the PDF file despite that VAT was disabled' - end - end - - # check the recipient & the address - if invoice.invoicing_profile.organization - assert lines.first.include?(invoice.invoicing_profile.organization.name), 'On the PDF invoice, organization name is invalid' - assert invoice.invoicing_profile.organization.address.address.include?(lines[2].split(' ').last.strip), 'On the PDF invoice, organization address is invalid' - else - assert lines.first.include?(invoice.invoicing_profile.full_name), 'On the PDF invoice, customer name is invalid' - assert invoice.invoicing_profile.address.address.include?(lines[2].split(' ').last.strip), 'On the PDF invoice, customer address is invalid' - end - # check the email - assert lines[1].include?(invoice.invoicing_profile.email), 'On the PDF invoice, email is invalid' - - File.delete(invoice.file) - end - # Force the statistics export generation worker to run NOW and check the resulting file generated. # Delete the file afterwards. # @param export {Export} @@ -147,63 +98,10 @@ class ActiveSupport::TestCase end end - def assert_archive(accounting_period) - assert_not_nil accounting_period, 'AccountingPeriod was not created' - - archive_worker = ArchiveWorker.new - archive_worker.perform(accounting_period.id) - - assert FileTest.exist?(accounting_period.archive_file), 'ZIP archive was not generated' - - # Extract archive - require 'tmpdir' - require 'fileutils' - dest = "#{Dir.tmpdir}/accounting/#{accounting_period.id}" - FileUtils.mkdir_p "#{dest}/accounting" - Zip::File.open(accounting_period.archive_file) do |zip_file| - # Handle entries one by one - zip_file.each do |entry| - # Extract to file/directory/symlink - entry.extract("#{dest}/#{entry.name}") - end - end - - # Check archive matches - require 'integrity/checksum' - sumfile = File.read("#{dest}/checksum.sha256").split("\t") - assert_equal sumfile[0], Integrity::Checksum.file("#{dest}/#{sumfile[1]}"), 'archive checksum does not match' - - archive = File.read("#{dest}/#{sumfile[1]}") - archive_json = JSON.parse(archive) - invoices = Invoice.where( - 'created_at >= :start_date AND created_at <= :end_date', - start_date: accounting_period.start_at.to_datetime, end_date: accounting_period.end_at.to_datetime - ) - - assert_equal invoices.count, archive_json['invoices'].count - assert_equal accounting_period.footprint, archive_json['period_footprint'] - - require 'version' - assert_equal Version.current, archive_json['software']['version'] - - # we clean up the files before quitting - FileUtils.rm_rf(dest) - FileUtils.rm_rf(accounting_period.archive_folder) - end - def assert_dates_equal(expected, actual, msg = nil) assert_not_nil actual, msg assert_equal expected.to_date, actual.to_date, msg end - - private - - # Parse a line of text read from a PDF file and return the price included inside - # Line of text should be of form 'Label $10.00' - # @returns {float} - def parse_amount_from_invoice_line(line) - line[line.rindex(' ') + 1..-1].tr(I18n.t('number.currency.format.unit'), '').to_f - end end class ActionDispatch::IntegrationTest