1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-03-01 23:29:23 +01:00

Merge branch 'dev' for release 3.1.0

This commit is contained in:
Sylvain 2019-04-08 12:40:58 +02:00
commit cff2dddb2e
42 changed files with 576 additions and 249 deletions

View File

@ -24,3 +24,5 @@ Style/ClassAndModuleChildren:
EnforcedStyle: compact EnforcedStyle: compact
Style/AndOr: Style/AndOr:
EnforcedStyle: conditionals EnforcedStyle: conditionals
Style/FormatString:
EnforcedStyle: sprintf

View File

@ -1,4 +1,15 @@
# Changelog Fab Manager # 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 ## v3.0.1 2019 April 1st
- Insert archive generation datetime in chained.sha256 - Insert archive generation datetime in chained.sha256
@ -20,6 +31,7 @@
- Rebranded product from "La Casemate" - Rebranded product from "La Casemate"
- Refactored some pieces of Ruby code, according to style guide - Refactored some pieces of Ruby code, according to style guide
- Added asterisks on required fields in sign-up form - 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] (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] 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` - [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_invoices_records`
- [TODO DEPLOY] `rake fablab:setup:chain_history_values_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 `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 ## v2.8.4 2019 March 18

View File

@ -419,6 +419,8 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
$uibModal.open({ $uibModal.open({
templateUrl: '<%= asset_path "admin/invoices/closePeriodModal.html" %>', templateUrl: '<%= asset_path "admin/invoices/closePeriodModal.html" %>',
controller: 'ClosePeriodModalController', controller: 'ClosePeriodModalController',
backdrop: 'static',
keyboard: false,
size: 'lg', size: 'lg',
resolve: { resolve: {
periods() { return AccountingPeriod.query().$promise; }, 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 * 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', Application.Controllers.controller('ClosePeriodModalController', ['$scope', '$uibModalInstance', '$window', '$sce', 'Invoice', 'AccountingPeriod', 'periods', 'lastClosingEnd','dialogs', 'growl', '_t',
function ($scope, $uibModalInstance, $window, 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 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 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(); 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 () { object () {
return { return {
title: _t('invoices.confirmation_required'), title: _t('invoices.confirmation_required'),
msg: _t( msg: $sce.trustAsHtml(
'invoices.confirm_close_START_END', _t(
{ START: moment.utc($scope.period.start_at).format('LL'), END: moment.utc($scope.period.end_at).format('LL') } 'invoices.confirm_close_START_END',
{ START: moment.utc($scope.period.start_at).format('LL'), END: moment.utc($scope.period.end_at).format('LL') }
)
+ '<br/><br/><strong>'
+ _t('invoices.period_must_match_fiscal_year')
+ '</strong><br/><br/>'
+ _t('invoices.this_may_take_a_while')
) )
}; };
} }

View File

@ -53,6 +53,7 @@ Application.Controllers.controller('EditPricingController', ['$scope', '$state',
// List of coupons // List of coupons
$scope.coupons = couponsPromise; $scope.coupons = couponsPromise;
$scope.couponsPage = 1;
// List of spaces // List of spaces
$scope.spaces = spacesPromise; $scope.spaces = spacesPromise;
@ -81,6 +82,20 @@ Application.Controllers.controller('EditPricingController', ['$scope', '$state',
'all' '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) { $scope.findTrainingsPricing = function (trainingsPricings, trainingId, groupId) {
for (let trainingsPricing of Array.from(trainingsPricings)) { for (let trainingsPricing of Array.from(trainingsPricings)) {
if ((trainingsPricing.training_id === trainingId) && (trainingsPricing.group_id === groupId)) { 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 */ /* PRIVATE SCOPE */
/** /**

View File

@ -786,7 +786,7 @@ angular.module('application.router', ['ui.router'])
machineCreditsPromise: ['Credit', function (Credit) { return Credit.query({ creditable_type: 'Machine' }).$promise; }], machineCreditsPromise: ['Credit', function (Credit) { return Credit.query({ creditable_type: 'Machine' }).$promise; }],
machinesPromise: ['Machine', function (Machine) { return Machine.query().$promise; }], machinesPromise: ['Machine', function (Machine) { return Machine.query().$promise; }],
trainingCreditsPromise: ['Credit', function (Credit) { return Credit.query({ creditable_type: 'Training' }).$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; }], spacesPromise: ['Space', function (Space) { return Space.query().$promise; }],
spacesPricesPromise: ['Price', function (Price) { return Price.query({ priceable_type: 'Space', plan_id: 'null' }).$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; }] spacesCreditsPromise: ['Credit', function (Credit) { return Credit.query({ creditable_type: 'Space' }).$promise; }]

View File

@ -204,6 +204,10 @@ table.closings-table {
margin-left: 2em; margin-left: 2em;
cursor: pointer; cursor: pointer;
} }
& > span.no-pointer {
cursor: default;
}
} }
tbody .show-more { tbody .show-more {
@ -268,3 +272,7 @@ table.scrollable-3-cols {
.period-info-title { .period-info-title {
font-weight: bold; font-weight: bold;
} }
input.form-control.as-writable {
background-color: white;
}

View File

@ -29,7 +29,7 @@
<div class="input-group"> <div class="input-group">
<span class="input-group-addon"><i class="fa fa-calendar"></i></span> <span class="input-group-addon"><i class="fa fa-calendar"></i></span>
<input type="text" <input type="text"
class="form-control" class="form-control as-writable"
name="end_at" name="end_at"
ng-model="period.end_at" ng-model="period.end_at"
uib-datepicker-popup="{{datePicker.format}}" uib-datepicker-popup="{{datePicker.format}}"
@ -40,7 +40,8 @@
init-date="period.end_at" init-date="period.end_at"
placeholder="{{datePicker.format}}" placeholder="{{datePicker.format}}"
ng-click="toggleDatePicker($event)" ng-click="toggleDatePicker($event)"
required/> required
readonly/>
</div> </div>
<span class="help-block" ng-show="closePeriodForm.end_at.$dirty && closePeriodForm.end_at.$error.required" translate>{{ 'invoices.end_date_is_required' }}</span> <span class="help-block" ng-show="closePeriodForm.end_at.$dirty && closePeriodForm.end_at.$error.required" translate>{{ 'invoices.end_date_is_required' }}</span>
<span class="help-block error" ng-show="errors.end_at">{{ errors.end_at[0] }}</span> <span class="help-block error" ng-show="errors.end_at">{{ errors.end_at[0] }}</span>
@ -65,7 +66,8 @@
<td>{{period.end_at | amDateFormat:'L'}}</td> <td>{{period.end_at | amDateFormat:'L'}}</td>
<td class="actions"> <td class="actions">
<span class="show-more" uib-popover-template="'<%= asset_path 'admin/invoices/_period.html' %>'"><i class="fa fa-info-circle"></i></span> <span class="show-more" uib-popover-template="'<%= asset_path 'admin/invoices/_period.html' %>'"><i class="fa fa-info-circle"></i></span>
<span class="download-archive" ng-click="downloadArchive(period)"><i class="fa fa-archive"></i></span> <span class="download-archive" ng-click="downloadArchive(period)" ng-show="period.archive_ready"><i class="fa fa-archive"></i></span>
<span class="no-pointer" ng-hide="period.archive_ready"><i class="fa fa-spinner fa-pulse"></i></span>
</td> </td>
</tr> </tr>
</tbody> </tbody>

View File

@ -1,6 +1,20 @@
<h2 translate>{{ 'pricing.list_of_the_coupons' }}</h2> <h2 translate>{{ 'pricing.list_of_the_coupons' }}</h2>
<button type="button" class="btn btn-warning m-t-lg m-b" ui-sref="app.admin.coupons_new" translate>{{ 'pricing.add_a_new_coupon' }}</button> <div class="m-t-lg m-b">
<button type="button" class="btn btn-warning" ui-sref="app.admin.coupons_new">
<i class="fa fa-plus m-r"></i>
<span translate>{{ 'pricing.add_a_new_coupon' }}</span>
</button>
<div class="form-group pull-right">
<div class="input-group">
<span class="input-group-addon"><i class="fa fa-filter"></i></span>
<select ng-model="filter.coupon" class="form-control" ng-change="updateCouponFilter()">
<option ng-repeat="status in couponStatus" value="{{status}}" translate>{{ 'pricing.'+status }}</option>
</select>
</div>
</div>
</div>
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
@ -27,4 +41,8 @@
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="text-center">
<button class="btn btn-warning" ng-click="loadMore()" ng-hide="coupons.length === 0 || coupons.length >= coupons[0].total"><i class="fa fa-search-plus" aria-hidden="true"></i> {{ 'pricing.display_more_coupons' | translate }}</button>
</div>

View File

@ -97,7 +97,7 @@
<h1 class="m-b">{{event.title}}</h1> <h1 class="m-b">{{event.title}}</h1>
</div> </div>
<div class="col-xs-3"> <div class="col-xs-3">
<span class="v-middle badge text-xs" ng-class="'bg-{{event.category.name | lowercase}}'">{{event.category.name}}</span> <span class="v-middle badge text-xs" ng-class="'bg-{{event.category.slug}}'">{{event.category.name}}</span>
</div> </div>
</div> </div>
<p class="event-description" ng-bind-html="event.description | simpleText | humanize : 500 | breakFilter"></p> <p class="event-description" ng-bind-html="event.description | simpleText | humanize : 500 | breakFilter"></p>

View File

@ -6,8 +6,12 @@ class API::CouponsController < API::ApiController
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_coupon, only: %i[show update destroy] 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 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 end
def show; end def show; end

View File

@ -1,67 +1,70 @@
# frozen_string_literal: true
# Various helpers methods
module ApplicationHelper module ApplicationHelper
include Twitter::Autolink include Twitter::Autolink
require 'message_format' require 'message_format'
## machine/spaces availabilities are divided in multiple slots of 60 minutes ## machine/spaces availabilities are divided in multiple slots of 60 minutes
SLOT_DURATION ||= 60 SLOT_DURATION ||= 60
## ##
# Verify if the provided attribute is in the provided attributes array, whatever it exists or not # Verify if the provided attribute is in the provided attributes array, whatever it exists or not
# @param attributes {Array|nil} # @param attributes {Array|nil}
# @param attribute {String} # @param attribute {String}
## ##
def attribute_requested?(attributes, attribute) def attribute_requested?(attributes, attribute)
attributes.try(:include?, attribute) attributes.try(:include?, attribute)
end end
def bootstrap_class_for flash_type def bootstrap_class_for flash_type
{ flash: 'alert-success', alert: 'alert-danger', notice: 'alert-info' }[flash_type.to_sym] || flash_type.to_s { flash: 'alert-success', alert: 'alert-danger', notice: 'alert-info' }[flash_type.to_sym] || flash_type.to_s
end end
def flash_messages(opts = {}) def flash_messages(_opts = {})
flash.each do |msg_type, message| 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(: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 content_tag(:button, 'x', class: 'close', data: { dismiss: 'alert' })
concat message concat message
end) end)
end end
nil nil
end end
def print_slot(starting, ending) def print_slot(starting, ending)
"#{starting.strftime('%H:%M')} - #{ending.strftime('%H:%M')}" "#{starting.strftime('%H:%M')} - #{ending.strftime('%H:%M')}"
end end
def class_exists?(class_name) def class_exists?(class_name)
klass = Module.const_get(class_name) klass = Module.const_get(class_name)
return klass.is_a?(Class) klass.is_a?(Class)
rescue NameError rescue NameError
return false false
end end
## ##
# Allow to treat a rails i18n key as a MessageFormat interpolated pattern. Used in ruby views (API/mails) # 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 key {String} Ruby-on-Rails I18n key (from config/locales/xx.yml)
# @param interpolations {Hash} list of variables to interpolate, following ICU MessageFormat syntax # @param interpolations {Hash} list of variables to interpolate, following ICU MessageFormat syntax
## ##
def _t(key, interpolations) def _t(key, interpolations)
message = MessageFormat.new(I18n.t(scope_key_by_partial(key)), I18n.locale.to_s) message = MessageFormat.new(I18n.t(scope_key_by_partial(key)), I18n.locale.to_s)
text = message.format(interpolations) text = message.format(interpolations)
if html_safe_translation_key?(key) if html_safe_translation_key?(key)
text.html_safe text.html_safe
else else
text text
end end
end end
def bool_to_sym(bool) def bool_to_sym(bool)
if (bool) then return :true else return :false end bool ? :true : :false # rubocop:disable Lint/BooleanSymbol
end end
def amount_to_f(amount) def amount_to_f(amount)
amount / 100.00 amount / 100.00
end end
## ##
# Retrieve an item in the given array of items # 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 # this can be overridden by passing a third parameter to specify the
# property to match # property to match
## ##
def get_item(array, id, key = nil) def get_item(array, id, key = nil)
array.each do |i| array.each do |i|
if key.nil? if key.nil?
return i if i.id == id return i if i.id == id
else elsif i[key] == id
return i if i[key] == id return i
end end
end end
nil nil
end end
## ##
# Apply a correction for a future DateTime due to change in Daylight Saving Time (DST) period # Apply a correction for a future DateTime due to change in Daylight Saving Time (DST) period
# @param reference {ActiveSupport::TimeWithZone} # @param reference {ActiveSupport::TimeWithZone}
# @param datetime {DateTime} # @param datetime {DateTime}
# Inspired by https://stackoverflow.com/a/12065605 # Inspired by https://stackoverflow.com/a/12065605
## ##
def dst_correction(reference, datetime) def dst_correction(reference, datetime)
res = datetime.in_time_zone(reference.time_zone.tzinfo.name) res = datetime.in_time_zone(reference.time_zone.tzinfo.name)
res = res - 1.hour if res.dst? && !reference.dst? res -= 1.hour if res.dst? && !reference.dst?
res = res + 1.hour if reference.dst? && !res.dst? res += 1.hour if reference.dst? && !res.dst?
res res
end end
private 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
def html_safe_translation_key?(key) ## inspired by gems/actionview-4.2.5/lib/action_view/helpers/translation_helper.rb
key.to_s =~ /(\b|_|\.)html$/ def scope_key_by_partial(key)
end 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 end

View File

@ -1,3 +1,6 @@
# frozen_string_literal: true
# Helpers methods about calendar availabilities
module AvailabilityHelper module AvailabilityHelper
MACHINE_COLOR = '#e4cd78' MACHINE_COLOR = '#e4cd78'
TRAINING_COLOR = '#bd7ae9' TRAINING_COLOR = '#bd7ae9'
@ -9,14 +12,14 @@ module AvailabilityHelper
def availability_border_color(availability) def availability_border_color(availability)
case availability.available_type case availability.available_type
when 'machines' when 'machines'
MACHINE_COLOR MACHINE_COLOR
when 'training' when 'training'
TRAINING_COLOR TRAINING_COLOR
when 'space' when 'space'
SPACE_COLOR SPACE_COLOR
else else
EVENT_COLOR EVENT_COLOR
end end
end end
@ -45,14 +48,14 @@ module AvailabilityHelper
IS_COMPLETED IS_COMPLETED
else else
case availability.available_type case availability.available_type
when 'training' when 'training'
TRAINING_COLOR TRAINING_COLOR
when 'event' when 'event'
EVENT_COLOR EVENT_COLOR
when 'space' when 'space'
SPACE_COLOR SPACE_COLOR
else else
'#000' '#000'
end end
end end
end end

View File

@ -1,3 +1,6 @@
# frozen_string_literal: true
# Helpers methods about uploading files
module UploadHelper module UploadHelper
def delete_empty_dirs def delete_empty_dirs

View File

@ -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 class Abuse < ActiveRecord::Base
include NotifyWith::NotificationAttachedObject include NotifyWith::NotificationAttachedObject

View File

@ -10,10 +10,12 @@ class AccountingPeriod < ActiveRecord::Base
before_destroy { false } before_destroy { false }
before_update { false } before_update { false }
before_create :compute_totals 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 :start_at, :end_at, :closed_at, :closed_by, presence: true
validates_with DateRangeValidator validates_with DateRangeValidator
validates_with DurationValidator
validates_with PastPeriodValidator
validates_with PeriodOverlapValidator validates_with PeriodOverlapValidator
validates_with PeriodIntegrityValidator validates_with PeriodIntegrityValidator
@ -61,11 +63,15 @@ class AccountingPeriod < ActiveRecord::Base
first_rate = @vat_rates.first first_rate = @vat_rates.first
return first_rate[:rate] if date < first_rate[:date] return first_rate[:rate] if date < first_rate[:date]
@vat_rates.each do |h| @vat_rates.each_index do |i|
return h[:rate] if h[:date] <= date return @vat_rates[i][:rate] if date >= @vat_rates[i][:date] && (@vat_rates[i + 1].nil? || date < @vat_rates[i + 1][:date])
end end
end end
def previous_period
AccountingPeriod.where('closed_at < ?', closed_at).order(closed_at: :desc).limit(1).last
end
private private
def vat_history def vat_history
@ -79,47 +85,8 @@ class AccountingPeriod < ActiveRecord::Base
key_dates.sort_by { |k| k[:date] } key_dates.sort_by { |k| k[:date] }
end 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 def archive_closed_data
data = invoices.includes(:invoice_items) ArchiveWorker.perform_async(id)
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
end end
def price_without_taxe(invoice) def price_without_taxe(invoice)

View File

@ -16,6 +16,20 @@ class Coupon < ActiveRecord::Base
validates_with CouponDiscountValidator validates_with CouponDiscountValidator
validates_with CouponExpirationValidator 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 def safe_destroy
if invoices.size.zero? if invoices.size.zero?
destroy destroy

View File

@ -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 class Export < ActiveRecord::Base
require 'fileutils' require 'fileutils'
@ -13,25 +16,26 @@ class Export < ActiveRecord::Base
dir = "exports/#{category}/#{export_type}" dir = "exports/#{category}/#{export_type}"
# create directories if they doesn't exists (exports & type & id) # create directories if they doesn't exists (exports & type & id)
FileUtils::mkdir_p dir FileUtils.mkdir_p dir
"#{dir}/#{self.filename}" "#{dir}/#{filename}"
end end
def filename 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 end
private private
def generate_and_send_export def generate_and_send_export
case category case category
when 'statistics' when 'statistics'
StatisticsExportWorker.perform_async(self.id) StatisticsExportWorker.perform_async(id)
when 'users' when 'users'
UsersExportWorker.perform_async(self.id) UsersExportWorker.perform_async(id)
when 'availabilities' when 'availabilities'
AvailabilitiesExportWorker.perform_async(self.id) AvailabilitiesExportWorker.perform_async(id)
else else
raise NoMethodError, "Unknown export service for #{category}/#{export_type}" raise NoMethodError, "Unknown export service for #{category}/#{export_type}"
end end
end end
end end

View File

@ -44,6 +44,7 @@ class NotificationType
notify_member_reservation_reminder notify_member_reservation_reminder
notify_admin_free_disk_space notify_admin_free_disk_space
notify_admin_close_period_reminder notify_admin_close_period_reminder
notify_admin_archive_complete
] ]
# deprecated: # deprecated:
# - notify_member_subscribed_plan_is_changed # - notify_member_subscribed_plan_is_changed

View File

@ -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

View File

@ -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

View File

@ -6,4 +6,5 @@ json.array!(@accounting_periods) do |ap|
json.perpetual_total ap.perpetual_total / 100.0 json.perpetual_total ap.perpetual_total / 100.0
json.chained_footprint ap.check_footprint json.chained_footprint ap.check_footprint
json.user_name "#{ap.first_name} #{ap.last_name}" json.user_name "#{ap.first_name} #{ap.last_name}"
json.archive_ready FileTest.exist?(ap.archive_file)
end end

View File

@ -1,3 +1,6 @@
# frozen_string_literal: true
json.array!(@coupons) do |coupon| json.array!(@coupons) do |coupon|
json.partial! 'api/coupons/coupon', coupon: coupon json.partial! 'api/coupons/coupon', coupon: coupon
json.total @total
end end

View File

@ -12,6 +12,7 @@ if event.category
json.category do json.category do
json.id event.category.id json.id event.category.id
json.name event.category.name json.name event.category.name
json.slug event.category.slug
end end
end end
json.event_theme_ids event.event_theme_ids json.event_theme_ids event.event_theme_ids

View File

@ -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)

View File

@ -0,0 +1,12 @@
<%= render 'notifications_mailer/shared/hello', recipient: @recipient %>
<p>
<%= t('.body.archive_complete', START: @attached_object.start_at, END: @attached_object.end_at) %>
</p>
<p>
<%= t('.body.click_to_download') %>
<%=link_to( t('.body.here'), "#{root_url}api/accounting_periods/#{@attached_object.id}/archive", target: "_blank" )%>
</p>
<p>
<%= t('.body.save_on_secured') %>
</p>

View File

@ -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

View File

@ -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 class UsersExportWorker
include Sidekiq::Worker include Sidekiq::Worker
def perform(export_id) def perform(export_id)
export = Export.find(export_id) export = Export.find(export_id)
unless export.user.admin? raise SecurityError, 'Not allowed to export' unless export.user.admin?
raise SecurityError, 'Not allowed to export' raise KeyError, 'Wrong worker called' unless export.category == 'users'
end
unless export.category == 'users'
raise KeyError, 'Wrong worker called'
end
service = UsersExportService.new service = UsersExportService.new
method_name = "export_#{export.export_type}" method_name = "export_#{export.export_type}"
if %w(members subscriptions reservations).include?(export.export_type) and service.respond_to?(method_name) return unless %w[members subscriptions reservations].include?(export.export_type) && service.respond_to?(method_name)
service.public_send(method_name, export)
NotificationCenter.call type: :notify_admin_export_complete, service.public_send(method_name, export)
receiver: export.user,
attached_object: export
end
NotificationCenter.call type: :notify_admin_export_complete,
receiver: export.user,
attached_object: export
end end
end end

View File

@ -221,10 +221,12 @@ en:
nb_of_usages: "Number of usages" nb_of_usages: "Number of usages"
status: "Status" status: "Status"
add_a_new_coupon: "Add a new coupon" add_a_new_coupon: "Add a new coupon"
display_more_coupons: "Display the next coupons"
disabled: "Disabled" disabled: "Disabled"
expired: "Expired" expired: "Expired"
sold_out: "Sold out" sold_out: "Sold out"
active: "Active" active: "Active"
all: "Display all"
confirmation_required: "Confirmation required" confirmation_required: "Confirmation required"
do_you_really_want_to_delete_this_coupon: "Do you really want to delete this coupon?" do_you_really_want_to_delete_this_coupon: "Do you really want to delete this coupon?"
coupon_was_successfully_deleted: "Coupon was successfully deleted." coupon_was_successfully_deleted: "Coupon was successfully deleted."
@ -421,8 +423,10 @@ en:
perpetual_total: "Perpetual total" perpetual_total: "Perpetual total"
integrity: "Integrity check" integrity: "Integrity check"
confirmation_required: "Confirmation required" 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" confirm_close_START_END: "Do you really want to close the accounting period between {{START}} and {{END}}? Any subsequent changes will be impossible."
period_START_END_closed_success: "The accounting period from {{START}} to {{END}} has been successfully closed" 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" failed_to_close_period: "An error occurred, unable to close the accounting period"
no_periods: "No closings for now" no_periods: "No closings for now"

View File

@ -221,10 +221,12 @@ es:
nb_of_usages: "Número de usos" nb_of_usages: "Número de usos"
status: "Estado" status: "Estado"
add_a_new_coupon: "Añadir un nuevo cupón" add_a_new_coupon: "Añadir un nuevo cupón"
display_more_coupons: "Display the next coupons" # translation_missing
disabled: "Desactivado" disabled: "Desactivado"
expired: "Expirado" expired: "Expirado"
sold_out: "Agotado" sold_out: "Agotado"
active: "Activo" active: "Activo"
all: "Display all" # translation_missing
confirmation_required: "Confirmación requerida" confirmation_required: "Confirmación requerida"
do_you_really_want_to_delete_this_coupon: "¿Desea realmente eliminar este cupón?" do_you_really_want_to_delete_this_coupon: "¿Desea realmente eliminar este cupón?"
coupon_was_successfully_deleted: "El cupón se eliminó correctamente." coupon_was_successfully_deleted: "El cupón se eliminó correctamente."
@ -421,8 +423,10 @@ es:
perpetual_total: "Perpetual total" # translation_missing perpetual_total: "Perpetual total" # translation_missing
integrity: "Verificación de integridad" integrity: "Verificación de integridad"
confirmation_required: "Confirmation required" # translation_missing 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 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_START_END_closed_success: "The accounting period from {{START}} to {{END}} has been successfully closed" # 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 failed_to_close_period: "An error occurred, unable to close the accounting period" # translation_missing
no_periods: "No closings for now" # translation_missing no_periods: "No closings for now" # translation_missing

View File

@ -221,10 +221,12 @@ fr:
nb_of_usages: "Nombre d'utilisations" nb_of_usages: "Nombre d'utilisations"
status: "Statut" status: "Statut"
add_a_new_coupon: "Ajouter un code promotionnel" add_a_new_coupon: "Ajouter un code promotionnel"
display_more_coupons: "Afficher les codes suivants"
disabled: "Désactivé" disabled: "Désactivé"
expired: "Expiré" expired: "Expiré"
sold_out: "Épuisé" sold_out: "Épuisé"
active: "Actif" active: "Actif"
all: "Afficher tous"
confirmation_required: "Confirmation requise" confirmation_required: "Confirmation requise"
do_you_really_want_to_delete_this_coupon: "Êtes-vous sûr(e) de vouloir supprimer ce code promotionnel ?" 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é." coupon_was_successfully_deleted: "Le code promotionnel a bien été supprimé."
@ -421,8 +423,10 @@ fr:
perpetual_total: "Total perpétuel" perpetual_total: "Total perpétuel"
integrity: "Contrôle d'intégrité" integrity: "Contrôle d'intégrité"
confirmation_required: "Confirmation requise" 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." 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_START_END_closed_success: "La période comptable du {{START}} au {{END}} a bien été clôturée" 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" failed_to_close_period: "Une erreur est survenue, impossible de clôturer la période comptable"
no_periods: "Aucune clôture pour le moment" no_periods: "Aucune clôture pour le moment"

View File

@ -221,10 +221,12 @@ pt:
nb_of_usages: "Número de usos" nb_of_usages: "Número de usos"
status: "Status" status: "Status"
add_a_new_coupon: "Adicionar novo cupom" add_a_new_coupon: "Adicionar novo cupom"
display_more_coupons: "Display the next coupons" # translation_missing
disabled: "Desabilitado" disabled: "Desabilitado"
expired: "Expirado" expired: "Expirado"
sold_out: "Esgotado" sold_out: "Esgotado"
active: "Ativo" active: "Ativo"
all: "Display all" # translation_missing
confirmation_required: "Confirmação obrigatória" confirmation_required: "Confirmação obrigatória"
do_you_really_want_to_delete_this_coupon: "Você realmente deseja deletar este cupom?" do_you_really_want_to_delete_this_coupon: "Você realmente deseja deletar este cupom?"
coupon_was_successfully_deleted: "O cupom foi deletado com sucesso." coupon_was_successfully_deleted: "O cupom foi deletado com sucesso."
@ -421,8 +423,10 @@ pt:
perpetual_total: "Perpetual total" # translation_missing perpetual_total: "Perpetual total" # translation_missing
integrity: "Verificação de integridade" integrity: "Verificação de integridade"
confirmation_required: "Confirmation required" # translation_missing 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 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_START_END_closed_success: "The accounting period from {{START}} to {{END}} has been successfully closed" # 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 failed_to_close_period: "An error occurred, unable to close the accounting period" # translation_missing
no_periods: "No closings for now" # translation_missing no_periods: "No closings for now" # translation_missing

View File

@ -41,6 +41,8 @@ en:
in_closed_period: "can't be within a closed accounting period" in_closed_period: "can't be within a closed accounting period"
invalid_footprint: "invoice's checksum is invalid" 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}" 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: activemodel:
errors: errors:
@ -314,6 +316,8 @@ en:
notify_admin_close_period_reminder: 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_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}" 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. <a href='api/accounting_periods/%{ID}/archive' target='_blank'>click here to download</a>. Remember to save it on an external secured media."
statistics: statistics:
# statistics tools for admins # statistics tools for admins

View File

@ -41,6 +41,8 @@ es:
in_closed_period: "can't be within a closed accounting period" # missing translation in_closed_period: "can't be within a closed accounting period" # missing translation
invalid_footprint: "invoice's checksum is invalid" # 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 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: activemodel:
errors: errors:
@ -314,6 +316,8 @@ es:
notify_admin_close_period_reminder: 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_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 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. <a href='api/accounting_periods/%{ID}/archive' target='_blank'>click here to download</a>. Remember to save it on an external secured media." # missing translation
statistics: statistics:
# statistics tools for admins # statistics tools for admins

View File

@ -41,6 +41,8 @@ fr:
in_closed_period: "ne peut pas être dans une période comptable fermée" 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" 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}" 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: activemodel:
errors: errors:
@ -314,6 +316,8 @@ fr:
notify_admin_close_period_reminder: 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_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}" 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é. <a href='api/accounting_periods/%{ID}/archive' target='_blank'>Cliquez ici pour la télécharger</a>. Pensez à l'enregistrer sur un support externe sécurisé."
statistics: statistics:
# outil de statistiques pour les administrateurs # outil de statistiques pour les administrateurs

View File

@ -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_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}." 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: shared:
hello: "Hello %{user_name}" hello: "Hello %{user_name}"

View File

@ -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_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}." 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: shared:
hello: "¡Hola %{user_name}!" hello: "¡Hola %{user_name}!"

View File

@ -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_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}." 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: shared:
hello: "Bonjour %{user_name}" hello: "Bonjour %{user_name}"

View File

@ -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_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}." 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: shared:
hello: "Olá %{user_name}" hello: "Olá %{user_name}"

View File

@ -41,6 +41,8 @@ pt:
in_closed_period: "can't be within a closed accounting period" # missing translation in_closed_period: "can't be within a closed accounting period" # missing translation
invalid_footprint: "invoice's checksum is invalid" # 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 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: activemodel:
errors: errors:
@ -314,6 +316,8 @@ pt:
notify_admin_close_period_reminder: 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_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 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. <a href='api/accounting_periods/%{ID}/archive' target='_blank'>click here to download</a>. Remember to save it on an external secured media." # missing translation
statistics: statistics:
# statistics tools for admins # statistics tools for admins

View File

@ -1,6 +1,6 @@
{ {
"name": "fab-manager", "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.", "description": "FabManager is the FabLab management solution. It is web-based, open-source and totally free.",
"keywords": [ "keywords": [
"fablab", "fablab",

View File

@ -30,42 +30,67 @@ class AccountingPeriodTest < ActionDispatch::IntegrationTest
assert_dates_equal end_at.to_date, period[:end_at] assert_dates_equal end_at.to_date, period[:end_at]
# Check archive file was created # Check archive file was created
assert FileTest.exists? accounting_period.archive_file assert_archive accounting_period
# 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)
end 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 end

View File

@ -1,20 +1,22 @@
# frozen_string_literal: true
require 'coveralls' require 'coveralls'
Coveralls.wear!('rails') Coveralls.wear!('rails')
ENV['RAILS_ENV'] ||= 'test' ENV['RAILS_ENV'] ||= 'test'
require File.expand_path('../../config/environment', __FILE__) require File.expand_path('../config/environment', __dir__)
require 'rails/test_help' require 'rails/test_help'
require 'vcr' require 'vcr'
require 'sidekiq/testing' require 'sidekiq/testing'
require 'minitest/reporters' require 'minitest/reporters'
VCR.configure do |config| VCR.configure do |config|
config.cassette_library_dir = "test/vcr_cassettes" config.cassette_library_dir = 'test/vcr_cassettes'
config.hook_into :webmock config.hook_into :webmock
end end
Sidekiq::Testing.fake! 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 end
def stripe_card_token(error: nil) def stripe_card_token(error: nil)
number = "4242424242424242" number = '4242424242424242'
exp_month = 4 exp_month = 4
exp_year = DateTime.now.next_year.year exp_year = DateTime.now.next_year.year
cvc = "314" cvc = '314'
case error case error
when /card_declined/ when /card_declined/
number = "4000000000000002" number = '4000000000000002'
when /incorrect_number/ when /incorrect_number/
number = "4242424242424241" number = '4242424242424241'
when /invalid_expiry_month/ when /invalid_expiry_month/
exp_month = 15 exp_month = 15
when /invalid_expiry_year/ when /invalid_expiry_year/
exp_year = 1964 exp_year = 1964
when /invalid_cvc/ when /invalid_cvc/
cvc = "99" cvc = '99'
else
number = (rand * 100_000).floor
exp_year = (rand * 1000).floor
cvc = (rand * 100).floor
end end
Stripe::Token.create(card: { Stripe::Token.create(
number: number, card: {
number: number,
exp_month: exp_month, exp_month: exp_month,
exp_year: exp_year, exp_year: exp_year,
cvc: cvc cvc: cvc
}, }
).id ).id
end end
@ -85,13 +92,11 @@ class ActiveSupport::TestCase
end end
# check that the VAT was correctly applied if it was configured # 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) if line.include? I18n.t('invoices.including_total_excluding_taxes')
ht_amount = parse_amount_from_invoice_line(line)
end
end end
if Setting.find_by(name: 'invoice_VAT-active').value == 'true' 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 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' 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) File.delete(invoice.file)
end end
# Force the statistics export generation worker to run NOW and check the resulting file generated. # Force the statistics export generation worker to run NOW and check the resulting file generated.
# Delete the file afterwards. # Delete the file afterwards.
# @param export {Export} # @param export {Export}
@ -120,6 +124,50 @@ class ActiveSupport::TestCase
end end
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) def assert_dates_equal(expected, actual, msg = nil)
assert_not_nil actual, msg assert_not_nil actual, msg
assert_equal expected.to_date, actual.to_date, msg assert_equal expected.to_date, actual.to_date, msg