diff --git a/.rubocop.yml b/.rubocop.yml index 8b1d1390e..89595f901 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -24,3 +24,5 @@ Style/ClassAndModuleChildren: EnforcedStyle: compact Style/AndOr: EnforcedStyle: conditionals +Style/FormatString: + EnforcedStyle: sprintf diff --git a/CHANGELOG.md b/CHANGELOG.md index e9543e3d6..0aae19bbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,15 @@ # Changelog Fab Manager + +## v3.1.0 2019 April 8 + +- Asynchronously generate accounting archives +- Improved end-user message when closing an accounting period +- Improved date checks before closing an accounting period +- Paginate list of coupons +- Allow filtering coupons list +- Fix a bug: when VAT has changed during fab-manager's lifecycle, this may not be reflected in archives +- Fix a bug: using a quote in event category's name results in angular $parse:syntax Error + ## v3.0.1 2019 April 1st - Insert archive generation datetime in chained.sha256 @@ -20,6 +31,7 @@ - Rebranded product from "La Casemate" - Refactored some pieces of Ruby code, according to style guide - Added asterisks on required fields in sign-up form +- [TODO DEPLOY] /!\ Before deploying, you must check (and eventually) correct your VAT history using the rails console. Missing rates can be added later but dates and rates (including date of activation, disabling) MUST be correct. These values are very likely wrong if your installation was made prior to 2.8.0 with VAT enabled. Other cases must be checked too. - [TODO DEPLOY] (dev) if applicable, you must first downgrade bundler to v1 `gem uninstall bundler --version=2.0.1 && gem install bundler --version=1.7.3 && bundle install` - [TODO DEPLOY] if you have changed your VAT rate in the past, add its history into database. You can use a rate of "0" to disable VAT. Eg. `rake fablab:setup:add_vat_rate[20,2017-01-01]` - [TODO DEPLOY] `rake fablab:setup:set_environment_to_invoices` @@ -27,6 +39,7 @@ - [TODO DEPLOY] `rake fablab:setup:chain_invoices_records` - [TODO DEPLOY] `rake fablab:setup:chain_history_values_records` - [TODO DEPLOY] add `DISK_SPACE_MB_ALERT` and `SUPERADMIN_EMAIL` environment variables (see [doc/environment.md](doc/environment.md) for configuration details) +- [TODO DEPLOY] add the `accounting` volume to the fab-manager's image in [docker-compose.yml](docker/docker-compose.yml) ## v2.8.4 2019 March 18 diff --git a/app/assets/javascripts/controllers/admin/invoices.js.erb b/app/assets/javascripts/controllers/admin/invoices.js.erb index cd0bd622f..6ce3aa390 100644 --- a/app/assets/javascripts/controllers/admin/invoices.js.erb +++ b/app/assets/javascripts/controllers/admin/invoices.js.erb @@ -419,6 +419,8 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I $uibModal.open({ templateUrl: '<%= asset_path "admin/invoices/closePeriodModal.html" %>', controller: 'ClosePeriodModalController', + backdrop: 'static', + keyboard: false, size: 'lg', resolve: { periods() { return AccountingPeriod.query().$promise; }, @@ -676,8 +678,8 @@ Application.Controllers.controller('AvoirModalController', ['$scope', '$uibModal /** * Controller used in the modal window allowing an admin to close an accounting period */ -Application.Controllers.controller('ClosePeriodModalController', ['$scope', '$uibModalInstance', '$window', 'Invoice', 'AccountingPeriod', 'periods', 'lastClosingEnd','dialogs', 'growl', '_t', - function ($scope, $uibModalInstance, $window, Invoice, AccountingPeriod, periods, lastClosingEnd, dialogs, growl, _t) { +Application.Controllers.controller('ClosePeriodModalController', ['$scope', '$uibModalInstance', '$window', '$sce', 'Invoice', 'AccountingPeriod', 'periods', 'lastClosingEnd','dialogs', 'growl', '_t', + function ($scope, $uibModalInstance, $window, $sce, Invoice, AccountingPeriod, periods, lastClosingEnd, dialogs, growl, _t) { const YESTERDAY = moment.utc({ h: 0, m: 0, s: 0, ms: 0 }).subtract(1, 'day').toDate(); const LAST_CLOSING = moment.utc(lastClosingEnd.last_end_date).toDate(); const MAX_END = moment.utc(lastClosingEnd.last_end_date).add(1, 'year').subtract(1, 'day').toDate(); @@ -734,9 +736,15 @@ Application.Controllers.controller('ClosePeriodModalController', ['$scope', '$ui object () { return { title: _t('invoices.confirmation_required'), - msg: _t( - 'invoices.confirm_close_START_END', - { START: moment.utc($scope.period.start_at).format('LL'), END: moment.utc($scope.period.end_at).format('LL') } + msg: $sce.trustAsHtml( + _t( + 'invoices.confirm_close_START_END', + { START: moment.utc($scope.period.start_at).format('LL'), END: moment.utc($scope.period.end_at).format('LL') } + ) + + '

' + + _t('invoices.period_must_match_fiscal_year') + + '

' + + _t('invoices.this_may_take_a_while') ) }; } diff --git a/app/assets/javascripts/controllers/admin/pricing.js.erb b/app/assets/javascripts/controllers/admin/pricing.js.erb index 98e2498fe..3f94af05e 100644 --- a/app/assets/javascripts/controllers/admin/pricing.js.erb +++ b/app/assets/javascripts/controllers/admin/pricing.js.erb @@ -53,6 +53,7 @@ Application.Controllers.controller('EditPricingController', ['$scope', '$state', // List of coupons $scope.coupons = couponsPromise; + $scope.couponsPage = 1; // List of spaces $scope.spaces = spacesPromise; @@ -81,6 +82,20 @@ Application.Controllers.controller('EditPricingController', ['$scope', '$state', 'all' ]; + // Default: we do not filter coupons + $scope.filter = { + coupon: 'all', + }; + + // Available status for filtering coupons + $scope.couponStatus = [ + 'all', + 'disabled', + 'expired', + 'sold_out', + 'active' + ]; + $scope.findTrainingsPricing = function (trainingsPricings, trainingId, groupId) { for (let trainingsPricing of Array.from(trainingsPricings)) { if ((trainingsPricing.training_id === trainingId) && (trainingsPricing.group_id === groupId)) { @@ -565,6 +580,26 @@ Application.Controllers.controller('EditPricingController', ['$scope', '$state', }); }; + /** + * Load the next 10 coupons + */ + $scope.loadMore = function() { + $scope.couponsPage++; + Coupon.query({ page: $scope.couponsPage, filter: $scope.filter.coupon }, function (data) { + $scope.coupons = $scope.coupons.concat(data); + }); + }; + + /** + * Reset the list of coupons according to the newly selected filter + */ + $scope.updateCouponFilter = function() { + $scope.couponsPage = 1; + Coupon.query({ page: $scope.couponsPage, filter: $scope.filter.coupon }, function (data) { + $scope.coupons = data; + }); + } + /* PRIVATE SCOPE */ /** diff --git a/app/assets/javascripts/router.js.erb b/app/assets/javascripts/router.js.erb index 95bd0f3eb..f04257f74 100644 --- a/app/assets/javascripts/router.js.erb +++ b/app/assets/javascripts/router.js.erb @@ -786,7 +786,7 @@ angular.module('application.router', ['ui.router']) machineCreditsPromise: ['Credit', function (Credit) { return Credit.query({ creditable_type: 'Machine' }).$promise; }], machinesPromise: ['Machine', function (Machine) { return Machine.query().$promise; }], trainingCreditsPromise: ['Credit', function (Credit) { return Credit.query({ creditable_type: 'Training' }).$promise; }], - couponsPromise: ['Coupon', function (Coupon) { return Coupon.query().$promise; }], + couponsPromise: ['Coupon', function (Coupon) { return Coupon.query({ page: 1, filter: 'all' }).$promise; }], spacesPromise: ['Space', function (Space) { return Space.query().$promise; }], spacesPricesPromise: ['Price', function (Price) { return Price.query({ priceable_type: 'Space', plan_id: 'null' }).$promise; }], spacesCreditsPromise: ['Credit', function (Credit) { return Credit.query({ creditable_type: 'Space' }).$promise; }] diff --git a/app/assets/stylesheets/modules/invoice.scss b/app/assets/stylesheets/modules/invoice.scss index 9c36c3b1f..d4904f49c 100644 --- a/app/assets/stylesheets/modules/invoice.scss +++ b/app/assets/stylesheets/modules/invoice.scss @@ -204,6 +204,10 @@ table.closings-table { margin-left: 2em; cursor: pointer; } + + & > span.no-pointer { + cursor: default; + } } tbody .show-more { @@ -268,3 +272,7 @@ table.scrollable-3-cols { .period-info-title { font-weight: bold; } + +input.form-control.as-writable { + background-color: white; +} diff --git a/app/assets/templates/admin/invoices/closePeriodModal.html.erb b/app/assets/templates/admin/invoices/closePeriodModal.html.erb index e75ade394..f2ada5808 100644 --- a/app/assets/templates/admin/invoices/closePeriodModal.html.erb +++ b/app/assets/templates/admin/invoices/closePeriodModal.html.erb @@ -29,7 +29,7 @@
+ required + readonly/>
{{ 'invoices.end_date_is_required' }} {{ errors.end_at[0] }} @@ -65,7 +66,8 @@ {{period.end_at | amDateFormat:'L'}} - + + diff --git a/app/assets/templates/admin/pricing/coupons.html.erb b/app/assets/templates/admin/pricing/coupons.html.erb index 484c9d74b..556a00cbb 100644 --- a/app/assets/templates/admin/pricing/coupons.html.erb +++ b/app/assets/templates/admin/pricing/coupons.html.erb @@ -1,6 +1,20 @@

{{ 'pricing.list_of_the_coupons' }}

- +
+ +
+
+ + +
+
+
+ @@ -27,4 +41,8 @@ -
\ No newline at end of file + + +
+ +
diff --git a/app/assets/templates/home.html.erb b/app/assets/templates/home.html.erb index 0f44dd80d..f11b15937 100644 --- a/app/assets/templates/home.html.erb +++ b/app/assets/templates/home.html.erb @@ -97,7 +97,7 @@

{{event.title}}

- {{event.category.name}} + {{event.category.name}}

diff --git a/app/controllers/api/coupons_controller.rb b/app/controllers/api/coupons_controller.rb index 8462ac139..3d7b37703 100644 --- a/app/controllers/api/coupons_controller.rb +++ b/app/controllers/api/coupons_controller.rb @@ -6,8 +6,12 @@ class API::CouponsController < API::ApiController before_action :authenticate_user! before_action :set_coupon, only: %i[show update destroy] + # Number of notifications added to the page when the user clicks on 'load next notifications' + COUPONS_PER_PAGE = 10 + def index - @coupons = Coupon.all + @coupons = Coupon.method(params[:filter]).call.page(params[:page]).per(COUPONS_PER_PAGE).order('created_at DESC') + @total = Coupon.method(params[:filter]).call.length end def show; end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 6f941cced..ea6e2ab0b 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,67 +1,70 @@ +# frozen_string_literal: true + +# Various helpers methods module ApplicationHelper - include Twitter::Autolink - require 'message_format' + include Twitter::Autolink + require 'message_format' - ## machine/spaces availabilities are divided in multiple slots of 60 minutes - SLOT_DURATION ||= 60 + ## machine/spaces availabilities are divided in multiple slots of 60 minutes + SLOT_DURATION ||= 60 - ## - # Verify if the provided attribute is in the provided attributes array, whatever it exists or not - # @param attributes {Array|nil} - # @param attribute {String} - ## - def attribute_requested?(attributes, attribute) - attributes.try(:include?, attribute) - end + ## + # Verify if the provided attribute is in the provided attributes array, whatever it exists or not + # @param attributes {Array|nil} + # @param attribute {String} + ## + def attribute_requested?(attributes, attribute) + attributes.try(:include?, attribute) + end - def bootstrap_class_for flash_type - { flash: 'alert-success', alert: 'alert-danger', notice: 'alert-info' }[flash_type.to_sym] || flash_type.to_s - end + def bootstrap_class_for flash_type + { flash: 'alert-success', alert: 'alert-danger', notice: 'alert-info' }[flash_type.to_sym] || flash_type.to_s + end - def flash_messages(opts = {}) - flash.each do |msg_type, message| - concat(content_tag(:div, message, class: "flash-message alert #{bootstrap_class_for(msg_type)} fade in") do - concat content_tag(:button, 'x', class: 'close', data: { dismiss: 'alert' }) - concat message - end) - end - nil - end + def flash_messages(_opts = {}) + flash.each do |msg_type, message| + concat(content_tag(:div, message, class: "flash-message alert #{bootstrap_class_for(msg_type)} fade in") do + concat content_tag(:button, 'x', class: 'close', data: { dismiss: 'alert' }) + concat message + end) + end + nil + end - def print_slot(starting, ending) - "#{starting.strftime('%H:%M')} - #{ending.strftime('%H:%M')}" - end + def print_slot(starting, ending) + "#{starting.strftime('%H:%M')} - #{ending.strftime('%H:%M')}" + end - def class_exists?(class_name) - klass = Module.const_get(class_name) - return klass.is_a?(Class) - rescue NameError - return false - end + def class_exists?(class_name) + klass = Module.const_get(class_name) + klass.is_a?(Class) + rescue NameError + false + end - ## - # Allow to treat a rails i18n key as a MessageFormat interpolated pattern. Used in ruby views (API/mails) - # @param key {String} Ruby-on-Rails I18n key (from config/locales/xx.yml) - # @param interpolations {Hash} list of variables to interpolate, following ICU MessageFormat syntax - ## - def _t(key, interpolations) - message = MessageFormat.new(I18n.t(scope_key_by_partial(key)), I18n.locale.to_s) - text = message.format(interpolations) - if html_safe_translation_key?(key) - text.html_safe - else - text - end - end + ## + # Allow to treat a rails i18n key as a MessageFormat interpolated pattern. Used in ruby views (API/mails) + # @param key {String} Ruby-on-Rails I18n key (from config/locales/xx.yml) + # @param interpolations {Hash} list of variables to interpolate, following ICU MessageFormat syntax + ## + def _t(key, interpolations) + message = MessageFormat.new(I18n.t(scope_key_by_partial(key)), I18n.locale.to_s) + text = message.format(interpolations) + if html_safe_translation_key?(key) + text.html_safe + else + text + end + end - def bool_to_sym(bool) - if (bool) then return :true else return :false end - end + def bool_to_sym(bool) + bool ? :true : :false # rubocop:disable Lint/BooleanSymbol + end - def amount_to_f(amount) - amount / 100.00 - end + def amount_to_f(amount) + amount / 100.00 + end ## # Retrieve an item in the given array of items @@ -69,46 +72,45 @@ module ApplicationHelper # this can be overridden by passing a third parameter to specify the # property to match ## - def get_item(array, id, key = nil) - array.each do |i| - if key.nil? - return i if i.id == id - else - return i if i[key] == id - end - end - nil - end + def get_item(array, id, key = nil) + array.each do |i| + if key.nil? + return i if i.id == id + elsif i[key] == id + return i + end + end + nil + end - ## - # Apply a correction for a future DateTime due to change in Daylight Saving Time (DST) period - # @param reference {ActiveSupport::TimeWithZone} - # @param datetime {DateTime} - # Inspired by https://stackoverflow.com/a/12065605 - ## - def dst_correction(reference, datetime) - res = datetime.in_time_zone(reference.time_zone.tzinfo.name) - res = res - 1.hour if res.dst? && !reference.dst? - res = res + 1.hour if reference.dst? && !res.dst? - res - end + ## + # Apply a correction for a future DateTime due to change in Daylight Saving Time (DST) period + # @param reference {ActiveSupport::TimeWithZone} + # @param datetime {DateTime} + # Inspired by https://stackoverflow.com/a/12065605 + ## + def dst_correction(reference, datetime) + res = datetime.in_time_zone(reference.time_zone.tzinfo.name) + res -= 1.hour if res.dst? && !reference.dst? + res += 1.hour if reference.dst? && !res.dst? + res + end - private - ## inspired by gems/actionview-4.2.5/lib/action_view/helpers/translation_helper.rb - def scope_key_by_partial(key) - if key.to_s.first == "." - if @virtual_path - @virtual_path.gsub(%r{/_?}, ".") + key.to_s - else - raise "Cannot use t(#{key.inspect}) shortcut because path is not available" - end - else - key - end - end + private - def html_safe_translation_key?(key) - key.to_s =~ /(\b|_|\.)html$/ - end + ## inspired by gems/actionview-4.2.5/lib/action_view/helpers/translation_helper.rb + def scope_key_by_partial(key) + if key.to_s.first == '.' + raise "Cannot use t(#{key.inspect}) shortcut because path is not available" unless @virtual_path + + @virtual_path.gsub(%r{/_?}, '.') + key.to_s + else + key + end + end + + def html_safe_translation_key?(key) + key.to_s =~ /(\b|_|\.)html$/ + end end diff --git a/app/helpers/availability_helper.rb b/app/helpers/availability_helper.rb index 474d7c9d3..3999154f7 100644 --- a/app/helpers/availability_helper.rb +++ b/app/helpers/availability_helper.rb @@ -1,3 +1,6 @@ +# frozen_string_literal: true + +# Helpers methods about calendar availabilities module AvailabilityHelper MACHINE_COLOR = '#e4cd78' TRAINING_COLOR = '#bd7ae9' @@ -9,14 +12,14 @@ module AvailabilityHelper def availability_border_color(availability) case availability.available_type - when 'machines' - MACHINE_COLOR - when 'training' - TRAINING_COLOR - when 'space' - SPACE_COLOR - else - EVENT_COLOR + when 'machines' + MACHINE_COLOR + when 'training' + TRAINING_COLOR + when 'space' + SPACE_COLOR + else + EVENT_COLOR end end @@ -45,14 +48,14 @@ module AvailabilityHelper IS_COMPLETED else case availability.available_type - when 'training' - TRAINING_COLOR - when 'event' - EVENT_COLOR - when 'space' - SPACE_COLOR - else - '#000' + when 'training' + TRAINING_COLOR + when 'event' + EVENT_COLOR + when 'space' + SPACE_COLOR + else + '#000' end end end diff --git a/app/helpers/upload_helper.rb b/app/helpers/upload_helper.rb index 6d9ba45e0..d384c6593 100644 --- a/app/helpers/upload_helper.rb +++ b/app/helpers/upload_helper.rb @@ -1,3 +1,6 @@ +# frozen_string_literal: true + +# Helpers methods about uploading files module UploadHelper def delete_empty_dirs diff --git a/app/models/abuse.rb b/app/models/abuse.rb index c02066be6..e1438c8e3 100644 --- a/app/models/abuse.rb +++ b/app/models/abuse.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# Abuse is a report made by a visitor (not especially a logged user) who has signaled a content that seems abusive to his eyes. +# It is currently used with projects. class Abuse < ActiveRecord::Base include NotifyWith::NotificationAttachedObject diff --git a/app/models/accounting_period.rb b/app/models/accounting_period.rb index 5178e6255..85ff371a2 100644 --- a/app/models/accounting_period.rb +++ b/app/models/accounting_period.rb @@ -10,10 +10,12 @@ class AccountingPeriod < ActiveRecord::Base before_destroy { false } before_update { false } before_create :compute_totals - after_create :archive_closed_data + after_commit :archive_closed_data, on: [:create] validates :start_at, :end_at, :closed_at, :closed_by, presence: true validates_with DateRangeValidator + validates_with DurationValidator + validates_with PastPeriodValidator validates_with PeriodOverlapValidator validates_with PeriodIntegrityValidator @@ -61,11 +63,15 @@ class AccountingPeriod < ActiveRecord::Base first_rate = @vat_rates.first return first_rate[:rate] if date < first_rate[:date] - @vat_rates.each do |h| - return h[:rate] if h[:date] <= date + @vat_rates.each_index do |i| + return @vat_rates[i][:rate] if date >= @vat_rates[i][:date] && (@vat_rates[i + 1].nil? || date < @vat_rates[i + 1][:date]) end end + def previous_period + AccountingPeriod.where('closed_at < ?', closed_at).order(closed_at: :desc).limit(1).last + end + private def vat_history @@ -79,47 +85,8 @@ class AccountingPeriod < ActiveRecord::Base key_dates.sort_by { |k| k[:date] } end - def to_json_archive(invoices, previous_file, last_checksum) - code_checksum = Checksum.code - ApplicationController.new.view_context.render( - partial: 'archive/accounting', - locals: { - invoices: invoices_with_vat(invoices), - period_total: period_total, - perpetual_total: perpetual_total, - period_footprint: footprint, - code_checksum: code_checksum, - last_archive_checksum: last_checksum, - previous_file: previous_file, - software_version: Version.current, - date: Time.now.iso8601 - }, - formats: [:json], - handlers: [:jbuilder] - ) - end - - def previous_period - AccountingPeriod.where('closed_at < ?', closed_at).order(closed_at: :desc).limit(1).last - end - def archive_closed_data - data = invoices.includes(:invoice_items) - previous_file = previous_period&.archive_file - last_archive_checksum = previous_file ? Checksum.file(previous_file) : nil - json_data = to_json_archive(data, previous_file, last_archive_checksum) - current_archive_checksum = Checksum.text(json_data) - date = DateTime.iso8601 - chained = Checksum.text("#{current_archive_checksum}#{last_archive_checksum}#{date}") - - Zip::OutputStream.open(archive_file) do |io| - io.put_next_entry(archive_json_file) - io.write(json_data) - io.put_next_entry('checksum.sha256') - io.write("#{current_archive_checksum}\t#{archive_json_file}") - io.put_next_entry('chained.sha256') - io.write("#{chained}\t#{date}") - end + ArchiveWorker.perform_async(id) end def price_without_taxe(invoice) diff --git a/app/models/coupon.rb b/app/models/coupon.rb index 5208cabed..25855cf1e 100644 --- a/app/models/coupon.rb +++ b/app/models/coupon.rb @@ -16,6 +16,20 @@ class Coupon < ActiveRecord::Base validates_with CouponDiscountValidator validates_with CouponExpirationValidator + scope :disabled, -> { where(active: false) } + scope :expired, -> { where('valid_until IS NOT NULL AND valid_until < ?', DateTime.now) } + scope :sold_out, lambda { + joins(:invoices).select('coupons.*, COUNT(invoices.id) as invoices_count').group('coupons.id') + .where.not(max_usages: nil).having('COUNT(invoices.id) >= coupons.max_usages') + } + scope :active, lambda { + joins('LEFT OUTER JOIN invoices ON invoices.coupon_id = coupons.id') + .select('coupons.*, COUNT(invoices.id) as invoices_count') + .group('coupons.id') + .where('active = true AND (valid_until IS NULL OR valid_until >= ?)', DateTime.now) + .having('COUNT(invoices.id) < coupons.max_usages OR coupons.max_usages IS NULL') + } + def safe_destroy if invoices.size.zero? destroy diff --git a/app/models/export.rb b/app/models/export.rb index ce52ab867..32ab8ed68 100644 --- a/app/models/export.rb +++ b/app/models/export.rb @@ -1,3 +1,6 @@ +# frozen_string_literal: true + +# Export is a reference to a file asynchronously generated by the system and downloadable by the user class Export < ActiveRecord::Base require 'fileutils' @@ -13,25 +16,26 @@ class Export < ActiveRecord::Base dir = "exports/#{category}/#{export_type}" # create directories if they doesn't exists (exports & type & id) - FileUtils::mkdir_p dir - "#{dir}/#{self.filename}" + FileUtils.mkdir_p dir + "#{dir}/#{filename}" end def filename - "#{export_type}-#{self.id}_#{self.created_at.strftime('%d%m%Y')}.xlsx" + "#{export_type}-#{id}_#{created_at.strftime('%d%m%Y')}.xlsx" end private + def generate_and_send_export case category - when 'statistics' - StatisticsExportWorker.perform_async(self.id) - when 'users' - UsersExportWorker.perform_async(self.id) - when 'availabilities' - AvailabilitiesExportWorker.perform_async(self.id) - else - raise NoMethodError, "Unknown export service for #{category}/#{export_type}" + when 'statistics' + StatisticsExportWorker.perform_async(id) + when 'users' + UsersExportWorker.perform_async(id) + when 'availabilities' + AvailabilitiesExportWorker.perform_async(id) + else + raise NoMethodError, "Unknown export service for #{category}/#{export_type}" end end end diff --git a/app/models/notification_type.rb b/app/models/notification_type.rb index 200cbf65e..19ea34456 100644 --- a/app/models/notification_type.rb +++ b/app/models/notification_type.rb @@ -44,6 +44,7 @@ class NotificationType notify_member_reservation_reminder notify_admin_free_disk_space notify_admin_close_period_reminder + notify_admin_archive_complete ] # deprecated: # - notify_member_subscribed_plan_is_changed diff --git a/app/validators/duration_validator.rb b/app/validators/duration_validator.rb new file mode 100644 index 000000000..0fd4e8520 --- /dev/null +++ b/app/validators/duration_validator.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Validates that the duration between start_at and end_at is between 1 day and 1 year +class DurationValidator < ActiveModel::Validator + def validate(record) + the_end = record.end_at + the_start = record.start_at + diff = (the_end - the_start).to_i + # 0.day means that (the_start == the_end), so it's a one day period + return if diff.days >= 0.day && diff.days <= 1.year + + record.errors[:end_at] << I18n.t('errors.messages.invalid_duration', DAYS: diff) + end +end diff --git a/app/validators/past_period_validator.rb b/app/validators/past_period_validator.rb new file mode 100644 index 000000000..78ca9afe7 --- /dev/null +++ b/app/validators/past_period_validator.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Validates the current period is strictly in the past +class PastPeriodValidator < ActiveModel::Validator + def validate(record) + the_end = record.end_at + + return if the_end.present? && the_end < Date.today + + record.errors[:end_at] << I18n.t('errors.messages.must_be_in_the_past') + end +end diff --git a/app/views/api/accounting_periods/index.json.jbuilder b/app/views/api/accounting_periods/index.json.jbuilder index 3980cc0dd..a6e89e542 100644 --- a/app/views/api/accounting_periods/index.json.jbuilder +++ b/app/views/api/accounting_periods/index.json.jbuilder @@ -6,4 +6,5 @@ json.array!(@accounting_periods) do |ap| json.perpetual_total ap.perpetual_total / 100.0 json.chained_footprint ap.check_footprint json.user_name "#{ap.first_name} #{ap.last_name}" + json.archive_ready FileTest.exist?(ap.archive_file) end diff --git a/app/views/api/coupons/index.json.jbuilder b/app/views/api/coupons/index.json.jbuilder index e2c1763b5..83b001961 100644 --- a/app/views/api/coupons/index.json.jbuilder +++ b/app/views/api/coupons/index.json.jbuilder @@ -1,3 +1,6 @@ +# frozen_string_literal: true + json.array!(@coupons) do |coupon| json.partial! 'api/coupons/coupon', coupon: coupon + json.total @total end diff --git a/app/views/api/events/_event.json.jbuilder b/app/views/api/events/_event.json.jbuilder index 51098ee40..eb5434128 100644 --- a/app/views/api/events/_event.json.jbuilder +++ b/app/views/api/events/_event.json.jbuilder @@ -12,6 +12,7 @@ if event.category json.category do json.id event.category.id json.name event.category.name + json.slug event.category.slug end end json.event_theme_ids event.event_theme_ids diff --git a/app/views/api/notifications/_notify_admin_archive_complete.json.jbuilder b/app/views/api/notifications/_notify_admin_archive_complete.json.jbuilder new file mode 100644 index 000000000..aa1677d2d --- /dev/null +++ b/app/views/api/notifications/_notify_admin_archive_complete.json.jbuilder @@ -0,0 +1,7 @@ +json.title notification.notification_type +json.description t('.archive_complete', + START: notification.attached_object.start_at, + END: notification.attached_object.end_at, + ID: notification.attached_object.id + ) +json.url notification_url(notification, format: :json) diff --git a/app/views/notifications_mailer/notify_admin_archive_complete.html.erb b/app/views/notifications_mailer/notify_admin_archive_complete.html.erb new file mode 100644 index 000000000..98f0520b7 --- /dev/null +++ b/app/views/notifications_mailer/notify_admin_archive_complete.html.erb @@ -0,0 +1,12 @@ +<%= render 'notifications_mailer/shared/hello', recipient: @recipient %> + +

+ <%= t('.body.archive_complete', START: @attached_object.start_at, END: @attached_object.end_at) %> +

+

+ <%= t('.body.click_to_download') %> + <%=link_to( t('.body.here'), "#{root_url}api/accounting_periods/#{@attached_object.id}/archive", target: "_blank" )%> +

+

+ <%= t('.body.save_on_secured') %> +

diff --git a/app/workers/archive_worker.rb b/app/workers/archive_worker.rb new file mode 100644 index 000000000..8dc432b8a --- /dev/null +++ b/app/workers/archive_worker.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# Will generate a ZIP archive file containing all invoicing data for the given period. +# This file will be asynchronously generated by sidekiq and a notification will be sent to the requesting user when it's done. +class ArchiveWorker + include Sidekiq::Worker + + def perform(accounting_period_id) + period = AccountingPeriod.find(accounting_period_id) + + data = period.invoices.includes(:invoice_items).order(id: :asc) + previous_file = period.previous_period&.archive_file + last_archive_checksum = previous_file ? Checksum.file(previous_file) : nil + json_data = to_json_archive(period, data, previous_file, last_archive_checksum) + current_archive_checksum = Checksum.text(json_data) + date = DateTime.iso8601 + chained = Checksum.text("#{current_archive_checksum}#{last_archive_checksum}#{date}") + + Zip::OutputStream.open(period.archive_file) do |io| + io.put_next_entry(period.archive_json_file) + io.write(json_data) + io.put_next_entry('checksum.sha256') + io.write("#{current_archive_checksum}\t#{period.archive_json_file}") + io.put_next_entry('chained.sha256') + io.write("#{chained}\t#{date}") + end + + NotificationCenter.call type: :notify_admin_archive_complete, + receiver: User.find(period.closed_by), + attached_object: period + end + + private + + def to_json_archive(period, invoices, previous_file, last_checksum) + code_checksum = Checksum.code + ApplicationController.new.view_context.render( + partial: 'archive/accounting', + locals: { + invoices: period.invoices_with_vat(invoices), + period_total: period.period_total, + perpetual_total: period.perpetual_total, + period_footprint: period.footprint, + code_checksum: code_checksum, + last_archive_checksum: last_checksum, + previous_file: previous_file, + software_version: Version.current, + date: Time.now.iso8601 + }, + formats: [:json], + handlers: [:jbuilder] + ) + end +end diff --git a/app/workers/users_export_worker.rb b/app/workers/users_export_worker.rb index 0c0844d49..d069aee9a 100644 --- a/app/workers/users_export_worker.rb +++ b/app/workers/users_export_worker.rb @@ -1,27 +1,25 @@ +# frozen_string_literal: true + +# Will generate an excel file containing all the users. +# This file will be asynchronously generated by sidekiq and a notification will be sent to the requesting user when it's done. class UsersExportWorker include Sidekiq::Worker def perform(export_id) export = Export.find(export_id) - unless export.user.admin? - raise SecurityError, 'Not allowed to export' - end - - unless export.category == 'users' - raise KeyError, 'Wrong worker called' - end + raise SecurityError, 'Not allowed to export' unless export.user.admin? + raise KeyError, 'Wrong worker called' unless export.category == 'users' service = UsersExportService.new method_name = "export_#{export.export_type}" - if %w(members subscriptions reservations).include?(export.export_type) and service.respond_to?(method_name) - service.public_send(method_name, export) + return unless %w[members subscriptions reservations].include?(export.export_type) && service.respond_to?(method_name) - NotificationCenter.call type: :notify_admin_export_complete, - receiver: export.user, - attached_object: export - end + service.public_send(method_name, export) + NotificationCenter.call type: :notify_admin_export_complete, + receiver: export.user, + attached_object: export end end diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 1e96f1610..7d06301fa 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -221,10 +221,12 @@ en: nb_of_usages: "Number of usages" status: "Status" add_a_new_coupon: "Add a new coupon" + display_more_coupons: "Display the next coupons" disabled: "Disabled" expired: "Expired" sold_out: "Sold out" active: "Active" + all: "Display all" confirmation_required: "Confirmation required" do_you_really_want_to_delete_this_coupon: "Do you really want to delete this coupon?" coupon_was_successfully_deleted: "Coupon was successfully deleted." @@ -421,8 +423,10 @@ en: perpetual_total: "Perpetual total" integrity: "Integrity check" confirmation_required: "Confirmation required" - confirm_close_START_END: "Do you really want to close the accounting period between {{START}} and {{END}}? Any subsequent changes will be impossible. This operation will take some time to complete" - period_START_END_closed_success: "The accounting period from {{START}} to {{END}} has been successfully closed" + confirm_close_START_END: "Do you really want to close the accounting period between {{START}} and {{END}}? Any subsequent changes will be impossible." + period_must_match_fiscal_year: "A closing must occur at the end of a minimum annual period, or per financial year when it is not calendar-based." + this_may_take_a_while: "This operation will take some time to complete." + period_START_END_closed_success: "The accounting period from {{START}} to {{END}} has been successfully closed. Archive generation is running, you'll be notified when it's done." failed_to_close_period: "An error occurred, unable to close the accounting period" no_periods: "No closings for now" diff --git a/config/locales/app.admin.es.yml b/config/locales/app.admin.es.yml index fe4266d0e..b49436a60 100644 --- a/config/locales/app.admin.es.yml +++ b/config/locales/app.admin.es.yml @@ -221,10 +221,12 @@ es: nb_of_usages: "Número de usos" status: "Estado" add_a_new_coupon: "Añadir un nuevo cupón" + display_more_coupons: "Display the next coupons" # translation_missing disabled: "Desactivado" expired: "Expirado" sold_out: "Agotado" active: "Activo" + all: "Display all" # translation_missing confirmation_required: "Confirmación requerida" do_you_really_want_to_delete_this_coupon: "¿Desea realmente eliminar este cupón?" coupon_was_successfully_deleted: "El cupón se eliminó correctamente." @@ -421,8 +423,10 @@ es: perpetual_total: "Perpetual total" # translation_missing integrity: "Verificación de integridad" confirmation_required: "Confirmation required" # translation_missing - confirm_close_START_END: "Do you really want to close the accounting period between {{START}} and {{END}}? Any subsequent changes will be impossible. This operation will take some time to complete" # translation_missing - period_START_END_closed_success: "The accounting period from {{START}} to {{END}} has been successfully closed" # translation_missing + confirm_close_START_END: "Do you really want to close the accounting period between {{START}} and {{END}}? Any subsequent changes will be impossible." # translation_missing + period_must_match_fiscal_year: "A closing must occur at the end of a minimum annual period, or per financial year when it is not calendar-based." # translation_missing + this_may_take_a_while: "This operation will take some time to complete." # translation_missing + period_START_END_closed_success: "The accounting period from {{START}} to {{END}} has been successfully closed. Archive generation is running, you'll be notified when it's done." # translation_missing failed_to_close_period: "An error occurred, unable to close the accounting period" # translation_missing no_periods: "No closings for now" # translation_missing diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 508e1c7ba..b7c35cbfa 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -221,10 +221,12 @@ fr: nb_of_usages: "Nombre d'utilisations" status: "Statut" add_a_new_coupon: "Ajouter un code promotionnel" + display_more_coupons: "Afficher les codes suivants" disabled: "Désactivé" expired: "Expiré" sold_out: "Épuisé" active: "Actif" + all: "Afficher tous" confirmation_required: "Confirmation requise" do_you_really_want_to_delete_this_coupon: "Êtes-vous sûr(e) de vouloir supprimer ce code promotionnel ?" coupon_was_successfully_deleted: "Le code promotionnel a bien été supprimé." @@ -421,8 +423,10 @@ fr: perpetual_total: "Total perpétuel" integrity: "Contrôle d'intégrité" confirmation_required: "Confirmation requise" - confirm_close_START_END: "Êtes-vous sur de vouloir clôturer la période comptable du {{START}} au {{END}} ? Toute modification ultérieure sera impossible. Cette opération va prendre un certain temps." - period_START_END_closed_success: "La période comptable du {{START}} au {{END}} a bien été clôturée" + confirm_close_START_END: "Êtes-vous sur de vouloir clôturer la période comptable du {{START}} au {{END}} ? Toute modification ultérieure sera impossible." + period_must_match_fiscal_year: "Une clôture doit intervenir à l'issue d'une période au minimum annuelle, ou par exercice lorsque celui-ci n'est pas calé sur l'année civile." + this_may_take_a_while: "Cette opération va prendre un certain temps." + period_START_END_closed_success: "La période comptable du {{START}} au {{END}} a bien été clôturée. La génération de l'archive est en cours, vous serez prévenu lorsque celle-ci sera terminée." failed_to_close_period: "Une erreur est survenue, impossible de clôturer la période comptable" no_periods: "Aucune clôture pour le moment" diff --git a/config/locales/app.admin.pt.yml b/config/locales/app.admin.pt.yml index 4d0f916a3..965fcc2fe 100755 --- a/config/locales/app.admin.pt.yml +++ b/config/locales/app.admin.pt.yml @@ -221,10 +221,12 @@ pt: nb_of_usages: "Número de usos" status: "Status" add_a_new_coupon: "Adicionar novo cupom" + display_more_coupons: "Display the next coupons" # translation_missing disabled: "Desabilitado" expired: "Expirado" sold_out: "Esgotado" active: "Ativo" + all: "Display all" # translation_missing confirmation_required: "Confirmação obrigatória" do_you_really_want_to_delete_this_coupon: "Você realmente deseja deletar este cupom?" coupon_was_successfully_deleted: "O cupom foi deletado com sucesso." @@ -421,8 +423,10 @@ pt: perpetual_total: "Perpetual total" # translation_missing integrity: "Verificação de integridade" confirmation_required: "Confirmation required" # translation_missing - confirm_close_START_END: "Do you really want to close the accounting period between {{START}} and {{END}}? Any subsequent changes will be impossible. This operation will take some time to complete." # translation_missing - period_START_END_closed_success: "The accounting period from {{START}} to {{END}} has been successfully closed" # translation_missing + confirm_close_START_END: "Do you really want to close the accounting period between {{START}} and {{END}}? Any subsequent changes will be impossible" # translation_missing + period_must_match_fiscal_year: "A closing must occur at the end of a minimum annual period, or per financial year when it is not calendar-based." # translation_missing + this_may_take_a_while: "This operation will take some time to complete." # translation_missing + period_START_END_closed_success: "The accounting period from {{START}} to {{END}} has been successfully closed. Archive generation is running, you'll be notified when it's done." # translation_missing failed_to_close_period: "An error occurred, unable to close the accounting period" # translation_missing no_periods: "No closings for now" # translation_missing diff --git a/config/locales/en.yml b/config/locales/en.yml index 0690a80e2..bbe5d44e8 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -41,6 +41,8 @@ en: in_closed_period: "can't be within a closed accounting period" invalid_footprint: "invoice's checksum is invalid" end_before_start: "The end date can't be before the start date. Pick a date after %{START}" + invalid_duration: "The allowed duration must be between 1 day and 1 year. Your period is %{DAYS} days long." + must_be_in_the_past: "The period must be strictly prior to today's date." activemodel: errors: @@ -314,6 +316,8 @@ en: notify_admin_close_period_reminder: warning_last_closed_period_over_1_year: "Please remind to periodically close your accounting periods. Last closed period ended at %{LAST_END}" warning_no_closed_periods: "Please remind to periodically close your accounting periods. You have to close periods from %{FIRST_DATE}" + notify_admin_archive_complete: + archive_complete: "Data archiving from %{START} to %{END} is done. click here to download. Remember to save it on an external secured media." statistics: # statistics tools for admins diff --git a/config/locales/es.yml b/config/locales/es.yml index 7b63df33d..2f8159387 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -41,6 +41,8 @@ es: in_closed_period: "can't be within a closed accounting period" # missing translation invalid_footprint: "invoice's checksum is invalid" # missing translation end_before_start: "The end date can't be before the start date. Pick a date after %{START}" # missing translation + invalid_duration: "The allowed duration must be between 1 day and 1 year. Your period is %{DAYS} days long." # missing translation + must_be_in_the_past: "The period must be strictly prior to today's date." # missing translation activemodel: errors: @@ -314,6 +316,8 @@ es: notify_admin_close_period_reminder: warning_last_closed_period_over_1_year: "Please remind to periodically close your accounting periods. Last closed period ended at %{LAST_END}" # missing translation warning_no_closed_periods: "Please remind to periodically close your accounting periods. You have to close periods from %{FIRST_DATE}" # missing translation + notify_admin_archive_complete: # missing translation + archive_complete: "Data archiving from %{START} to %{END} is done. click here to download. Remember to save it on an external secured media." # missing translation statistics: # statistics tools for admins diff --git a/config/locales/fr.yml b/config/locales/fr.yml index aedcbcee7..7f6aabbef 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -41,6 +41,8 @@ fr: in_closed_period: "ne peut pas être dans une période comptable fermée" invalid_footprint: "la somme de contrôle de la facture est invalide" end_before_start: "La date de fin ne peut pas être antérieure à la date de début. Choisissez une date après le %{START}" + invalid_duration: "La durée doit être comprise entre 1 jour et 1 an. Votre période dure %{DAYS} jours." + must_be_in_the_past: "La période doit être strictement antérieure à la date du jour." activemodel: errors: @@ -314,6 +316,8 @@ fr: notify_admin_close_period_reminder: warning_last_closed_period_over_1_year: "Pensez à clôturer régulièrement vos périodes comptables. Les comptes sont actuellement clôturés jusqu'au %{LAST_END}" warning_no_closed_periods: "Pensez à clôturer régulièrement vos périodes comptables. Vous devez clôturer des périodes depuis le %{FIRST_DATE}" + notify_admin_archive_complete: + archive_complete: "L'archivage des données du %{START} au %{END} est terminé. Cliquez ici pour la télécharger. Pensez à l'enregistrer sur un support externe sécurisé." statistics: # outil de statistiques pour les administrateurs diff --git a/config/locales/mails.en.yml b/config/locales/mails.en.yml index 580c74eab..cb7befdd0 100644 --- a/config/locales/mails.en.yml +++ b/config/locales/mails.en.yml @@ -286,5 +286,13 @@ en: warning_last_closed_period_over_1_year: "Please remind to periodically close your accounting periods. Last closed period ended at %{LAST_END}." warning_no_closed_periods: "Please remind to periodically close your accounting periods. You have to close periods from %{FIRST_DATE}." + notify_admin_archive_complete: + subject: "Archiving completed" + body: + archive_complete: "You have closed the accounting period from %{START} to %{END}. Archiving of data is now complete." + click_to_download: "To download the ZIP archive, click" + here: "here." + save_on_secured: "Remember that you must save this archive on a secured external support, which may be requested by the tax authorities during a check." + shared: hello: "Hello %{user_name}" diff --git a/config/locales/mails.es.yml b/config/locales/mails.es.yml index 5605007a1..2c7bf207d 100644 --- a/config/locales/mails.es.yml +++ b/config/locales/mails.es.yml @@ -285,5 +285,13 @@ es: warning_last_closed_period_over_1_year: "Please remind to periodically close your accounting periods. Last closed period ended at %{LAST_END}." warning_no_closed_periods: "Please remind to periodically close your accounting periods. You have to close periods from %{FIRST_DATE}." + notify_admin_archive_complete: #translation_missing + subject: "Archiving completed" + body: + archive_complete: "You have closed the accounting period from %{START} to %{END}. Archiving of data is now complete." + click_to_download: "To download the ZIP archive, click" + here: "here." + save_on_secured: "Remember that you must save this archive on a secured external support, which may be requested by the tax authorities during a check." + shared: hello: "¡Hola %{user_name}!" diff --git a/config/locales/mails.fr.yml b/config/locales/mails.fr.yml index 25a85599b..6bd198b2e 100644 --- a/config/locales/mails.fr.yml +++ b/config/locales/mails.fr.yml @@ -286,5 +286,13 @@ fr: warning_last_closed_period_over_1_year: "Pensez à clôturer régulièrement vos périodes comptables. Les comptes sont actuellement clôturés jusqu'au %{LAST_END}." warning_no_closed_periods: "Pensez à clôturer régulièrement vos périodes comptables. Vous devez clôturer des périodes depuis le %{FIRST_DATE}." + notify_admin_archive_complete: + subject: "Archivage terminé" + body: + archive_complete: "Vous avez clôturé la période comptable du %{START} au %{END}. L'archivage des données est maintenant terminé." + click_to_download: "Pour télécharger l'archive ZIP, cliquez" + here: "ici." + save_on_secured: "N'oubliez pas que vous devez obligatoirement enregistrer cette archive sur un support externe sécurisé, qui peut vous être demandé par l'administration fiscale lors d'un contrôle." + shared: hello: "Bonjour %{user_name}" diff --git a/config/locales/mails.pt.yml b/config/locales/mails.pt.yml index 26df865fa..18ae430c5 100755 --- a/config/locales/mails.pt.yml +++ b/config/locales/mails.pt.yml @@ -286,5 +286,13 @@ pt: warning_last_closed_period_over_1_year: "Please remind to periodically close your accounting periods. Last closed period ended at %{LAST_END}." warning_no_closed_periods: "Please remind to periodically close your accounting periods. You have to close periods from %{FIRST_DATE}." + notify_admin_archive_complete: #translation_missing + subject: "Archiving completed" + body: + archive_complete: "You have closed the accounting period from %{START} to %{END}. Archiving of data is now complete." + click_to_download: "To download the ZIP archive, click" + here: "here." + save_on_secured: "Remember that you must save this archive on a secured external support, which may be requested by the tax authorities during a check." + shared: hello: "Olá %{user_name}" diff --git a/config/locales/pt.yml b/config/locales/pt.yml index 00ef49273..a6b0c4fd2 100755 --- a/config/locales/pt.yml +++ b/config/locales/pt.yml @@ -41,6 +41,8 @@ pt: in_closed_period: "can't be within a closed accounting period" # missing translation invalid_footprint: "invoice's checksum is invalid" # missing translation end_before_start: "The end date can't be before the start date. Pick a date after %{START}" # missing translation + invalid_duration: "The allowed duration must be between 1 day and 1 year. Your period is %{DAYS} days long." # missing translation + must_be_in_the_past: "The period must be strictly prior to today's date." # missing translation activemodel: errors: @@ -314,6 +316,8 @@ pt: notify_admin_close_period_reminder: warning_last_closed_period_over_1_year: "Please remind to periodically close your accounting periods. Last closed period ended at %{LAST_END}" # missing translation warning_no_closed_periods: "Please remind to periodically close your accounting periods. You have to close periods from %{FIRST_DATE}" # missing translation + notify_admin_archive_complete: # missing translation + archive_complete: "Data archiving from %{START} to %{END} is done. click here to download. Remember to save it on an external secured media." # missing translation statistics: # statistics tools for admins diff --git a/package.json b/package.json index 895f88204..f4d923d82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fab-manager", - "version": "3.0.1", + "version": "3.1.0", "description": "FabManager is the FabLab management solution. It is web-based, open-source and totally free.", "keywords": [ "fablab", diff --git a/test/integration/accounting_period_test.rb b/test/integration/accounting_period_test.rb index 1c0948673..9a4162c07 100644 --- a/test/integration/accounting_period_test.rb +++ b/test/integration/accounting_period_test.rb @@ -30,42 +30,67 @@ class AccountingPeriodTest < ActionDispatch::IntegrationTest assert_dates_equal end_at.to_date, period[:end_at] # Check archive file was created - assert FileTest.exists? accounting_period.archive_file - - # 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 'checksum' - sumfile = File.read("#{dest}/checksum.sha256").split("\t") - assert_equal sumfile[0], 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: start_at.to_datetime, end_date: 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) + assert_archive accounting_period end + test 'admin tries to close a too long period' do + start_at = '2012-01-01T00:00:00.000Z' + end_at = '2014-12-31T00:00:00.000Z' + diff = (end_at.to_date - start_at.to_date).to_i + + post '/api/accounting_periods', + { + accounting_period: { + start_at: start_at, + end_at: end_at + } + }.to_json, default_headers + + # Check response format & status + assert_equal 422, response.status, response.body + assert_equal Mime::JSON, response.content_type + + # check the error + assert_match(/#{I18n.t('errors.messages.invalid_duration', DAYS: diff)}/, response.body) + end + + test 'admin tries to close an overlapping period' do + start_at = '2014-12-01T00:00:00.000Z' + end_at = '2015-02-27T00:00:00.000Z' + + post '/api/accounting_periods', + { + accounting_period: { + start_at: start_at, + end_at: end_at + } + }.to_json, default_headers + + # Check response format & status + assert_equal 422, response.status, response.body + assert_equal Mime::JSON, response.content_type + + # check the error + assert_match(/#{I18n.t('errors.messages.cannot_overlap')}/, response.body) + end + + test 'admin tries to close today' do + start_at = Date.today.beginning_of_day.iso8601 + end_at = Date.today.end_of_day.iso8601 + + post '/api/accounting_periods', + { + accounting_period: { + start_at: start_at, + end_at: end_at + } + }.to_json, default_headers + + # Check response format & status + assert_equal 422, response.status, response.body + assert_equal Mime::JSON, response.content_type + + # check the error + assert_match(/#{I18n.t('errors.messages.must_be_in_the_past')}/, response.body) + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index f7c222f9d..9ce5eb8e7 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,20 +1,22 @@ +# frozen_string_literal: true + require 'coveralls' Coveralls.wear!('rails') ENV['RAILS_ENV'] ||= 'test' -require File.expand_path('../../config/environment', __FILE__) +require File.expand_path('../config/environment', __dir__) require 'rails/test_help' require 'vcr' require 'sidekiq/testing' require 'minitest/reporters' VCR.configure do |config| - config.cassette_library_dir = "test/vcr_cassettes" + config.cassette_library_dir = 'test/vcr_cassettes' config.hook_into :webmock end Sidekiq::Testing.fake! -Minitest::Reporters.use! [Minitest::Reporters::DefaultReporter.new({ color: true })] +Minitest::Reporters.use! [Minitest::Reporters::DefaultReporter.new(color: true)] @@ -33,30 +35,35 @@ class ActiveSupport::TestCase end def stripe_card_token(error: nil) - number = "4242424242424242" + number = '4242424242424242' exp_month = 4 exp_year = DateTime.now.next_year.year - cvc = "314" + cvc = '314' case error when /card_declined/ - number = "4000000000000002" + number = '4000000000000002' when /incorrect_number/ - number = "4242424242424241" + number = '4242424242424241' when /invalid_expiry_month/ exp_month = 15 when /invalid_expiry_year/ exp_year = 1964 when /invalid_cvc/ - cvc = "99" + cvc = '99' + else + number = (rand * 100_000).floor + exp_year = (rand * 1000).floor + cvc = (rand * 100).floor end - Stripe::Token.create(card: { - number: number, + Stripe::Token.create( + card: { + number: number, exp_month: exp_month, exp_year: exp_year, - cvc: cvc - }, + cvc: cvc + } ).id end @@ -85,13 +92,11 @@ class ActiveSupport::TestCase end # check that the VAT was correctly applied if it was configured - if line.include? I18n.t('invoices.including_total_excluding_taxes') - ht_amount = parse_amount_from_invoice_line(line) - end + ht_amount = parse_amount_from_invoice_line(line) if line.include? I18n.t('invoices.including_total_excluding_taxes') end if Setting.find_by(name: 'invoice_VAT-active').value == 'true' - vat_rate = Setting.find_by({name: 'invoice_VAT-rate'}).value.to_f + vat_rate = Setting.find_by(name: 'invoice_VAT-rate').value.to_f computed_ht = sprintf('%.2f', (invoice.total / (vat_rate / 100 + 1)) / 100.0).to_f assert_equal computed_ht, ht_amount, 'Total excluding taxes rendered in the PDF file is not computed correctly' @@ -101,7 +106,6 @@ class ActiveSupport::TestCase 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} @@ -120,6 +124,50 @@ 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 'checksum' + sumfile = File.read("#{dest}/checksum.sha256").split("\t") + assert_equal sumfile[0], 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