mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2024-12-01 12:24:28 +01:00
Merge branch 'dev' for release 3.1.0
This commit is contained in:
commit
cff2dddb2e
@ -24,3 +24,5 @@ Style/ClassAndModuleChildren:
|
||||
EnforcedStyle: compact
|
||||
Style/AndOr:
|
||||
EnforcedStyle: conditionals
|
||||
Style/FormatString:
|
||||
EnforcedStyle: sprintf
|
||||
|
13
CHANGELOG.md
13
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
|
||||
|
||||
|
@ -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') }
|
||||
)
|
||||
+ '<br/><br/><strong>'
|
||||
+ _t('invoices.period_must_match_fiscal_year')
|
||||
+ '</strong><br/><br/>'
|
||||
+ _t('invoices.this_may_take_a_while')
|
||||
)
|
||||
};
|
||||
}
|
||||
|
@ -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 */
|
||||
|
||||
/**
|
||||
|
@ -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; }]
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -29,7 +29,7 @@
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon"><i class="fa fa-calendar"></i></span>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
class="form-control as-writable"
|
||||
name="end_at"
|
||||
ng-model="period.end_at"
|
||||
uib-datepicker-popup="{{datePicker.format}}"
|
||||
@ -40,7 +40,8 @@
|
||||
init-date="period.end_at"
|
||||
placeholder="{{datePicker.format}}"
|
||||
ng-click="toggleDatePicker($event)"
|
||||
required/>
|
||||
required
|
||||
readonly/>
|
||||
</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 error" ng-show="errors.end_at">{{ errors.end_at[0] }}</span>
|
||||
@ -65,7 +66,8 @@
|
||||
<td>{{period.end_at | amDateFormat:'L'}}</td>
|
||||
<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="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>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
@ -1,6 +1,20 @@
|
||||
<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">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -27,4 +41,8 @@
|
||||
</td>
|
||||
</tr>
|
||||
</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>
|
||||
|
@ -97,7 +97,7 @@
|
||||
<h1 class="m-b">{{event.title}}</h1>
|
||||
</div>
|
||||
<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>
|
||||
<p class="event-description" ng-bind-html="event.description | simpleText | humanize : 500 | breakFilter"></p>
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -1,3 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Helpers methods about uploading files
|
||||
module UploadHelper
|
||||
|
||||
def delete_empty_dirs
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
14
app/validators/duration_validator.rb
Normal file
14
app/validators/duration_validator.rb
Normal 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
|
12
app/validators/past_period_validator.rb
Normal file
12
app/validators/past_period_validator.rb
Normal 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
|
@ -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
|
||||
|
@ -1,3 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.array!(@coupons) do |coupon|
|
||||
json.partial! 'api/coupons/coupon', coupon: coupon
|
||||
json.total @total
|
||||
end
|
||||
|
@ -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
|
||||
|
@ -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)
|
@ -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>
|
54
app/workers/archive_worker.rb
Normal file
54
app/workers/archive_worker.rb
Normal 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
|
@ -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
|
||||
|
@ -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"
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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"
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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. <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 tools for admins
|
||||
|
@ -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. <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 tools for admins
|
||||
|
@ -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é. <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:
|
||||
# outil de statistiques pour les administrateurs
|
||||
|
@ -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}"
|
||||
|
@ -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}!"
|
||||
|
@ -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}"
|
||||
|
@ -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}"
|
||||
|
@ -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. <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 tools for admins
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user