From 9fac706da8419d6492dd512df3eb96e6b1bad221 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 8 Jan 2019 17:32:45 +0100 Subject: [PATCH] validates accounting periods on creation + prevent refunding on closed periods (client only) --- .../controllers/admin/invoices.js.erb | 39 +++++++++++++++---- app/assets/javascripts/router.js.erb | 1 + app/assets/stylesheets/app.components.scss | 6 ++- .../admin/invoices/avoirModal.html.erb | 1 + .../admin/invoices/closePeriodModal.html.erb | 2 + app/models/accounting_period.rb | 8 ++++ app/validators/date_range_validator.rb | 12 ++++++ app/validators/period_overlap_validator.rb | 21 ++++++++++ config/locales/en.yml | 2 + config/locales/es.yml | 2 + config/locales/fr.yml | 4 +- config/locales/pt.yml | 2 + 12 files changed, 91 insertions(+), 9 deletions(-) create mode 100644 app/validators/date_range_validator.rb create mode 100644 app/validators/period_overlap_validator.rb diff --git a/app/assets/javascripts/controllers/admin/invoices.js.erb b/app/assets/javascripts/controllers/admin/invoices.js.erb index fd64f3a5f..d1c5751ac 100644 --- a/app/assets/javascripts/controllers/admin/invoices.js.erb +++ b/app/assets/javascripts/controllers/admin/invoices.js.erb @@ -17,8 +17,8 @@ /** * Controller used in the admin invoices listing page */ -Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'Invoice', 'AccountingPeriod', 'invoices', '$uibModal', 'growl', '$filter', 'Setting', 'settings', '_t', - function ($scope, $state, Invoice, AccountingPeriod, invoices, $uibModal, growl, $filter, Setting, settings, _t) { +Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'Invoice', 'AccountingPeriod', 'invoices', 'closedPeriods', '$uibModal', 'growl', '$filter', 'Setting', 'settings', '_t', + function ($scope, $state, Invoice, AccountingPeriod, invoices, closedPeriods, $uibModal, growl, $filter, Setting, settings, _t) { /* PRIVATE STATIC CONSTANTS */ // number of invoices loaded each time we click on 'load more...' @@ -110,7 +110,8 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I templateUrl: '<%= asset_path "admin/invoices/avoirModal.html" %>', controller: 'AvoirModalController', resolve: { - invoice () { return invoice; } + invoice () { return invoice; }, + closedPeriods() { return closedPeriods; } } }); @@ -387,6 +388,10 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I return invoiceSearch(true); }; + /** + * Open a modal allowing the user to close an accounting period and to + * view all periods already closed. + */ $scope.closeAnAccountingPeriod = function() { // open modal $uibModal.open({ @@ -394,7 +399,7 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I controller: 'ClosePeriodModalController', size: 'lg', resolve: { - periods() { return AccountingPeriod.query().$promise; }, + periods() { return closedPeriods; }, lastClosingEnd() { return AccountingPeriod.lastClosingEnd().$promise; }, } }); @@ -509,8 +514,8 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I /** * Controller used in the invoice refunding modal window */ -Application.Controllers.controller('AvoirModalController', ['$scope', '$uibModalInstance', 'invoice', 'Invoice', 'growl', '_t', - function ($scope, $uibModalInstance, invoice, Invoice, growl, _t) { +Application.Controllers.controller('AvoirModalController', ['$scope', '$uibModalInstance', 'invoice', 'closedPeriods', 'Invoice', 'growl', '_t', + function ($scope, $uibModalInstance, invoice, closedPeriods, Invoice, growl, _t) { /* PUBLIC SCOPE */ // invoice linked to the current refund @@ -589,6 +594,20 @@ Application.Controllers.controller('AvoirModalController', ['$scope', '$uibModal */ $scope.cancel = function () { $uibModalInstance.dismiss('cancel'); }; + /** + * Test if the given date is within a closed accounting period + * @param date {Date} date to test + * @returns {boolean} true if closed, false otherwise + */ + $scope.isDateClosed = function(date) { + for (const period of closedPeriods) { + if (moment(date).isBetween(moment.utc(period.start_at).startOf('day'), moment.utc(period.end_at).endOf('day'), null, '[]')) { + return true; + } + } + return false; + } + /* PRIVATE SCOPE */ /** @@ -624,11 +643,17 @@ Application.Controllers.controller('ClosePeriodModalController', ['$scope', '$ui const LAST_CLOSING = moment.utc(lastClosingEnd.last_end_date).toDate(); /* PUBLIC SCOPE */ + + // date pickers values are bound to these variables $scope.period = { start_at: LAST_CLOSING, end_at: YESTERDAY }; + // any form errors will come here + $scope.errors = {}; + + // existing closed periods, provided by the API $scope.accountingPeriods = periods; // AngularUI-Bootstrap datepickers parameters to define the period to close @@ -681,7 +706,7 @@ Application.Controllers.controller('ClosePeriodModalController', ['$scope', '$ui } , function(error) { growl.error(_t('invoices.failed_to_close_period')); - console.error(error); + $scope.errors = error.data; }); } ); diff --git a/app/assets/javascripts/router.js.erb b/app/assets/javascripts/router.js.erb index d0a256845..95bd0f3eb 100644 --- a/app/assets/javascripts/router.js.erb +++ b/app/assets/javascripts/router.js.erb @@ -885,6 +885,7 @@ angular.module('application.router', ['ui.router']) query: { number: '', customer: '', date: null, order_by: '-reference', page: 1, size: 20 } }).$promise; }], + closedPeriods: [ 'AccountingPeriod', function(AccountingPeriod) { return AccountingPeriod.query().$promise; }], translations: ['Translations', function (Translations) { return Translations.query('app.admin.invoices').$promise; }] } }) diff --git a/app/assets/stylesheets/app.components.scss b/app/assets/stylesheets/app.components.scss index 66fffacab..f04f147b6 100644 --- a/app/assets/stylesheets/app.components.scss +++ b/app/assets/stylesheets/app.components.scss @@ -616,4 +616,8 @@ padding: 10px; & > i.fileinput-exists { margin-right: 5px; } -} \ No newline at end of file +} + +.help-block.error { + color: #ff565d; +} diff --git a/app/assets/templates/admin/invoices/avoirModal.html.erb b/app/assets/templates/admin/invoices/avoirModal.html.erb index 348c704dc..4dfae71b1 100644 --- a/app/assets/templates/admin/invoices/avoirModal.html.erb +++ b/app/assets/templates/admin/invoices/avoirModal.html.erb @@ -14,6 +14,7 @@ uib-datepicker-popup="{{datePicker.format}}" datepicker-options="datePicker.options" is-open="datePicker.opened" + date-disabled="isDateClosed(date, mode)" placeholder="{{datePicker.format}}" ng-click="openDatePicker($event)" required/> diff --git a/app/assets/templates/admin/invoices/closePeriodModal.html.erb b/app/assets/templates/admin/invoices/closePeriodModal.html.erb index 9ba1514b4..da3e6d6c8 100644 --- a/app/assets/templates/admin/invoices/closePeriodModal.html.erb +++ b/app/assets/templates/admin/invoices/closePeriodModal.html.erb @@ -22,6 +22,7 @@ required/> {{ 'invoices.start_date_is_required' }} + {{ errors.start_at[0] }}
@@ -42,6 +43,7 @@ required/>
{{ 'invoices.end_date_is_required' }} + {{ errors.end_at[0] }}
diff --git a/app/models/accounting_period.rb b/app/models/accounting_period.rb index 824e5e3f4..08d3817b5 100644 --- a/app/models/accounting_period.rb +++ b/app/models/accounting_period.rb @@ -1,7 +1,15 @@ +# frozen_string_literal: true + +# AccountingPeriod is a period of N days (N > 0) which as been closed by an admin +# to prevent writing new accounting lines (invoices & refunds) during this period of time. class AccountingPeriod < ActiveRecord::Base before_destroy { false } before_update { false } + validates :start_at, :end_at, :closed_at, :closed_by, presence: true + validates_with DateRangeValidator + validates_with PeriodOverlapValidator + def delete false end diff --git a/app/validators/date_range_validator.rb b/app/validators/date_range_validator.rb new file mode 100644 index 000000000..2b8d64802 --- /dev/null +++ b/app/validators/date_range_validator.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Validates that start_at is same or before end_at in the given record +class DateRangeValidator < ActiveModel::Validator + def validate(record) + the_end = record.start_at + the_start = record.end_at + return unless the_end.present? && the_end >= the_start + + record.errors[:end_at] << "The end date can't be before the start date. Pick a date after #{the_start}" + end +end diff --git a/app/validators/period_overlap_validator.rb b/app/validators/period_overlap_validator.rb new file mode 100644 index 000000000..8d718c9a8 --- /dev/null +++ b/app/validators/period_overlap_validator.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Validates the current accounting period does not overlap an existing one +class PeriodOverlapValidator < ActiveModel::Validator + def validate(record) + the_end = record.end_at + the_start = record.start_at + + AccountingPeriod.all.each do |period| + if the_start >= period.start_at && the_start <= period.end_at + record.errors[:start_at] << I18n.t('errors.messages.cannot_overlap') + end + if the_end >= period.start_at && the_end <= period.end_at + record.errors[:end_at] << I18n.t('errors.messages.cannot_overlap') + end + if period.start_at >= the_start && period.end_at <= the_end + record.errors[:end_at] << I18n.t('errors.messages.cannot_encompass') + end + end + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 92460e907..a5abce9c4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -36,6 +36,8 @@ en: cannot_be_blank_at_same_time: "cannot be blank when %{field} is blank too" cannot_be_in_the_past: "cannot be in the past" cannot_be_before_previous_value: "cannot be before the previous value" + cannot_overlap: "can't overlap an existing accounting period" + cannot_encompass: "can't encompass an existing accounting period" activemodel: errors: diff --git a/config/locales/es.yml b/config/locales/es.yml index 1deaafb3d..9fe95ad1f 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -36,6 +36,8 @@ es: cannot_be_blank_at_same_time: "no puede estar vacío cuando %{field} también está vacío" cannot_be_in_the_past: "no puede estar en el pasado" cannot_be_before_previous_value: "No puede estar antes del valor anterior." + cannot_overlap: "can't overlap an existing accounting period" # missing translation + cannot_encompass: "can't encompass an existing accounting period" # missing translation activemodel: errors: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index aa9b8c0e8..857c38fef 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -36,6 +36,8 @@ fr: cannot_be_blank_at_same_time: "ou %{field} doit être rempli(e)" cannot_be_in_the_past: "ne peut pas être dans le passé" cannot_be_before_previous_value: "ne peut pas être antérieur(e) à la valeur précédente" + cannot_overlap: "ne peut pas chevaucher une période comptable existante" + cannot_encompass: "ne peut pas englober une période comptable existante" activemodel: errors: @@ -360,4 +362,4 @@ fr: group: # nom du groupe utilisateur pour les administrateurs - admins: 'Administrateurs' \ No newline at end of file + admins: 'Administrateurs' diff --git a/config/locales/pt.yml b/config/locales/pt.yml index badf770bb..9a645187b 100755 --- a/config/locales/pt.yml +++ b/config/locales/pt.yml @@ -36,6 +36,8 @@ pt: cannot_be_blank_at_same_time: "Não pode ficar em branco quando %{field} estiver em branco também" cannot_be_in_the_past: "não pode ser no passado" cannot_be_before_previous_value: "não pode ser antes do valor anterior" + cannot_overlap: "can't overlap an existing accounting period" # missing translation + cannot_encompass: "can't encompass an existing accounting period" # missing translation activemodel: errors: