1
0
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:
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
Style/AndOr:
EnforcedStyle: conditionals
Style/FormatString:
EnforcedStyle: sprintf

View File

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

View File

@ -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')
)
};
}

View File

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

View File

@ -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; }]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,6 @@
# frozen_string_literal: true
# Helpers methods about uploading files
module UploadHelper
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
include NotifyWith::NotificationAttachedObject

View File

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

View File

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

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

View File

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

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.chained_footprint ap.check_footprint
json.user_name "#{ap.first_name} #{ap.last_name}"
json.archive_ready FileTest.exist?(ap.archive_file)
end

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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_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}"

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_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}!"

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_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}"

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_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}"

View File

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

View File

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

View File

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

View File

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