1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2024-11-29 10:24:20 +01:00

Merge branch 'dev' for release 4.2.0

This commit is contained in:
Sylvain 2019-10-21 15:30:48 +02:00
commit 7d9f42da8f
181 changed files with 4214 additions and 784 deletions

View File

@ -5,29 +5,50 @@ vendor/cache
config/database.yml
config/application.yml
# Ignore the default SQLite database.
# Ignore database files.
db/*.sqlite3
db/*.sqlite3-journal
postgresql
elasticsearch
redis
# Ignore all logfiles and tempfiles.
log
tmp
# Ignore public assets
public/uploads
public/assets
# Ignore all logfiles and tempfiles.
log
*.log
tmp
# Ignore platform dependent files
*.DS_Store
.idea
# PDF invoices
invoices
# Excel exports
exports
# CSV imports
imports
.DS_Store
# Development files
.vagrant
Vagrantfile
provision
.git*
Dockerfile
docker-compose*
test
# Docs
*.md
doc
# Modules
node_modules

3
.gitignore vendored
View File

@ -34,6 +34,9 @@
# XLSX exports
/exports/*
# CSV imports
/imports/*
# Archives of cLosed accounting periods
/accounting/*

View File

@ -1,9 +1,9 @@
Metrics/LineLength:
Max: 140
Metrics/MethodLength:
Max: 30
Max: 35
Metrics/CyclomaticComplexity:
Max: 9
Max: 13
Metrics/PerceivedComplexity:
Max: 9
Metrics/AbcSize:
@ -16,6 +16,8 @@ Metrics/BlockLength:
- 'lib/tasks/**/*.rake'
- 'config/routes.rb'
- 'app/pdfs/pdf/*.rb'
Metrics/ParameterLists:
CountKeywordArgs: false
Style/BracesAroundHashParameters:
EnforcedStyle: context_dependent
Style/RegexpLiteral:

View File

@ -1,10 +1,49 @@
# Changelog Fab Manager
## v4.1.1 2019 september 20
## v4.2.0 2019 October 21
- fix a bug: api/reservations#index was using user_id instead of statistic_profile_id
- fix a bug: event_service#date_range method, test on all_day was never truthy
- fix a bug: sidekiq 5 does not have delay_for method anymore, uses perform_in instead
- Upgraded PostgreSQL from 9.4 to 9.6
- Optional reCaptcha checkbox in sign-up form
- Ability to configure and export the accounting data to the ACD accounting software
- Compute the VAT per item in each invoices, instead of globally
- Use Alpine Linux to build the Docker image (#147)
- Updated omniauth & omniauth-oauth2 gems
- Ability to set project's CAO attachement maximum upload size
- Ability to bulk-import members from a CSV file
- Ability to disable invoices generation and interfaces
- Added a known issue to the README (#152)
- Ability to fully rebuild the projets index in ElasticSearch with rake fablab:es:build_projects_index
- Ability to configure SMTP connection to use SMTP/TLS
- Updated user's manual for v4.2 (fr)
- Fix a bug: invoices with total = 0, are marked as paid on site even if paid by card
- Fix a bug: after disabling a group, its associated plans are hidden from the interface
- Fix a bug: in case of unexpected server error during stripe payment process, the confirm button is not unlocked
- Fix a bug: create a plan does not set its name
- Fix a bug: unable to dissociate the last machine from a formation
- Fix a bug: in profile_complete form, the user's group is not selected by default
- Fix a bug: missing asterisks on some required fields in profile_complete form
- Fix a bug: public calendar won't show anything if the current date range include a reserved space availability (#151)
- Fix a bug: invoices list is not shown by default in "manage invoices" section
- Fix a bug: unable to run rake fablab:es:* tasks due to an issue with gem faraday 0.16.x (was updated to 0.17)
- Fix a bug: unauthorized user can see the edit project form
- Fix a bug: do not display each days in invoices for multiple days event reservation
- Fix a security issue: fixed [CVE-2015-9284](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2015-9284)
- [TODO DEPLOY] **IMPORTANT** Please read [postgres_upgrade.md](doc/postgres_upgrade.md) for instructions on upgrading PostgreSQL.
- [TODO DEPLOY] `rake db:migrate`
- [TODO DEPLOY] -> (only dev) `yarn install` and `bundle install`
- [TODO DEPLOY] -> (only dev) configure `DEFAULT_HOST: 'localhost:5000'` and `DEFAULT_PROTOCOL: http` in [application.yml](config/application.yml.default)
- [TODO DEPLOY] add the `RECAPTCHA_SITE_KEY` and `RECAPTCHA_SECRET_KEY` environment variables (see [doc/environment.md](doc/environment.md) for configuration details)
- [TODO DEPLOY] add the `MAX_CAO_SIZE` environment variable (see [doc/environment.md](doc/environment.md) for configuration details)
- [TODO DEPLOY] add the `MAX_IMPORT_SIZE` environment variable (see [doc/environment.md](doc/environment.md) for configuration details)
- [TODO DEPLOY] add `- ${PWD}/imports:/usr/src/app/imports` in the volumes list of your fabmanager service in [docker-compose.yml](docker/docker-compose.yml)
- [TODO DEPLOY] add the `FABLAB_WITHOUT_INVOICES` environment variable (see [doc/environment.md](doc/environment.md) for configuration details)
- [TODO DEPLOY] add the following environment variables: `SMTP_TLS`
## v4.1.1 2019 September 20
- Fix a bug: api/reservations#index was using user_id instead of statistic_profile_id
- Fix a bug: event_service#date_range method, test on all_day was never truthy
- Fix a bug: sidekiq 5 does not have delay_for method anymore, uses perform_in instead
## v4.1.0 2019 September 12

View File

@ -1,46 +1,62 @@
FROM ruby:2.3
FROM ruby:2.3.8-alpine
MAINTAINER peng@sleede.com
# First we need to be able to fetch from https repositories
RUN apt-get update && \
apt-get install -y apt-transport-https \
ca-certificates apt-utils
# Add sources for external tools to APT
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
curl -sSL https://deb.nodesource.com/gpgkey/nodesource.gpg.key | apt-key add - && \
echo "deb https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list && \
echo "deb https://deb.nodesource.com/node_10.x jessie main" > /etc/apt/sources.list.d/nodesource.list && \
echo "deb-src https://deb.nodesource.com/node_10.x jessie main" >> /etc/apt/sources.list.d/nodesource.list
# Install apt based dependencies required to run Rails as
# well as RubyGems. As the Ruby image itself is based on a
# Debian image, we use apt-get to install those.
RUN apt-get update && \
apt-get install -y \
# Install upgrade system packages
RUN apk update && apk upgrade && \
# Install runtime apk dependencies
apk add --update \
bash \
curl \
nodejs \
yarn \
imagemagick \
supervisor \
yarn
tzdata \
libc-dev \
ruby-dev \
zlib-dev \
xz-dev \
postgresql-dev \
libxml2-dev \
libxslt-dev \
libidn-dev && \
# Install buildtime apk dependencies
apk add --update --no-cache --virtual .build-deps \
alpine-sdk \
build-base \
linux-headers \
git \
patch
# throw errors if Gemfile has been modified since Gemfile.lock
# Throw error if Gemfile has been modified since Gemfile.lock
RUN bundle config --global frozen 1
# Run Bundle in a cache efficient way
# Install gems in a cache efficient way
WORKDIR /tmp
COPY Gemfile /tmp/
COPY Gemfile.lock /tmp/
RUN bundle install --binstubs
RUN bundle install --binstubs --without development test doc
# Install Javascript packages
WORKDIR /usr/src/app
COPY package.json /usr/src/app/package.json
COPY yarn.lock /usr/src/app/yarn.lock
RUN yarn install
# Clean up APT when done.
#RUN apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Clean up build deps, cached packages and temp files
RUN apk del .build-deps && \
yarn cache clean && \
rm -rf /tmp/* \
/var/tmp/* \
/var/cache/apk/* \
/usr/lib/ruby/gems/*/cache/*
# Web app
RUN mkdir -p /usr/src/app && \
mkdir -p /usr/src/app/config && \
mkdir -p /usr/src/app/invoices && \
mkdir -p /usr/src/app/exports && \
mkdir -p /usr/src/app/imports && \
mkdir -p /usr/src/app/log && \
mkdir -p /usr/src/app/public/uploads && \
mkdir -p /usr/src/app/public/assets && \
@ -48,32 +64,23 @@ RUN mkdir -p /usr/src/app && \
mkdir -p /usr/src/app/tmp/sockets && \
mkdir -p /usr/src/app/tmp/pids
WORKDIR /usr/src/app
COPY docker/database.yml /usr/src/app/config/database.yml
COPY package.json /usr/src/app/package.json
COPY yarn.lock /usr/src/app/yarn.lock
# Run Yarn
RUN yarn install
COPY . /usr/src/app
# Volumes
VOLUME /usr/src/app/invoices
VOLUME /usr/src/app/exports
VOLUME /usr/src/app/imports
VOLUME /usr/src/app/public
VOLUME /usr/src/app/public/uploads
VOLUME /usr/src/app/public/assets
VOLUME /usr/src/app/accounting
VOLUME /var/log/supervisor
# Expose port 3000 to the Docker host, so we can access it
# from the outside.
# Expose port 3000 to the Docker host, so we can access it from the outside
EXPOSE 3000
# The main command to run when the container starts. Also
# tell the Rails dev server to bind to all interfaces by
# default.
# The main command to run when the container starts. Also tell the Rails server
# to bind to all interfaces by default.
COPY docker/supervisor.conf /etc/supervisor/conf.d/fablab.conf
CMD ["/usr/bin/supervisord"]
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/fablab.conf"]

12
Gemfile
View File

@ -3,6 +3,8 @@ source 'https://rubygems.org'
gem 'compass-rails', '2.0.4'
# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
gem 'rails', '4.2.11.1'
# Use Puma as web server
gem 'puma', '3.10.0'
# Use SCSS for stylesheets
gem 'sass-rails', '5.0.1'
@ -45,7 +47,6 @@ group :development do
gem 'foreman'
# Preview mail in the browser
gem 'mailcatcher'
gem 'puma'
gem 'rb-readline'
end
@ -68,10 +69,11 @@ gem 'seed_dump'
gem 'pg'
gem 'devise', ">= 4.6.0"
gem 'devise', '>= 4.6.0'
gem 'omniauth', '~> 1.6.0'
gem 'omniauth', '~> 1.9.0'
gem 'omniauth-oauth2'
gem 'omniauth-rails_csrf_protection', '~> 0.1'
gem 'rolify'
@ -97,8 +99,8 @@ gem 'friendly_id', '~> 5.1.0'
gem 'aasm'
# Background job processing
gem 'sidekiq', '>= 3.4.2'
gem 'redis-namespace'
gem 'sidekiq', '>= 3.4.2'
gem 'sinatra', require: false
# Recurring jobs for Sidekiq
gem 'sidekiq-cron'
@ -148,3 +150,5 @@ gem 'rack-protection', '1.5.5'
gem 'sys-filesystem'
gem 'sha3'
gem 'repost'

View File

@ -159,7 +159,7 @@ GEM
execjs (2.7.0)
faker (1.4.3)
i18n (~> 0.5)
faraday (0.9.2)
faraday (0.17)
multipart-post (>= 1.2, < 3)
ffi (1.9.24)
figaro (1.1.0)
@ -180,7 +180,7 @@ GEM
activerecord (>= 3.0)
hashdiff (0.3.0)
hashery (2.1.2)
hashie (3.5.7)
hashie (3.6.0)
hike (1.2.3)
htmlentities (4.3.4)
http (3.0.0)
@ -209,7 +209,7 @@ GEM
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (1.8.6)
jwt (1.5.1)
jwt (2.2.1)
kaminari (0.16.3)
actionpack (>= 3.0.0)
activesupport (>= 3.0.0)
@ -243,8 +243,8 @@ GEM
minitest (>= 5.0)
ruby-progressbar
multi_json (1.13.1)
multi_xml (0.5.5)
multipart-post (2.0.0)
multi_xml (0.6.0)
multipart-post (2.1.1)
naught (1.1.0)
nokogiri (1.10.4)
mini_portile2 (~> 2.4.0)
@ -252,19 +252,22 @@ GEM
jbuilder (~> 2.0)
rails (>= 4.2.0)
responders (~> 2.0)
oauth2 (1.0.0)
faraday (>= 0.8, < 0.10)
jwt (~> 1.0)
oauth2 (1.4.2)
faraday (>= 0.8, < 2.0)
jwt (>= 1.0, < 3.0)
multi_json (~> 1.3)
multi_xml (~> 0.5)
rack (~> 1.2)
rack (>= 1.2, < 3)
oj (2.12.8)
omniauth (1.6.1)
hashie (>= 3.4.6, < 3.6.0)
omniauth (1.9.0)
hashie (>= 3.4.6, < 3.7.0)
rack (>= 1.6.2, < 3)
omniauth-oauth2 (1.3.1)
oauth2 (~> 1.0)
omniauth (~> 1.2)
omniauth-oauth2 (1.6.0)
oauth2 (~> 1.1)
omniauth (~> 1.9)
omniauth-rails_csrf_protection (0.1.2)
actionpack (>= 4.2)
omniauth (>= 1.3.1)
openlab_ruby (0.0.4)
httparty (~> 0.13)
orm_adapter (0.5.0)
@ -342,6 +345,7 @@ GEM
redis-namespace (1.6.0)
redis (>= 3.0.4)
ref (2.0.0)
repost (0.2.9)
responders (2.1.0)
railties (>= 4.2.0, < 5)
rolify (4.0.0)
@ -509,15 +513,16 @@ DEPENDENCIES
minitest-reporters
notify_with
oj
omniauth (~> 1.6.0)
omniauth (~> 1.9.0)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 0.1)
openlab_ruby
pdf-reader
pg
prawn
prawn-table
protected_attributes
puma
puma (= 3.10.0)
pundit
rack-protection (= 1.5.5)
railroady
@ -527,6 +532,7 @@ DEPENDENCIES
rb-readline
recurrence
redis-namespace
repost
responders (~> 2.0)
rolify
rubocop (~> 0.61.1)

View File

@ -1,3 +1,3 @@
web: bundle exec rails server puma -p $PORT -b0.0.0.0
worker: bundle exec sidekiq -C ./config/sidekiq.yml
mail: bundle exec mailcatcher --foreground
mail: bundle exec mailcatcher --foreground --http-ip=0.0.0.0

View File

@ -13,7 +13,7 @@ FabManager is the Fab Lab management solution. It provides a comprehensive, web-
4. [Setup a development environment](#setup-a-development-environment)<br/>
4.1. [General Guidelines](#general-guidelines)<br/>
5. [PostgreSQL](#postgresql)<br/>
5.1. [Install PostgreSQL 9.4](#setup-postgresql)
5.1. [Install PostgreSQL 9.6](#setup-postgresql)
6. [ElasticSearch](#elasticsearch)<br/>
6.1. [Install ElasticSearch](#setup-elasticsearch)<br/>
6.2. [Rebuild statistics](#rebuild-stats)<br/>
@ -43,7 +43,7 @@ FabManager is a Ruby on Rails / AngularJS web application that runs on the follo
- Redis 2.8.4+
- Sidekiq 3.3.4+
- Elasticsearch 5.6
- PostgreSQL 9.4
- PostgreSQL 9.6
<a name="contributing"></a>
## Contributing
@ -193,7 +193,7 @@ Optionally, you can use a virtual development environment that relies on Vagrant
## PostgreSQL
<a name="setup-postgresql"></a>
### Install PostgreSQL 9.4
### Install PostgreSQL 9.6
We will use docker to easily install the required version of PostgreSQL.
@ -208,7 +208,7 @@ We will use docker to easily install the required version of PostgreSQL.
-v $(pwd)/.docker/postgresql:/var/lib/postgresql/data \
--network fabmanager --ip 172.18.0.2 \
-p 5432:5432 \
postgres:9.4
postgres:9.6
```
3. Configure fab-manager to use it.
@ -432,6 +432,9 @@ Developers may find information on how to implement their own authentication pro
```bash
sudo systemctl restart elasticsearch.service
```
- In some cases, the invoices won't be generated. This can be due to the image included in the invoice header not being supported.
To fix this issue, change the image in the administrator interface (manage the invoices / invoices settings).
See [this thread](https://forum.fab-manager.com/t/resolu-erreur-generation-facture/428) for more info.
<a name="related-documentation"></a>
## Related Documentation

12
Vagrantfile vendored
View File

@ -20,6 +20,9 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.network "forwarded_port", guest: port, host: port
end
# nginx server
config.vm.network "forwarded_port", guest: 80, host: 8080
# Configuration to allocate resources fro the virtual machine
config.vm.provider 'virtualbox' do |vb|
vb.customize ['modifyvm', :id, '--memory', '2048']
@ -30,9 +33,16 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.synced_folder '.', '/vagrant', type: 'virtualbox'
# Copy default configuration files for the database conenction and the Rails application
config.vm.provision "file", source: "./config/database.yml.default", destination: "/vagrant/config/database.yml"
config.vm.provision "file", source: "./config/database.yml.default", destination: "/vagrant/config/database.yml"
config.vm.provision "file", source: "./config/application.yml.default", destination: "/vagrant/config/application.yml"
# Copy default configuration files to allow reviewing the Docker Compose integration
config.vm.provision "file", source: "./docker/docker-compose.yml", destination: "/home/vagrant/docker-compose.yml"
config.vm.provision "file", source: "./docker/env.example", destination: "/home/vagrant/config/env"
config.vm.provision "file", source: "./docker/nginx.conf.example", destination: "/home/vagrant/config/nginx/fabmanager.conf"
config.vm.provision "file", source: "./docker/elasticsearch.yml", destination: "/home/vagrant/elasticsearch/config/elasticsearch.yml"
config.vm.provision "file", source: "./docker/log4j2.properties", destination: "/home/vagrant/elasticsearch/config/log4j2.properties"
## Provision software dependencies
config.vm.provision "shell", privileged: false, run: "once",
path: "provision/zsh_setup.sh"

View File

@ -20,7 +20,7 @@ angular.module('application', ['ngCookies', 'ngResource', 'ngSanitize', 'ui.rout
'ui.select', 'ui.calendar', 'angularMoment', 'Devise', 'DeviseModal', 'angular-growl', 'xeditable',
'checklist-model', 'unsavedChanges', 'angular-loading-bar', 'ngTouch', 'angular-google-analytics',
'angularUtils.directives.dirDisqus', 'summernote', 'elasticsearch', 'angular-medium-editor', 'naif.base64',
'minicolors', 'pascalprecht.translate', 'ngFitText', 'ngAside', 'ngCapsLock'])
'minicolors', 'pascalprecht.translate', 'ngFitText', 'ngAside', 'ngCapsLock', 'vcRecaptcha'])
.config(['$httpProvider', 'AuthProvider', 'growlProvider', 'unsavedWarningsConfigProvider', 'AnalyticsProvider', 'uibDatepickerPopupConfig', '$provide', '$translateProvider',
function ($httpProvider, AuthProvider, growlProvider, unsavedWarningsConfigProvider, AnalyticsProvider, uibDatepickerPopupConfig, $provide, $translateProvider) {
// Google analytics
@ -84,6 +84,8 @@ angular.module('application', ['ngCookies', 'ngResource', 'ngSanitize', 'ui.rout
$rootScope.fablabWithoutSpaces = Fablab.withoutSpaces;
// Global config: if true, all payments will be disabled in the application for the members (only admins will be able to proceed reservations)
$rootScope.fablabWithoutOnlinePayment = Fablab.withoutOnlinePayment;
// Global config: if true, no invoices will be generated
$rootScope.fablabWithoutInvoices = Fablab.withoutInvoices;
// Global function to allow the user to navigate to the previous screen (ie. $state).
// If no previous $state were recorded, navigate to the home page

View File

@ -64,6 +64,7 @@
//= require ng-fittext/dist/ng-FitText.min
//= require angular-aside/dist/js/angular-aside
//= require ng-caps-lock/ng-caps-lock
//= require angular-recaptcha
//= require_tree ./controllers
//= require_tree ./services
//= require_tree ./directives

View File

@ -26,6 +26,12 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
/* PUBLIC SCOPE */
// default active tab
$scope.tabs = {
listing: { active: !Fablab.withoutInvoices },
settings: { active: Fablab.withoutInvoices }
};
// List of all users invoices
$scope.invoices = invoices;
@ -76,6 +82,94 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
}
};
// Accounting codes
$scope.settings = {
journalCode: {
name: 'accounting_journal_code',
value: settings['accounting_journal_code']
},
cardClientCode: {
name: 'accounting_card_client_code',
value: settings['accounting_card_client_code']
},
cardClientLabel: {
name: 'accounting_card_client_label',
value: settings['accounting_card_client_label']
},
walletClientCode: {
name: 'accounting_wallet_client_code',
value: settings['accounting_wallet_client_code']
},
walletClientLabel: {
name: 'accounting_wallet_client_label',
value: settings['accounting_wallet_client_label']
},
otherClientCode: {
name: 'accounting_other_client_code',
value: settings['accounting_other_client_code']
},
otherClientLabel: {
name: 'accounting_other_client_label',
value: settings['accounting_other_client_label']
},
walletCode: {
name: 'accounting_wallet_code',
value: settings['accounting_wallet_code']
},
walletLabel: {
name: 'accounting_wallet_label',
value: settings['accounting_wallet_label']
},
vatCode: {
name: 'accounting_VAT_code',
value: settings['accounting_VAT_code']
},
vatLabel: {
name: 'accounting_VAT_label',
value: settings['accounting_VAT_label']
},
subscriptionCode: {
name: 'accounting_subscription_code',
value: settings['accounting_subscription_code']
},
subscriptionLabel: {
name: 'accounting_subscription_label',
value: settings['accounting_subscription_label']
},
machineCode: {
name: 'accounting_Machine_code',
value: settings['accounting_Machine_code']
},
machineLabel: {
name: 'accounting_Machine_label',
value: settings['accounting_Machine_label']
},
trainingCode: {
name: 'accounting_Training_code',
value: settings['accounting_Training_code']
},
trainingLabel: {
name: 'accounting_Training_label',
value: settings['accounting_Training_label']
},
eventCode: {
name: 'accounting_Event_code',
value: settings['accounting_Event_code']
},
eventLabel: {
name: 'accounting_Event_label',
value: settings['accounting_Event_label']
},
spaceCode: {
name: 'accounting_Space_code',
value: settings['accounting_Space_code']
},
spaceLabel: {
name: 'accounting_Space_label',
value: settings['accounting_Space_label']
}
};
// Placeholding date for the invoice creation
$scope.today = moment();
@ -325,9 +419,7 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
$scope.history.push({ date: rate.created_at, rate: rate.value, user: rate.user })
});
activeHistory.setting.history.forEach(function (v) {
if (v.value === 'false') {
$scope.history.push({ date: v.created_at, rate: 0, user: v.user })
}
$scope.history.push({ date: v.created_at, enabled: v.value === 'true', user: v.user })
});
}
@ -432,6 +524,14 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
});
}
$scope.toggleExportModal = function() {
$uibModal.open({
templateUrl: '<%= asset_path "admin/invoices/accountingExportModal.html" %>',
controller: 'AccountingExportModalController',
size: 'xl'
});
}
/**
* Test if the given date is within a closed accounting period
* @param date {Date} date to test
@ -446,6 +546,20 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
return false;
}
/**
* Callback to bulk save all settings in the page to the database with their values
*/
$scope.save = function() {
Setting.bulkUpdate(
{ settings: Object.values($scope.settings) },
function () { growl.success(_t('invoices.codes_customization_success')); },
function (error) {
growl.error('unexpected_error_occurred');
console.error(error);
}
);
}
/* PRIVATE SCOPE */
/**
@ -791,7 +905,7 @@ Application.Controllers.controller('ClosePeriodModalController', ['$scope', '$ui
};
/**
* Cancel the refund, dismiss the modal window
* Just dismiss the modal window
*/
$scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
@ -803,3 +917,136 @@ Application.Controllers.controller('ClosePeriodModalController', ['$scope', '$ui
}
}
]);
Application.Controllers.controller('AccountingExportModalController', ['$scope', '$uibModalInstance', 'Invoice', 'Export', 'CSRF', 'growl', '_t',
function ($scope, $uibModalInstance, Invoice, Export, CSRF, growl, _t) {
// Retrieve Anti-CSRF tokens from cookies
CSRF.setMetaTags();
const SETTINGS = {
acd: {
format: 'csv',
encoding: 'ISO-8859-1',
separator: ';',
dateFormat: '%d/%m/%Y',
labelMaxLength: 50,
decimalSeparator: ',',
exportInvoicesAtZero: false,
columns: ['journal_code', 'date', 'account_code', 'account_label', 'piece', 'line_label', 'debit_origin', 'credit_origin', 'debit_euro', 'credit_euro', 'lettering']
}
};
/* PUBLIC SCOPE */
// API URL where the form will be posted
$scope.actionUrl = '/api/accounting/export';
// Form action on the above URL
$scope.method = 'post';
// Anti-CSRF token to inject into the download form
$scope.csrfToken = angular.element('meta[name="csrf-token"]')[0].content;
// API request body to generate the export
$scope.query = null;
// binding to radio button "export to"
$scope.exportTarget = {
software: null,
startDate: null,
endDate: null,
settings: null
};
// AngularUI-Bootstrap datepicker parameters to define export dates range
$scope.datePicker = {
format: Fablab.uibDateFormat,
opened: { // default: datePickers are not shown
start: false,
end: false
},
options: {
startingDay: Fablab.weekStartingDay
}
};
// Date of the first invoice
$scope.firstInvoice = null;
/**
* Validate the export
*/
$scope.ok = function () {
const statusQry = mkQuery();
$scope.query = statusQry;
Export.status(statusQry).then(function (res) {
if (!res.data.exists) {
growl.success(_t('invoices.export_is_running'));
}
$uibModalInstance.close(res);
});
};
/**
* Callback to open/close one of the datepickers
* @param event {Object} see https://docs.angularjs.org/guide/expression#-event-
* @param picker {string} start | end
*/
$scope.toggleDatePicker = function(event, picker) {
event.preventDefault();
$scope.datePicker.opened[picker] = !$scope.datePicker.opened[picker];
};
/**
* Will fill the export settings, according to the selected software
*/
$scope.fillSettings = function() {
$scope.exportTarget.settings = SETTINGS[$scope.exportTarget.software];
};
/**
* Just dismiss the modal window
*/
$scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
/* PRIVATE SCOPE */
/**
* Kind of constructor: these actions will be realized first when the controller is loaded
*/
const initialize = function () {
// if the invoice was payed with stripe, allow to refund through stripe
Invoice.first(function (data) {
$scope.firstInvoice = data.date;
$scope.exportTarget.startDate = data.date;
$scope.exportTarget.endDate = moment().toISOString();
});
};
/**
* Prepare the query for the export API
* @returns {{extension: *, query: *, category: string, type: *, key: *}}
*/
const mkQuery = function() {
return {
category: 'accounting',
type: $scope.exportTarget.software,
extension: $scope.exportTarget.settings.format,
key: $scope.exportTarget.settings.separator,
query: JSON.stringify({
columns: $scope.exportTarget.settings.columns,
encoding: $scope.exportTarget.settings.encoding,
date_format: $scope.exportTarget.settings.dateFormat,
start_date: $scope.exportTarget.startDate,
end_date: $scope.exportTarget.endDate,
label_max_length: $scope.exportTarget.settings.labelMaxLength,
decimal_separator: $scope.exportTarget.settings.decimalSeparator,
export_invoices_at_zero: $scope.exportTarget.settings.exportInvoicesAtZero
})
};
}
// !!! MUST BE CALLED AT THE END of the controller
return initialize();
}]);

View File

@ -609,6 +609,81 @@ Application.Controllers.controller('NewMemberController', ['$scope', '$state', '
}
]);
/**
* Controller used in the member's import page: import from CSV (admin view)
*/
Application.Controllers.controller('ImportMembersController', ['$scope', '$state', 'Group', 'Training', 'CSRF', 'tags', 'growl',
function($scope, $state, Group, Training, CSRF, tags, growl) {
CSRF.setMetaTags();
/* PUBLIC SCOPE */
// API URL where the form will be posted
$scope.actionUrl = '/api/imports/members';
// Form action on the above URL
$scope.method = 'post';
// List of all tags
$scope.tags = tags
/*
* Callback run after the form was submitted
* @param content {*} The result provided by the server, may be an Import object or an error message
*/
$scope.onImportResult = function(content) {
if (content.id) {
$state.go('app.admin.members_import_result', { id: content.id });
} else {
growl.error(JSON.stringify(content));
}
}
// Using the MembersController
return new MembersController($scope, $state, Group, Training);
}
]);
/**
* Controller used in the member's import results page (admin view)
*/
Application.Controllers.controller('ImportMembersResultController', ['$scope', '$state', 'Import', 'importItem',
function ($scope, $state, Import, importItem) {
/* PUBLIC SCOPE */
// Current import as saved in database
$scope.import = importItem;
// Current import results
$scope.results = null;
/**
* Changes the admin's view to the members import page
*/
$scope.cancel = function () { $state.go('app.admin.members_import'); };
/* PRIVATE SCOPE */
/**
* Kind of constructor: these actions will be realized first when the controller is loaded
*/
const initialize = function () {
$scope.results = JSON.parse($scope.import.results);
if (!$scope.results) {
setTimeout(function() {
Import.get({ id: $scope.import.id }, function(data) {
$scope.import = data;
initialize();
});
}, 5000);
}
};
// !!! MUST BE CALLED AT THE END of the controller
initialize();
}
]);
/**
* Controller used in the admin's creation page (admin view)
*/

View File

@ -82,6 +82,10 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
*/
$scope.signup = function (e) {
if (e) { e.preventDefault(); }
<% active_provider = AuthProvider.active %>
<% if active_provider.providable_type != DatabaseProvider.name %>
$window.location.href = '/sso-redirect';
<% else %>
return $uibModal.open({
templateUrl: '<%= asset_path "shared/signupModal.html" %>',
@ -96,6 +100,9 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
}
};
// reCaptcha v2 site key (or undefined)
$scope.recaptchaSiteKey = Fablab.recaptchaSiteKey;
// callback to open the date picker (account creation modal)
$scope.openDatePicker = function ($event) {
$event.preventDefault();
@ -117,7 +124,9 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
// default user's parameters
$scope.user = {
is_allow_contact: true,
is_allow_newsletter: false
is_allow_newsletter: false,
// reCaptcha response, received from Google (through AJAX) and sent to server for validation
recaptcha: undefined
};
// Errors display
@ -162,6 +171,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
// when the account was created successfully, set the session to the newly created account
$scope.setCurrentUser(user);
});
<% end %>
};
/**
@ -198,7 +208,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
return Auth.login().then(function (user) {
$scope.setCurrentUser(user);
}, function (error) {
console.error(`Authentication failed: ${error}`);
console.error(`Authentication failed: ${JSON.stringify(error)}`);
}
);
});
@ -346,7 +356,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
var openLoginModal = function (toState, toParams, callback) {
<% active_provider = AuthProvider.active %>
<% if active_provider.providable_type != DatabaseProvider.name %>
$window.location.href = '<%="/users/auth/#{active_provider.strategy_name}"%>';
$window.location.href = '/sso-redirect';
<% else %>
return $uibModal.open({
templateUrl: '<%= asset_path "shared/deviseModal.html" %>',
@ -362,7 +372,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
}
}
, function (error) {
console.error(`Authentication failed: ${error}`);
console.error(`Authentication failed: ${JSON.stringify(error)}`);
$scope.alerts = [];
return $scope.alerts.push({
msg: _t('wrong_email_or_password'),

View File

@ -442,9 +442,9 @@ Application.Controllers.controller('NewProjectController', ['$scope', '$state',
/**
* Controller used in the project edition page
*/
Application.Controllers.controller('EditProjectController', ['$scope', '$state', '$stateParams', 'Project', 'Machine', 'Member', 'Component', 'Theme', 'Licence', '$document', 'CSRF', 'projectPromise', 'Diacritics', 'dialogs', 'allowedExtensions', '_t',
function ($scope, $state, $stateParams, Project, Machine, Member, Component, Theme, Licence, $document, CSRF, projectPromise, Diacritics, dialogs, allowedExtensions, _t) {
CSRF.setMetaTags();
Application.Controllers.controller('EditProjectController', ['$rootScope', '$scope', '$state', '$stateParams', 'Project', 'Machine', 'Member', 'Component', 'Theme', 'Licence', '$document', 'CSRF', 'projectPromise', 'Diacritics', 'dialogs', 'allowedExtensions', '_t',
function ($rootScope, $scope, $state, $stateParams, Project, Machine, Member, Component, Theme, Licence, $document, CSRF, projectPromise, Diacritics, dialogs, allowedExtensions, _t) {
/* PUBLIC SCOPE */
// API URL where the form will be posted
$scope.actionUrl = `/api/projects/${$stateParams.id}`;
@ -462,8 +462,25 @@ Application.Controllers.controller('EditProjectController', ['$scope', '$state',
});
});
// Using the ProjectsController
return new ProjectsController($scope, $state, Project, Machine, Member, Component, Theme, Licence, $document, Diacritics, dialogs, allowedExtensions, _t);
/* PRIVATE SCOPE */
/**
* Kind of constructor: these actions will be realized first when the controller is loaded
*/
const initialize = function () {
CSRF.setMetaTags();
if ($scope.project.author_id !== $rootScope.currentUser.id && $scope.project.user_ids.indexOf($rootScope.currentUser.id) === -1) {
$state.go('app.public.projects_show', { id: $scope.project.slug });
console.error('[EditProjectController::initialize] user is not allowed')
}
// Using the ProjectsController
return new ProjectsController($scope, $state, Project, Machine, Member, Component, Theme, Licence, $document, Diacritics, dialogs, allowedExtensions, _t);
}
// !!! MUST BE CALLED AT THE END of the controller
return initialize();
}
]);

View File

@ -63,7 +63,7 @@ Application.Directives.directive('stripeForm', ['Payment', 'growl', '_t',
Payment.confirm({ payment_method_id: paymentMethod.id, cart_items: $scope.cartItems }, function (response) {
// Handle server response (see Step 3)
handleServerResponse(response, button);
}, function(error) { handleServerResponse({ error }) });
}, function(error) { handleServerResponse({ error }, button) });
}
});
});
@ -89,7 +89,7 @@ Application.Directives.directive('stripeForm', ['Payment', 'growl', '_t',
// The PaymentIntent can be confirmed again on the server
Payment.confirm({ payment_intent_id: result.paymentIntent.id, cart_items: $scope.cartItems }, function(confirmResult) {
handleServerResponse(confirmResult, confirmButton);
}, function(error) { handleServerResponse({ error }) });
}, function(error) { handleServerResponse({ error }, confirmButton) });
}
});
} else {

View File

@ -38,7 +38,9 @@ angular.module('application.router', ['ui.router'])
logoBlackFile: ['CustomAsset', function (CustomAsset) { return CustomAsset.get({ name: 'logo-black-file' }).$promise; }],
commonTranslations: ['Translations', function (Translations) { return Translations.query(['app.public.common', 'app.shared.buttons', 'app.shared.elements']).$promise; }]
},
onEnter: ['$rootScope', 'logoFile', 'logoBlackFile', function ($rootScope, logoFile, logoBlackFile) {
onEnter: ['$rootScope', 'logoFile', 'logoBlackFile', 'CSRF', function ($rootScope, logoFile, logoBlackFile, CSRF) {
// Retrieve Anti-CSRF tokens from cookies
CSRF.setMetaTags();
// Application logo
$rootScope.logo = logoFile.custom_asset;
return $rootScope.logoBlack = logoBlackFile.custom_asset;
@ -899,15 +901,13 @@ angular.module('application.router', ['ui.router'])
resolve: {
settings: ['Setting', function (Setting) {
return Setting.query({
names: `['invoice_legals', \
'invoice_text', \
'invoice_VAT-rate', \
'invoice_VAT-active', \
'invoice_order-nb', \
'invoice_code-value', \
'invoice_code-active', \
'invoice_reference', \
'invoice_logo']` }).$promise;
names: `['invoice_legals', 'invoice_text', 'invoice_VAT-rate', 'invoice_VAT-active', 'invoice_order-nb', 'invoice_code-value', \
'invoice_code-active', 'invoice_reference', 'invoice_logo', 'accounting_journal_code', 'accounting_card_client_code', \
'accounting_card_client_label', 'accounting_wallet_client_code', 'accounting_wallet_client_label', \
'accounting_other_client_code', 'accounting_other_client_label', 'accounting_wallet_code', 'accounting_wallet_label', \
'accounting_VAT_code', 'accounting_VAT_label', 'accounting_subscription_code', 'accounting_subscription_label', \
'accounting_Machine_code', 'accounting_Machine_label', 'accounting_Training_code', 'accounting_Training_label', \
'accounting_Event_code', 'accounting_Event_label', 'accounting_Space_code', 'accounting_Space_label']` }).$promise;
}],
invoices: [ 'Invoice', function (Invoice) {
return Invoice.list({
@ -961,6 +961,32 @@ angular.module('application.router', ['ui.router'])
translations: ['Translations', function (Translations) { return Translations.query(['app.admin.members_new', 'app.shared.user', 'app.shared.user_admin']).$promise; }]
}
})
.state('app.admin.members_import', {
url: '/admin/members/import',
views: {
'main@': {
templateUrl: '<%= asset_path "admin/members/import.html" %>',
controller: 'ImportMembersController'
}
},
resolve: {
translations: ['Translations', function (Translations) { return Translations.query(['app.admin.members_import', 'app.shared.user', 'app.shared.user_admin']).$promise; }],
tags: ['Tag', function(Tag) { return Tag.query().$promise }]
}
})
.state('app.admin.members_import_result', {
url: '/admin/members/import/:id/results',
views: {
'main@': {
templateUrl: '<%= asset_path "admin/members/import_result.html" %>',
controller: 'ImportMembersResultController'
}
},
resolve: {
translations: ['Translations', function (Translations) { return Translations.query(['app.admin.members_import_result', 'app.shared.user', 'app.shared.user_admin']).$promise; }],
importItem: ['Import', '$stateParams', function(Import, $stateParams) { return Import.get({ id: $stateParams.id }).$promise }]
}
})
.state('app.admin.members_edit', {
url: '/admin/members/:id/edit',
views: {

View File

@ -1,6 +1,6 @@
'use strict';
Application.Services.factory('AuthService', ['Session', function (Session) {
Application.Services.factory('AuthService', ['Session', 'CSRF', function (Session, CSRF) {
return {
isAuthenticated () {
return (Session.currentUser != null) && (Session.currentUser.id != null);

View File

@ -0,0 +1,7 @@
'use strict';
Application.Services.factory('Import', ['$resource', function ($resource) {
return $resource('/api/imports/:id',
{ id: '@id' }
);
}]);

View File

@ -10,6 +10,10 @@ Application.Services.factory('Invoice', ['$resource', function ($resource) {
url: '/api/invoices/list',
method: 'POST',
isArray: true
},
first: {
url: '/api/invoices/first',
method: 'GET'
}
}
);

View File

@ -9,6 +9,10 @@ Application.Services.factory('Setting', ['$resource', function ($resource) {
return angular.toJson({ setting: data });
}
},
bulkUpdate: {
url: '/api/settings/bulk_update',
method: 'PATCH'
},
query: {
isArray: false
}

View File

@ -37,6 +37,7 @@
.text-blue { color: $blue; }
.text-muted { color: $text-muted; }
.text-danger, .red { color: $red !important; }
.text-red-only { color: $red !important; }
.text-purple { color: $violet !important; }
.text-japonica { color: $japonica !important; }
.text-beige { color: $beige !important; }

View File

@ -65,6 +65,10 @@
height: 100%;
}
.modal-xl {
width: 900px;
}
// component card
.card {
position: relative;

View File

@ -180,6 +180,11 @@ p, .widget p {
.p-lg { padding: 30px; }
.p-l { padding: 16px; }
.p-h-xs { padding-left: 5px; padding-right: 5px; }
.p-h-s { padding-left: 10px; padding-right: 10px; }
.p-h-l { padding-left: 16px; padding-right: 16px; }
.p-h-lg { padding-left: 30px; padding-right: 30px; }
.m-xxs{margin: 2px 4px}
.m-xs{margin: 5px;}
.m-sm{margin: 10px;}
@ -256,6 +261,14 @@ p, .widget p {
.m-b-n-lg{margin-bottom: -30px}
.m-b-n-xl{margin-bottom: -40px}
.m-h-none{margin-left: 0; margin-right: 0;}
.m-h-xs{margin-left: 5px; margin-right: 5px;}
.m-h-sm{margin-left: 10px; margin-right: 10px;}
.m-h{margin-left: 15px; margin-right: 15px;}
.m-h-md{margin-left: 20px; margin-right: 20px;}
.m-h-lg{margin-left: 30px; margin-right: 30px;}
.m-h-xl{margin-left: 40px; margin-right: 40px;}
.media-xs{min-width: 50px}
.media-sm{min-width: 80px}
.media-md{min-width: 90px}
@ -306,6 +319,17 @@ p, .widget p {
width: auto;
vertical-align: sub;
}
// the two classes above are used in import results page for "import failed"
.fa-stack-inside {
font-size: 0.8em !important;
}
.fa-stack-outside {
font-size: 1.3em !important;
line-height: 1.7em !important;
}
.contrast-250 { -webkit-filter: contrast(250%); filter: contrast(250%); }
.clear{display:block;overflow: hidden;}
@ -350,6 +374,11 @@ p, .widget p {
cursor: help;
}
.flex-center {
display: flex;
justify-content: center;
}
@media screen and (min-width: $screen-lg-min) {
.b-r-lg {border-right: 1px solid $border-color; }
.hide-b-r-lg { border: none !important; }

View File

@ -276,3 +276,31 @@ table.scrollable-3-cols {
input.form-control.as-writable {
background-color: white;
}
.accounting-codes .row {
margin-top: 2rem;
button {
margin-top: 1em;
}
}
table.export-table-template {
margin-top: 10px;
thead td {
width: 20px;
background-color: #227447;
color: white;
border-bottom: 2px solid black;
font-size: 13px;
font-weight: bold;
padding: 10px 5px;
line-height: 12px;
}
tbody td {
border-right: 1px solid #d4d4d4;
height: 30px;
}
}

View File

@ -0,0 +1,97 @@
<div class="modal-header">
<h3 class="text-center red" translate>{{ 'invoices.export_accounting_data' }}</h3>
</div>
<div class="modal-body">
<form role="form" name="exportForm">
<div class="row">
<div class="form-group col-md-6">
<label for="start_date" translate>{{ 'invoices.export_form_date' }}</label>
<div class="input-group">
<span class="input-group-addon"><i class="fa fa-calendar"></i></span>
<input type="text"
class="form-control"
name="start_date"
id="start_date"
ng-model="exportTarget.startDate"
uib-datepicker-popup="{{datePicker.format}}"
datepicker-options="datePicker.options"
is-open="datePicker.opened.start"
min-date="firstInvoice"
placeholder="{{datePicker.format}}"
ng-click="toggleDatePicker($event, 'start')"
required/>
</div>
</div>
<div class="form-group col-md-6">
<label for="end_date" translate>{{ 'invoices.export_to_date' }}</label>
<div class="input-group">
<span class="input-group-addon"><i class="fa fa-calendar"></i></span>
<input type="text"
class="form-control"
name="end_date"
id="end_date"
ng-model="exportTarget.endDate"
uib-datepicker-popup="{{datePicker.format}}"
datepicker-options="datePicker.options"
is-open="datePicker.opened.end"
min-date="exportTarget.startDate || firstInvoice"
placeholder="{{datePicker.format}}"
ng-click="toggleDatePicker($event, 'end')"
required/>
</div>
</div>
</div>
<div class="row">
<h4 class="control-label m-l" translate>{{ 'invoices.export_to' }}</h4>
<div class="form-group m-l-lg">
<label for="acd">
<input type="radio" name="acd" id="acd" ng-model="exportTarget.software" ng-value="'acd'" ng-click="fillSettings()" required/>
{{ 'invoices.acd' | translate }}
</label>
</div>
</div>
<div class="row" ng-show="exportTarget.settings">
<div class="col-md-4 font-bold" translate>{{ 'invoices.format' }}</div>
<div class="col-md-8">{{ exportTarget.settings.format }}</div>
<div class="col-md-4 font-bold" translate>{{ 'invoices.encoding' }}</div>
<div class="col-md-8">{{ exportTarget.settings.encoding }}</div>
<div class="col-md-4 font-bold" translate>{{ 'invoices.separator' }}</div>
<div class="col-md-8">{{ exportTarget.settings.separator }}</div>
<div class="col-md-4 font-bold" translate>{{ 'invoices.dateFormat' }}</div>
<div class="col-md-8">
<a href="https://apidock.com/ruby/DateTime/strftime" class="help-cursor" target="_blank">{{ exportTarget.settings.dateFormat }}</a>
</div>
<div class="col-md-4 font-bold" translate>{{ 'invoices.labelMaxLength' }}</div>
<div class="col-md-8">{{ exportTarget.settings.labelMaxLength }}</div>
<div class="col-md-4 font-bold" translate>{{ 'invoices.decimalSeparator' }}</div>
<div class="col-md-8">{{ exportTarget.settings.decimalSeparator }}</div>
<div class="col-md-4 font-bold" translate>{{ 'invoices.exportInvoicesAtZero' }}</div>
<div class="col-md-8" translate>{{ exportTarget.settings.exportInvoicesAtZero ? 'yes' : 'no' }}</div>
<div class="col-md-4 font-bold" translate>{{ 'invoices.columns' }}</div>
<table class="col-md-12 export-table-template">
<thead>
<tr>
<td ng-repeat="column in exportTarget.settings.columns" translate>{{ 'invoices.exportColumns.' + column }}</td>
</tr>
</thead>
<tbody>
<tr>
<td ng-repeat="column in exportTarget.settings.columns"></td>
</tr>
</tbody>
</table>
</div>
</form>
</div>
<div class="modal-footer">
<form role="form" ng-submit="ok()" name="exportFormParams" method="post" action="{{ actionUrl }}" class="inline">
<input name="authenticity_token" type="hidden" ng-value="csrfToken"/>
<input name="_method" type="hidden" ng-value="method"/>
<input name="extension" type="hidden" ng-value="query.extension"/>
<input name="type" type="hidden" ng-value="exportTarget.software"/>
<input name="key" type="hidden" ng-value="query.key"/>
<input name="query" type="hidden" ng-value="query.query"/>
<input type="submit" class="btn btn-warning" value="{{ 'confirm' | translate }}" formtarget="export-frame"/>
</form>
<button class="btn btn-default" ng-click="cancel()" translate>{{ 'cancel' }}</button>
</div>

View File

@ -23,7 +23,7 @@
</div>
<div class="form-group">
<label translate>{{ 'invoices.refund_mode' }}</label>
<select class="form-control m-t-sm" name="avoir_mode" ng-model="avoir.avoir_mode" ng-options="mode.value as mode.name for mode in avoirModes" required></select>
<select class="form-control m-t-sm" name="payment_method" ng-model="avoir.payment_method" ng-options="mode.value as mode.name for mode in avoirModes" required></select>
</div>
<div class="form-group" ng-if="invoice.is_subscription_invoice">
<label translate>{{ 'invoices.do_you_want_to_disable_the_user_s_subscription' }}</label>

View File

@ -12,6 +12,8 @@
</div>
<div class="col-xs-12 col-sm-12 col-md-3 b-t hide-b-md">
<section class="heading-actions wrapper">
<a class="btn btn-default rounded m-t-sm" ng-click="toggleExportModal()"><i class="fa fa-book"></i></a>
<iframe name="export-frame" height="0" width="0" class="none" id="accounting-export-frame"></iframe>
<a class="btn btn-lg btn-default rounded m-t-sm text-sm" ng-click="closeAnAccountingPeriod()"><i class="fa fa-calendar-check-o"></i> {{ 'invoices.accounting_periods' | translate }}</a>
</section>
</div>
@ -22,7 +24,7 @@
<div class="row">
<div class="col-md-12">
<uib-tabset justified="true">
<uib-tab heading="{{ 'invoices.invoices_list' | translate }}">
<uib-tab heading="{{ 'invoices.invoices_list' | translate }}" ng-hide="fablabWithoutInvoices" active="tabs.listing.active">
<h3 class="m-t-xs"><i class="fa fa-filter"></i> {{ 'invoices.filter_invoices' | translate }}</h3>
<div class="row">
@ -114,9 +116,13 @@
<uib-tab heading="{{ 'invoices.invoicing_settings' | translate }}">
<uib-tab heading="{{ 'invoices.invoicing_settings' | translate }}" active="tabs.settings.active">
<div class="alert alert-warning p-md m-t" role="alert" ng-show="fablabWithoutInvoices">
<i class="fa fa-warning m-r"></i>
<span translate>{{ 'invoices.warning_invoices_disabled' }}</span>
</div>
<form class="invoice-placeholder">
<div class="invoice-logo" style="background-image: url({{invoice.logo}});">
<div class="invoice-logo">
<img src="data:image/png;base64," data-src="holder.js/100%x100%/text:&#xf03e;/font:FontAwesome/icon" bs-holder ng-if="!invoice.logo" class="img-responsive">
<img base-sixty-four-image="invoice.logo" ng-if="invoice.logo && invoice.logo.base64">
<div class="tools-box">
@ -201,6 +207,123 @@
</div>
</form>
</uib-tab>
<uib-tab heading="{{ 'invoices.accounting_codes' | translate }}">
<div class="panel panel-default m-t-md accounting-codes">
<div class="panel-body">
<div class="row">
<div class="col-md-6">
<label for="journalCode" translate>{{ 'invoices.accounting_journal_code' }}</label>
<input type="text" id="journalCode" ng-model="settings.journalCode.value" class="form-control" placeholder="{{ 'invoices.general_journal_code' | translate }}"/>
</div>
</div>
<div class="row">
<div class="col-md-6">
<label for="cardClientCode" translate>{{ 'invoices.accounting_card_client_code' }}</label>
<input type="text" id="cardClientCode" ng-model="settings.cardClientCode.value" class="form-control" placeholder="{{ 'invoices.card_client_code' | translate }}" />
</div>
<div class="col-md-6">
<label for="cardClientLabel" translate>{{ 'invoices.accounting_card_client_label' }}</label>
<input type="text" id="cardClientLabel" ng-model="settings.cardClientLabel.value" class="form-control" placeholder="{{ 'invoices.card_client_label' | translate }}"/>
</div>
</div>
<div class="row">
<div class="col-md-6">
<label for="walletClientCode" translate>{{ 'invoices.accounting_wallet_client_code' }}</label>
<input type="text" id="walletClientCode" ng-model="settings.walletClientCode.value" class="form-control" placeholder="{{ 'invoices.wallet_client_code' | translate }}" />
</div>
<div class="col-md-6">
<label for="walletClientLabel" translate>{{ 'invoices.accounting_wallet_client_label' }}</label>
<input type="text" id="walletClientLabel" ng-model="settings.walletClientLabel.value" class="form-control" placeholder="{{ 'invoices.wallet_client_label' | translate }}"/>
</div>
</div>
<div class="row">
<div class="col-md-6">
<label for="otherClientCode" translate>{{ 'invoices.accounting_other_client_code' }}</label>
<input type="text" id="otherClientCode" ng-model="settings.otherClientCode.value" class="form-control" placeholder="{{ 'invoices.other_client_code' | translate }}" />
</div>
<div class="col-md-6">
<label for="otherClientLabel" translate>{{ 'invoices.accounting_other_client_label' }}</label>
<input type="text" id="otherClientLabel" ng-model="settings.otherClientLabel.value" class="form-control" placeholder="{{ 'invoices.other_client_label' | translate }}"/>
</div>
</div>
<div class="row">
<div class="col-md-6">
<label for="walletCode" translate>{{ 'invoices.accounting_wallet_code' }}</label>
<input type="text" id="walletCode" ng-model="settings.walletCode.value" class="form-control" placeholder="{{ 'invoices.general_wallet_code' | translate }}" />
</div>
<div class="col-md-6">
<label for="walletLabel" translate>{{ 'invoices.accounting_wallet_label' }}</label>
<input type="text" id="walletLabel" ng-model="settings.walletLabel.value" class="form-control" placeholder="{{ 'invoices.general_wallet_label' | translate }}"/>
</div>
</div>
<div class="row">
<div class="col-md-6">
<label for="vatCode" translate>{{ 'invoices.accounting_vat_code' }}</label>
<input type="text" id="vatCode" ng-model="settings.vatCode.value" class="form-control" placeholder="{{ 'invoices.general_vat_code' | translate }}"/>
</div>
<div class="col-md-6">
<label for="vatLabel" translate>{{ 'invoices.accounting_vat_label' }}</label>
<input type="text" id="vatLabel" ng-model="settings.vatLabel.value" class="form-control" placeholder="{{ 'invoices.general_vat_label' | translate }}"/>
</div>
</div>
<div class="row">
<div class="col-md-6">
<label for="subscriptionCode" translate>{{ 'invoices.accounting_subscription_code' }}</label>
<input type="text" id="subscriptionCode" ng-model="settings.subscriptionCode.value" class="form-control" placeholder="{{ 'invoices.general_subscription_code' | translate }}" />
</div>
<div class="col-md-6">
<label for="subscriptionLabel" translate>{{ 'invoices.accounting_subscription_label' }}</label>
<input type="text" id="subscriptionLabel" ng-model="settings.subscriptionLabel.value" class="form-control" placeholder="{{ 'invoices.general_subscription_label' | translate }}"/>
</div>
</div>
<div class="row">
<div class="col-md-6">
<label for="machineCode" translate>{{ 'invoices.accounting_Machine_code' }}</label>
<input type="text" id="machineCode" ng-model="settings.machineCode.value" class="form-control" placeholder="{{ 'invoices.general_machine_code' | translate }}"/>
</div>
<div class="col-md-6">
<label for="machineLabel" translate>{{ 'invoices.accounting_Machine_label' }}</label>
<input type="text" id="machineLabel" ng-model="settings.machineLabel.value" class="form-control" placeholder="{{ 'invoices.general_machine_label' | translate }}"/>
</div>
</div>
<div class="row">
<div class="col-md-6">
<label for="trainingCode" translate>{{ 'invoices.accounting_Training_code' }}</label>
<input type="text" id="trainingCode" ng-model="settings.trainingCode.value" class="form-control" placeholder="{{ 'invoices.general_training_code' | translate }}" />
</div>
<div class="col-md-6">
<label for="trainingLabel" translate>{{ 'invoices.accounting_Training_label' }}</label>
<input type="text" id="trainingLabel" ng-model="settings.trainingLabel.value" class="form-control" placeholder="{{ 'invoices.general_training_label' | translate }}"/>
</div>
</div>
<div class="row">
<div class="col-md-6">
<label for="eventCode" translate>{{ 'invoices.accounting_Event_code' }}</label>
<input type="text" id="eventCode" ng-model="settings.eventCode.value" class="form-control" placeholder="{{ 'invoices.general_event_code' | translate }}"/>
</div>
<div class="col-md-6">
<label for="eventLabel" translate>{{ 'invoices.accounting_Event_label' }}</label>
<input type="text" id="eventLabel" ng-model="settings.eventLabel.value" class="form-control" placeholder="{{ 'invoices.general_event_label' | translate }}"/>
</div>
</div>
<div class="row">
<div class="col-md-6">
<label for="spaceCode" translate>{{ 'invoices.accounting_Space_code' }}</label>
<input type="text" id="spaceCode" ng-model="settings.spaceCode.value" class="form-control" placeholder="{{ 'invoices.general_space_code' | translate }}" />
</div>
<div class="col-md-6">
<label for="spaceLabel" translate>{{ 'invoices.accounting_Space_label' }}</label>
<input type="text" id="spaceLabel" ng-model="settings.spaceLabel.value" class="form-control" placeholder="{{ 'invoices.general_space_label' | translate }}"/>
</div>
</div>
<button name="button" class="btn btn-warning m-t-lg" ng-click="save()" translate>{{ 'save' }}</button>
</div>
</div>
</uib-tab>
</uib-tabset>
</div>
</div>
@ -409,8 +532,9 @@
<tbody>
<tr ng-repeat="value in history | orderBy:'-date'">
<td>
<span class="no-user-label" ng-show="value.rate === 0" translate>{{'invoices.VAT_disabled'}}</span>
<span ng-hide="value.rate === 0">{{value.rate}}</span>
<span class="no-user-label" ng-show="value.enabled === false" translate>{{'invoices.VAT_disabled'}}</span>
<span class="no-user-label" ng-show="value.enabled === true" translate>{{'invoices.VAT_enabled'}}</span>
<span ng-show="value.rate">{{value.rate}}</span>
</td>
<td>{{value.date | amDateFormat:'L LT'}}</td>
<td>{{value.user.name}}<span class="no-user-label" ng-hide="value.user" translate>{{ 'invoices.deleted_user' }}</span></td>

View File

@ -187,7 +187,7 @@
</div>
</uib-tab>
<uib-tab heading="{{ 'invoices' | translate }}">
<uib-tab heading="{{ 'invoices' | translate }}" ng-hide="fablabWithoutInvoices">
<div class="col-md-12 m m-t-lg">

View File

@ -0,0 +1,177 @@
<div>
<section class="heading b-b">
<div class="row no-gutter">
<div class="col-md-1 hidden-xs">
<section class="heading-btn">
<a ng-click="cancel()"><i class="fa fa-long-arrow-left"></i></a>
</section>
</div>
<div class="col-md-8 b-l b-r">
<section class="heading-title">
<h1 translate>{{ 'members_import.import_members' }}</h1>
</section>
</div>
<div class="col-md-3">
<section class="heading-actions wrapper">
<a class="btn btn-lg btn-block btn-default m-t-xs" target="_blank" href="example.csv" translate>
{{ 'members_import.download_example' }}
</a>
</section>
</div>
</div>
</section>
<div class="row p-sm">
<div class="col-md-12">
<p class="alert alert-info" translate>
{{ 'members_import.info' }}
</p>
</div>
</div>
<div class="row m-h-sm">
<div class="col-md-6 p-h-s">
<h3 translate>{{ 'members_import.groups' }}</h3>
<table class="table">
<thead>
<tr>
<th translate>{{ 'members_import.group_name' }}</th>
<th translate>{{ 'members_import.group_identifier' }}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="group in groups">
<td>
{{ group.name }}
</td>
<td>
{{ group.slug }}
</td>
</tr>
</tbody>
</table>
</div>
<div class="col-md-6 p-h-s">
<h3 translate>{{ 'members_import.trainings' }}</h3>
<table class="table">
<thead>
<tr>
<th translate>{{ 'members_import.training_name' }}</th>
<th translate>{{ 'members_import.training_identifier' }}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="training in trainings | filterDisabled">
<td>
{{ training.name }}
</td>
<td>
{{ training.id }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="row m-h-sm">
<div class="col-md-6 p-h-s" ng-hide="tags.length == 0">
<h3 translate>{{ 'members_import.tags' }}</h3>
<table class="table">
<thead>
<tr>
<th translate>{{ 'members_import.tag_name' }}</th>
<th translate>{{ 'members_import.tag_identifier' }}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="tag in tags">
<td>
{{ tag.name }}
</td>
<td>
{{ tag.id }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="row no-gutter">
<div class="col-sm-12 col-md-12 b-r nopadding">
<form role="form" name="importForm" class="form-horizontal" novalidate action="{{ actionUrl }}" ng-upload="onImportResult(content)" upload-options-enable-rails-csrf="true">
<section class="panel panel-default bg-light m-lg">
<div class="panel-body m-r">
<div class="m-t">
<p class="alert alert-warning m-h" translate>
{{ 'members_import.required_fields' }}
</p>
<p class="alert alert-warning m-h" translate>
{{ 'members_import.about_example' }}
</p>
</div>
<div class="fileinput input-group" data-provides="fileinput" ng-class="fileinputClass()">
<div class="form-control" data-trigger="fileinput">
<i class="glyphicon glyphicon-file fileinput-exists"></i> <span class="fileinput-filename">{{file.attachment}}</span>
</div>
<span class="input-group-addon btn btn-default btn-file"><span class="fileinput-new" translate>{{ 'members_import.select_file' }}</span>
<span class="fileinput-exists" translate>{{ 'change' }}</span>
<input type="file"
name="import_members"
accept="text/csv"
required></span>
<a class="input-group-addon btn btn-danger fileinput-exists" data-dismiss="fileinput" ng-click="deleteFile(file)"><i class="fa fa-trash-o"></i></a>
</div>
<div class="m-h">
<span translate>{{ 'members_import.update_field' }}</span>
<div class="radio m-l-md">
<label class="control-label">
<input type="radio" id="update_field" name="update_field" value="id" checked>
<span translate>{{ 'members_import.update_on_id' }}</span>
</label>
</div>
<div class="radio m-l-md">
<label class="control-label">
<input type="radio" id="update_field" name="update_field" value="username">
<span translate>{{ 'members_import.update_on_username' }}</span>
</label>
</div>
<div class="radio m-l-md">
<label class="control-label">
<input type="radio" id="update_field" name="update_field" value="email">
<span translate>{{ 'members_import.update_on_email' }}</span>
</label>
</div>
</div>
</div> <!-- ./panel-body -->
<div class="panel-footer no-padder">
<input type="submit" value="{{ 'members_import.import' | translate }}" class="r-b btn-valid btn btn-warning btn-block p-lg btn-lg text-u-c" ng-disabled="importForm.$invalid"/>
</div>
</section>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,66 @@
<div>
<section class="heading b-b">
<div class="row no-gutter">
<div class="col-md-1 hidden-xs">
<section class="heading-btn">
<a ng-click="cancel()"><i class="fa fa-long-arrow-left"></i></a>
</section>
</div>
<div class="col-md-8 b-l">
<section class="heading-title">
<h1 translate>{{ 'members_import_result.import_results' }}</h1>
</section>
</div>
</div>
</section>
<div class="row no-gutter">
<div class="col-sm-12 col-md-12">
<h2 class="m-l-lg">{{ 'members_import_result.import_details' | translate:{DATE:(import.created_at | amDateFormat:'L'), USER:import.user.full_name, ID:import.id} }}</h2>
<h3 class="m-l-lg" ng-hide="results"><i class="fa fa-spinner fa-pulse"></i> <span translate>{{ 'members_import_result.pending' }}</span></h3>
<div ng-show="results">
<h3 class="m-l-lg" translate>{{ 'members_import_result.results' }}</h3>
<div class="row p-h-lg" ng-repeat="resultRow in results track by $index">
<div class="scroll-x">
<table class="table table-bordered font-thin text-xs m-t-lg" ng-if="resultRow.row">
<tr>
<th ng-repeat="(key, value) in resultRow.row">{{key}}</th>
</tr>
<tr class="text-nowrap">
<td ng-repeat="(key, value) in resultRow.row">{{value}}</td>
</tr>
</table>
</div>
<div ng-if="resultRow.status">
<i class="fa fa-arrow-right m-l-lg m-r"></i>
<span class="m-r-md">{{ 'members_import_result.status_' + resultRow.status | translate:{ID:resultRow.user} }}</span>
<span ng-show="resultRow.result" class="green font-bold">
<i class="fa fa-check-square-o fa-stack-outside"></i>
<span class="m-l" translate>{{ 'members_import_result.success' }}</span>
</span>
<span ng-hide="resultRow.result" class="text-red-only font-bold">
<span class="fa-stack v-bottom">
<i class="fa fa-square-o fa-stack-1x fa-stack-outside"></i>
<i class="fa fa-times fa-stack-1x fa-stack-inside"></i>
</span>
<span class="m-l" translate>{{ 'members_import_result.failed' }}</span>
</span>
</div>
<div class="m-l-lg text-red-only" ng-if="!resultRow.row && !resultRow.status">
<span class="m-r" translate>{{ 'members_import_result.error_details' }}</span>{{resultRow}}
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -5,11 +5,18 @@
<a href="#" ng-click="backPrevLocation($event)"><i class="fa fa-long-arrow-left "></i></a>
</section>
</div>
<div class="col-xs-10 col-sm-10 col-md-8 b-l">
<div class="col-xs-8 col-sm-8 col-md-8 b-l">
<section class="heading-title">
<h1 translate>{{ 'users_management' }}</h1>
</section>
</div>
<div class="col-xs-1 col-xs-offset-1 col-md-offset-2 b-l">
<section class="heading-actions wrapper">
<a role="button" class="btn btn-default b-2x rounded m-t-sm" ui-sref="app.admin.members_import">
<i class="fa fa-cloud-upload"></i>
</a>
</section>
</div>
</div>
</section>

View File

@ -36,8 +36,7 @@
<tbody>
<tr ng-repeat="plan in plans | filterDisabled:planFiltering | orderBy:orderPlans"
ng-class="{'disabled-line' : plan.disabled && planFiltering === 'all'}"
ng-init="group = getGroupFromId(groups, plan.group_id)"
ng-hide="group.disabled">
ng-init="group = getGroupFromId(groups, plan.group_id)">
<td>{{getPlanType(plan.type)}}</td>
<td>{{plan.base_name}}</td>
<td>{{ plan.interval | planIntervalFilter:plan.interval_count }}</td>
@ -47,4 +46,4 @@
<td><button type="button" class="btn btn-default" ui-sref="app.admin.plans.edit({id:plan.id})"><i class="fa fa-pencil-square-o"></i></button> <button type="button" class="btn btn-danger" ng-click="deletePlan(plans, plan.id)"><i class="fa fa-trash"></i></button></td>
</tr>
</tbody>
</table>
</table>

View File

@ -76,6 +76,9 @@
<ui-select-choices ui-disable-choice="m.disabled" repeat="m.id as m in (machines | filter: $select.search)">
<span ng-bind-html="m.name | highlight: $select.search"></span>
</ui-select-choices>
<ui-select-no-choice>
<input type="hidden" name="training[machine_ids][]" value="" />
</ui-select-no-choice>
</ui-select>
</div>
</div>

View File

@ -15,7 +15,7 @@
<li ui-sref-active="active"><a class="text-black" href="#" ui-sref="app.logged.dashboard.projects" translate>{{ 'my_projects' }}</a></li>
<li ui-sref-active="active"><a class="text-black" href="#" ui-sref="app.logged.dashboard.trainings" translate>{{ 'my_trainings' }}</a></li>
<li ui-sref-active="active"><a class="text-black" href="#" ui-sref="app.logged.dashboard.events" translate>{{ 'my_events' }}</a></li>
<li ui-sref-active="active"><a class="text-black" href="#" ui-sref="app.logged.dashboard.invoices" translate>{{ 'my_invoices' }}</a></li>
<li ui-sref-active="active" ng-hide="fablabWithoutInvoices"><a class="text-black" href="#" ui-sref="app.logged.dashboard.invoices" translate>{{ 'my_invoices' }}</a></li>
<li ui-sref-active="active"><a class="text-black" href="#" ui-sref="app.logged.dashboard.wallet" translate>{{ 'my_wallet' }}</a></li>
</ul>
</section>

View File

@ -34,7 +34,7 @@
</section>
</div>
</div>
<div class="row col-md-2 col-md-offset-5 hidden-sm hidden-xs">
<div class="row col-md-2 col-md-offset-5 hidden-sm hidden-xs" ng-hide="user.merged_at">
<p class="font-felt fleche-left text-lg upper text-center">
<%= image_tag("fleche-left.png", class: 'fleche-left visible-lg visible-md fleche-left-from-top') %>
<span class="or" translate>{{ 'or' }}</span>
@ -71,10 +71,15 @@
<!-- group -->
<div class="form-group" ng-class="{'has-error': userForm['user[group_id]'].$dirty && userForm['user[group_id]'].$invalid}">
<div class="input-group">
<span class="input-group-addon"><i class="fa fa-users"></i></span>
<span class="input-group-addon">
<i class="fa fa-users"></i>
<span class="exponent m-l-xs help-cursor" title="{{ 'used_for_statistics' | translate }}">
<i class="fa fa-asterisk" aria-hidden="true"></i>
</span>
</span>
<select ng-model="user.group_id" class="form-control" name="user[group_id]" required>
<option value=null translate>{{ 'your_user_s_profile' }}</option>
<option ng-repeat="group in groups" value="{{group.id}}" ng-selected="group.id == user.group_id">{{group.name}}</option>
<option ng-repeat="group in groups" ng-value="group.id" ng-selected="group.id == user.group_id">{{group.name}}</option>
</select>
</div>
<span class="help-block" ng-show="userForm['user[group_id]'].$dirty && userForm['user[group_id]'].$error.required" translate>{{ 'user_s_profile_is_required' }}</span>
@ -86,7 +91,9 @@
name="cgu"
ng-model="user.cgu"
value="true"
ng-required="cgu != null"/> {{ 'i_ve_read_and_i_accept_' | translate }} <a href="{{cgu.custom_asset_file_attributes.attachment_url}}" target="_blank" translate>{{ '_the_fablab_policy' }}</a>
ng-required="cgu != null"/> {{ 'i_ve_read_and_i_accept_' | translate }}
<a href="{{cgu.custom_asset_file_attributes.attachment_url}}" target="_blank" translate>{{ '_the_fablab_policy' }}</a>
<span class="exponent m-l-xs"><i class="fa fa-asterisk" aria-hidden="true"></i></span>
</div>
</div>
</div>

View File

@ -39,23 +39,15 @@
<li><a href="#" ui-sref="app.logged.dashboard.projects" translate>{{ 'my_projects' }}</a></li>
<li><a href="#" ui-sref="app.logged.dashboard.trainings" translate>{{ 'my_trainings' }}</a></li>
<li><a href="#" ui-sref="app.logged.dashboard.events" translate>{{ 'my_events' }}</a></li>
<li><a href="#" ui-sref="app.logged.dashboard.invoices" translate>{{ 'my_invoices' }}</a></li>
<li><a href="#" ui-sref="app.logged.dashboard.invoices" ng-hide="fablabWithoutInvoices" translate>{{ 'my_invoices' }}</a></li>
<li><a href="#" ui-sref="app.logged.dashboard.wallet" translate>{{ 'my_wallet' }}</a></li>
<li class="divider"></li>
<li><a href="#" class="text-black" ng-click="logout($event)"><i class="fa fa-power-off"></i> {{ 'sign_out' | translate }}</a></li>
</ul>
</li>
<% active_provider = AuthProvider.active %>
<% if active_provider.providable_type == DatabaseProvider.name %>
<li ng-if="!isAuthenticated()"><a href="#" class="font-sbold label text-md" ng-click="signup($event)"><i class="fa fa-rocket"></i> {{ 'sign_up' | translate }}</a></li>
<li ng-if="!isAuthenticated()">
<a href="#" class="font-sbold label text-md" ng-click="login($event)"><i class="fa fa-sign-in"></i> {{ 'sign_in' | translate }}</a>
</li>
<% else %>
<li ng-if="!isAuthenticated()"><a href="<%= "/users/auth/#{active_provider.strategy_name}"%>" class="font-sbold label text-md"><i class="fa fa-rocket"></i> {{ 'sign_up' | translate }}</a></li>
<li ng-if="!isAuthenticated()">
<a href="<%= "/users/auth/#{active_provider.strategy_name}"%>" class="font-sbold label text-md"><i class="fa fa-sign-in"></i> {{ 'sign_in' | translate }}</a>
</li>
<% end %>
<li ng-if="!isAuthenticated()"><a href="#" class="font-sbold label text-md" ng-click="signup($event)"><i class="fa fa-rocket"></i> {{ 'sign_up' | translate }}</a></li>
<li ng-if="!isAuthenticated()">
<a href="#" class="font-sbold label text-md" ng-click="login($event)"><i class="fa fa-sign-in"></i> {{ 'sign_in' | translate }}</a>
</li>
</ul>

View File

@ -56,7 +56,7 @@
<i class="fa fa-calendar-o"></i> <span translate>{{ 'my_events' }}</span>
</a>
</li>
<li class="hidden-sm hidden-md hidden-lg" ng-if-end>
<li class="hidden-sm hidden-md hidden-lg" ng-hide="fablabWithoutInvoices" ng-if-end>
<a href="#" ui-sref="app.logged.dashboard.invoices">
<i class="fa fa-file-pdf-o"></i> <span translate>{{ 'my_invoices' }}</span>
</a>

View File

@ -247,6 +247,12 @@
<span class="exponent"><i class="fa fa-asterisk" aria-hidden="true"></i></span></label>
</div>
</div>
<div vc-recaptcha
key="recaptchaSiteKey"
class="flex-center"
ng-model="user.recaptcha"
ng-if="recaptchaSiteKey">
</div>
<span class="info-required">
<span class="exponent"><i class="fa fa-asterisk" aria-hidden="true"></i></span>
<span translate>{{ 'field_required' }}</span>

View File

@ -40,7 +40,7 @@
</div>
<hr/>
<div class="text-right m-t">
<div class="text-right m-t" ng-hide="fablabWithoutInvoices">
<label for="generate_avoir" translate>{{ 'generate_a_refund_invoice' }}</label>
<div class="inline m-l">
<input bs-switch

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
# API Controller for exporting accounting data to external accounting softwares
class API::AccountingExportsController < API::ApiController
before_action :authenticate_user!
def export
authorize :accounting_export
export = Export.where(category: 'accounting', export_type: 'accounting-software', key: params[:key])
.where(extension: params[:extension], query: params[:query])
.where('created_at > ?', Invoice.maximum('updated_at'))
.last
if export.nil? || !FileTest.exist?(export.file)
@export = Export.new(
category: 'accounting',
export_type: params[:type],
user: current_user,
extension: params[:extension],
query: params[:query],
key: params[:key]
)
if @export.save
render json: { export_id: @export.id }, status: :ok
else
render json: @export.errors, status: :unprocessable_entity
end
else
send_file File.join(Rails.root, export.file),
type: 'text/csv',
disposition: 'attachment'
end
end
end

View File

@ -8,10 +8,17 @@ class API::ExportsController < API::ApiController
def download
authorize @export
mime_type = if @export.extension == 'xlsx'
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
elsif @export.extension == 'csv'
'text/csv'
else
'application/octet-stream'
end
if FileTest.exist?(@export.file)
send_file File.join(Rails.root, @export.file),
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
type: mime_type,
disposition: 'attachment'
else
render text: I18n.t('errors.messages.export_not_found'), status: :not_found
@ -21,28 +28,14 @@ class API::ExportsController < API::ApiController
def status
authorize Export
export = Export.where(category: params[:category], export_type: params[:type], query: params[:query], key: params[:key])
if params[:category] == 'users'
case params[:type]
when 'subscriptions'
export = export.where('created_at > ?', Subscription.maximum('updated_at'))
when 'reservations'
export = export.where('created_at > ?', Reservation.maximum('updated_at'))
when 'members'
export = export.where('created_at > ?', User.with_role(:member).maximum('updated_at'))
else
raise ArgumentError, "Unknown export users/#{params[:type]}"
end
elsif params[:category] == 'availabilities'
case params[:type]
when 'index'
export = export.where('created_at > ?', [Availability.maximum('updated_at'), Reservation.maximum('updated_at')].max)
else
raise ArgumentError, "Unknown type availabilities/#{params[:type]}"
end
end
export = export.last
exports = Export.where(
category: params[:category],
export_type: params[:type],
query: params[:query],
key: params[:key],
extension: params[:extension]
)
export = retrieve_last_export(exports, params[:category], params[:type])
if export.nil? || !FileTest.exist?(export.file)
render json: { exists: false, id: nil }, status: :ok
@ -53,6 +46,39 @@ class API::ExportsController < API::ApiController
private
def retrieve_last_export(export, category, type)
case category
when 'users'
case type
when 'subscriptions'
export = export.where('created_at > ?', Subscription.maximum('updated_at'))
when 'reservations'
export = export.where('created_at > ?', Reservation.maximum('updated_at'))
when 'members'
export = export.where('created_at > ?', User.with_role(:member).maximum('updated_at'))
else
raise ArgumentError, "Unknown export users/#{type}"
end
when 'availabilities'
case type
when 'index'
export = export.where('created_at > ?', [Availability.maximum('updated_at'), Reservation.maximum('updated_at')].max)
else
raise ArgumentError, "Unknown type availabilities/#{type}"
end
when 'accounting'
case type
when 'acd'
export = export.where('created_at > ?', Invoice.maximum('updated_at'))
else
raise ArgumentError, "Unknown type accounting/#{type}"
end
else
raise ArgumentError, "Unknown category #{category}"
end
export.last
end
def set_export
@export = Export.find(params[:id])
end

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
# API Controller for resources of type Import
class API::ImportsController < API::ApiController
before_action :authenticate_user!
def show
authorize Import
@import = Import.find(params[:id])
end
def members
authorize Import
@import = Import.new(
attachment: import_params,
user: current_user,
update_field: params[:update_field],
category: 'members'
)
if @import.save
render json: { id: @import.id }, status: :created
else
render json: @import.errors, status: :unprocessable_entity
end
end
private
def import_params
params.require(:import_members)
end
end

View File

@ -51,10 +51,16 @@ class API::InvoicesController < API::ApiController
end
end
def first
authorize Invoice
invoice = Invoice.order(:created_at).first
@first = invoice&.created_at
end
private
def avoir_params
params.require(:avoir).permit(:invoice_id, :avoir_date, :avoir_mode, :subscription_to_expire, :description,
params.require(:avoir).permit(:invoice_id, :avoir_date, :payment_method, :subscription_to_expire, :description,
invoice_items_ids: [])
end

View File

@ -18,6 +18,19 @@ class API::SettingsController < API::ApiController
end
end
def bulk_update
authorize Setting
@settings = []
params[:settings].each do |setting|
next if !setting[:name] || !setting[:value]
db_setting = Setting.find_or_initialize_by(name: setting[:name])
db_setting.save && db_setting.history_values.create(value: setting[:value], invoicing_profile: current_user.invoicing_profile)
@settings.push db_setting
end
end
def show
@setting = Setting.find_or_create_by(name: params[:name])
@show_history = params[:history] == 'true' && current_user.admin?

View File

@ -13,17 +13,14 @@ class API::UsersController < API::ApiController
end
def create
if current_user.admin?
res = UserService.create_partner(partner_params)
authorize User
res = UserService.create_partner(partner_params)
if res[:saved]
@user = res[:user]
render status: :created
else
render json: res[:user].errors.full_messages, status: :unprocessable_entity
end
if res[:saved]
@user = res[:user]
render status: :created
else
head 403
render json: res[:user].errors.full_messages, status: :unprocessable_entity
end
end

View File

@ -19,6 +19,12 @@ class ApplicationController < ActionController::Base
def index; end
def sso_redirect
@authorization_token = request.query_parameters[:auth_token]
@authentication_token = form_authenticity_token
@active_provider = AuthProvider.active
end
protected
def set_csrf_cookie

View File

@ -4,6 +4,11 @@
class RegistrationsController < Devise::RegistrationsController
# POST /users.json
def create
# first check the recaptcha
check = RecaptchaService.verify(params[:user][:recaptcha])
render json: check['error-codes'], status: :unprocessable_entity and return unless check['success']
# then create the user
build_resource(sign_up_params)
resource_saved = resource.save

View File

@ -1,9 +1,12 @@
# frozen_string_literal: true
# RSS feed about 10 last events
class Rss::EventsController < Rss::RssController
def index
@events = Event.includes(:event_image, :event_files, :availability, :category)
.where('availabilities.start_at >= ?', Time.now)
.order('availabilities.start_at ASC').references(:availabilities).limit(10)
.where('availabilities.start_at >= ?', Time.now)
.order('availabilities.start_at ASC').references(:availabilities).limit(10)
@fab_name = Setting.find_by(name: 'fablab_name').value
end
end

View File

@ -6,7 +6,7 @@ class SessionsController < Devise::SessionsController
def new
active_provider = AuthProvider.active
if active_provider.providable_type != DatabaseProvider.name
redirect_to "/users/auth/#{active_provider.strategy_name}"
redirect_post "/users/auth/#{active_provider.strategy_name}", params: { authenticity_token: form_authenticity_token }
else
super
end

View File

@ -17,7 +17,7 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
# unique random string, because:
# - if it is the same user, his email will be filled from the SSO when he merge his accounts
# - if it is not the same user, this will prevent the raise of PG::UniqueViolation
if active_provider.sso_fields.include?('user.email') and email_exists?(@user.email)
if active_provider.sso_fields.include?('user.email') && email_exists?(@user.email)
old_mail = @user.email
@user.email = "<#{old_mail}>#{Devise.friendly_token}-duplicate"
flash[:alert] = t('omniauth.email_already_linked_to_another_account_please_input_your_authentication_code', OLD_MAIL: old_mail)

View File

@ -1,5 +1,8 @@
# frozen_string_literal: true
# Handle most of the emails sent by the platform. Triggered by notifications
class NotificationsMailer < NotifyWith::NotificationsMailer
default :from => ENV['DEFAULT_MAIL_FROM']
default from: ENV['DEFAULT_MAIL_FROM']
layout 'notifications_mailer'
helper :application
@ -9,15 +12,15 @@ class NotificationsMailer < NotifyWith::NotificationsMailer
@recipient = notification.receiver
@attached_object = notification.attached_object
if !respond_to?(notification.notification_type)
class_eval %Q{
unless respond_to?(notification.notification_type)
class_eval %{
def #{notification.notification_type}
mail to: @recipient.email,
subject: t('notifications_mailer.#{notification.notification_type}.subject'),
template_name: '#{notification.notification_type}',
content_type: 'text/html'
end
}
}, __FILE__, __LINE__ - 7
end
send(notification.notification_type)
@ -29,11 +32,15 @@ class NotificationsMailer < NotifyWith::NotificationsMailer
def notify_user_when_invoice_ready
attachments[@attached_object.filename] = File.read(@attached_object.file)
mail(to: @recipient.email, subject: t('notifications_mailer.notify_member_invoice_ready.subject'), template_name: 'notify_member_invoice_ready')
mail(to: @recipient.email,
subject: t('notifications_mailer.notify_member_invoice_ready.subject'),
template_name: 'notify_member_invoice_ready')
end
def notify_user_when_avoir_ready
attachments[@attached_object.filename] = File.read(@attached_object.file)
mail(to: @recipient.email, subject: t('notifications_mailer.notify_member_avoir_ready.subject'), template_name: 'notify_member_avoir_ready')
mail(to: @recipient.email,
subject: t('notifications_mailer.notify_member_avoir_ready.subject'),
template_name: 'notify_member_avoir_ready')
end
end

View File

@ -1,3 +1,6 @@
# frozen_string_literal: true
# Handle emails related to users accounts, at Devise level
class UsersMailer < BaseMailer
def notify_user_account_created(user, generated_password)
@user = user

View File

@ -1,5 +1,8 @@
# frozen_string_literal: true
require 'file_size_validator'
# Generic class, parent of uploadable items
class Asset < ActiveRecord::Base
belongs_to :viewable, polymorphic: true
end

View File

@ -5,50 +5,12 @@
class Avoir < Invoice
belongs_to :invoice
validates :avoir_mode, inclusion: { in: %w[stripe cheque transfer none cash wallet] }
validates :payment_method, inclusion: { in: %w[stripe cheque transfer none cash wallet] }
attr_accessor :invoice_items_ids
def generate_reference
pattern = Setting.find_by(name: 'invoice_reference').value
# invoice number per day (dd..dd)
reference = pattern.gsub(/d+(?![^\[]*\])/) do |match|
pad_and_truncate(number_of_invoices('day'), match.to_s.length)
end
# invoice number per month (mm..mm)
reference.gsub!(/m+(?![^\[]*\])/) do |match|
pad_and_truncate(number_of_invoices('month'), match.to_s.length)
end
# invoice number per year (yy..yy)
reference.gsub!(/y+(?![^\[]*\])/) do |match|
pad_and_truncate(number_of_invoices('year'), match.to_s.length)
end
# full year (YYYY)
reference.gsub!(/YYYY(?![^\[]*\])/, created_at.strftime('%Y'))
# year without century (YY)
reference.gsub!(/YY(?![^\[]*\])/, created_at.strftime('%y'))
# abbreviated month name (MMM)
reference.gsub!(/MMM(?![^\[]*\])/, created_at.strftime('%^b'))
# month of the year, zero-padded (MM)
reference.gsub!(/MM(?![^\[]*\])/, created_at.strftime('%m'))
# month of the year, non zero-padded (M)
reference.gsub!(/M(?![^\[]*\])/, created_at.strftime('%-m'))
# day of the month, zero-padded (DD)
reference.gsub!(/DD(?![^\[]*\])/, created_at.strftime('%d'))
# day of the month, non zero-padded (DD)
reference.gsub!(/DD(?![^\[]*\])/, created_at.strftime('%-d'))
# information about refund/avoir (R[text])
reference.gsub!(/R\[([^\]]+)\]/, '\1')
# remove information about online selling (X[text])
reference.gsub!(/X\[([^\]]+)\]/, ''.to_s)
self.reference = reference
self.reference = InvoiceReferenceService.generate_reference(self, date: created_at, avoir: true)
end
def expire_subscription

View File

@ -1,3 +1,7 @@
# frozen_string_literal: true
# Validates uploaded images to check that it matches the env parameters
# You must `include ImageValidatorConcern` in your class to use it
module ImageValidatorConcern
extend ActiveSupport::Concern

View File

@ -21,7 +21,7 @@ class Export < ActiveRecord::Base
end
def filename
"#{export_type}-#{id}_#{created_at.strftime('%d%m%Y')}.xlsx"
"#{export_type}-#{id}_#{created_at.strftime('%d%m%Y')}.#{extension}"
end
private
@ -34,6 +34,8 @@ class Export < ActiveRecord::Base
UsersExportWorker.perform_async(id)
when 'availabilities'
AvailabilitiesExportWorker.perform_async(id)
when 'accounting'
AccountingExportWorker.perform_async(id)
else
raise NoMethodError, "Unknown export service for #{category}/#{export_type}"
end

View File

@ -1,3 +1,6 @@
# frozen_string_literal: true
# Group is way to bind users with prices. Different prices can be defined for each plan/reservable, for each group
class Group < ActiveRecord::Base
has_many :plans
has_many :users
@ -12,15 +15,21 @@ class Group < ActiveRecord::Base
friendly_id :name, use: :slugged
validates :name, :slug, presence: true
validates :disabled, inclusion: { in: [false] }, if: :group_has_users?
after_create :create_prices
after_create :create_statistic_subtype
after_update :update_statistic_subtype, if: :name_changed?
after_update :disable_plans, if: :disabled_changed?
def destroyable?
users.empty? and plans.empty?
end
def group_has_users?
users.count.positive?
end
private
def create_prices
@ -60,4 +69,10 @@ class Group < ActiveRecord::Base
subtype.label = name
subtype.save!
end
def disable_plans
plans.each do |plan|
plan.update_attributes(disabled: disabled)
end
end
end

View File

@ -25,14 +25,6 @@ class HistoryValue < ActiveRecord::Base
private
def compute_footprint
max_date = created_at || Time.current
previous = HistoryValue.where('created_at < ?', max_date)
.order('created_at DESC')
.limit(1)
columns = HistoryValue.columns.map(&:name)
.delete_if { |c| %w[footprint updated_at].include? c }
Checksum.text("#{columns.map { |c| self[c] }.join}#{previous.first ? previous.first.footprint : ''}")
FootprintService.compute_footprint(HistoryValue, self, 'created_at')
end
end

31
app/models/import.rb Normal file
View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
require 'file_size_validator'
# An Import is a file uploaded by an user that provides some data to the database.
# Currently, this is used to import some users from a CSV file
class Import < ActiveRecord::Base
mount_uploader :attachment, ImportUploader
belongs_to :user
validates :attachment, file_size: { maximum: Rails.application.secrets.max_import_size&.to_i || 5.megabytes.to_i }
validates :attachment, file_mime_type: { content_type: %w[text/csv text/comma-separated-values application/vnd.ms-excel] }
after_commit :proceed_import, on: [:create]
def results_hash
YAML.safe_load(results, [Symbol]) if results
end
private
def proceed_import
case category
when 'members'
MembersImportWorker.perform_async(id)
else
raise NoMethodError, "Unknown import service for #{category}"
end
end
end

View File

@ -48,52 +48,7 @@ class Invoice < ActiveRecord::Base
end
def generate_reference
pattern = Setting.find_by(name: 'invoice_reference').value
# invoice number per day (dd..dd)
reference = pattern.gsub(/d+(?![^\[]*\])/) do |match|
pad_and_truncate(number_of_invoices('day'), match.to_s.length)
end
# invoice number per month (mm..mm)
reference.gsub!(/m+(?![^\[]*\])/) do |match|
pad_and_truncate(number_of_invoices('month'), match.to_s.length)
end
# invoice number per year (yy..yy)
reference.gsub!(/y+(?![^\[]*\])/) do |match|
pad_and_truncate(number_of_invoices('year'), match.to_s.length)
end
# full year (YYYY)
reference.gsub!(/YYYY(?![^\[]*\])/, Time.now.strftime('%Y'))
# year without century (YY)
reference.gsub!(/YY(?![^\[]*\])/, Time.now.strftime('%y'))
# abreviated month name (MMM)
reference.gsub!(/MMM(?![^\[]*\])/, Time.now.strftime('%^b'))
# month of the year, zero-padded (MM)
reference.gsub!(/MM(?![^\[]*\])/, Time.now.strftime('%m'))
# month of the year, non zero-padded (M)
reference.gsub!(/M(?![^\[]*\])/, Time.now.strftime('%-m'))
# day of the month, zero-padded (DD)
reference.gsub!(/DD(?![^\[]*\])/, Time.now.strftime('%d'))
# day of the month, non zero-padded (DD)
reference.gsub!(/DD(?![^\[]*\])/, Time.now.strftime('%-d'))
# information about online selling (X[text])
if paid_with_stripe?
reference.gsub!(/X\[([^\]]+)\]/, '\1')
else
reference.gsub!(/X\[([^\]]+)\]/, ''.to_s)
end
# information about wallet (W[text])
# reference.gsub!(/W\[([^\]]+)\]/, ''.to_s)
# remove information about refunds (R[text])
reference.gsub!(/R\[([^\]]+)\]/, ''.to_s)
self.reference = reference
self.reference = InvoiceReferenceService.generate_reference(self)
end
def update_reference
@ -102,43 +57,7 @@ class Invoice < ActiveRecord::Base
end
def order_number
pattern = Setting.find_by(name: 'invoice_order-nb').value
# global invoice number (nn..nn)
reference = pattern.gsub(/n+(?![^\[]*\])/) do |match|
pad_and_truncate(number_of_invoices('global'), match.to_s.length)
end
# invoice number per year (yy..yy)
reference.gsub!(/y+(?![^\[]*\])/) do |match|
pad_and_truncate(number_of_invoices('year'), match.to_s.length)
end
# invoice number per month (mm..mm)
reference.gsub!(/m+(?![^\[]*\])/) do |match|
pad_and_truncate(number_of_invoices('month'), match.to_s.length)
end
# invoice number per day (dd..dd)
reference.gsub!(/d+(?![^\[]*\])/) do |match|
pad_and_truncate(number_of_invoices('day'), match.to_s.length)
end
# full year (YYYY)
reference.gsub!(/YYYY(?![^\[]*\])/, created_at.strftime('%Y'))
# year without century (YY)
reference.gsub!(/YY(?![^\[]*\])/, created_at.strftime('%y'))
# abbreviated month name (MMM)
reference.gsub!(/MMM(?![^\[]*\])/, created_at.strftime('%^b'))
# month of the year, zero-padded (MM)
reference.gsub!(/MM(?![^\[]*\])/, created_at.strftime('%m'))
# month of the year, non zero-padded (M)
reference.gsub!(/M(?![^\[]*\])/, created_at.strftime('%-m'))
# day of the month, zero-padded (DD)
reference.gsub!(/DD(?![^\[]*\])/, created_at.strftime('%d'))
# day of the month, non zero-padded (DD)
reference.gsub!(/DD(?![^\[]*\])/, created_at.strftime('%-d'))
reference
InvoiceReferenceService.generate_order_number(self)
end
# for debug & used by rake task "fablab:maintenance:regenerate_invoices"
@ -148,7 +67,7 @@ class Invoice < ActiveRecord::Base
end
def build_avoir(attrs = {})
raise Exception if refunded? === true || prevent_refund?
raise Exception if refunded? == true || prevent_refund?
avoir = Avoir.new(dup.attributes)
avoir.type = 'Avoir'
@ -189,7 +108,7 @@ class Invoice < ActiveRecord::Base
def subscription_invoice?
invoice_items.each do |ii|
return true if ii.subscription && !ii.subscription.expired?
return true if ii.subscription
end
false
end
@ -230,6 +149,18 @@ class Invoice < ActiveRecord::Base
total - (wallet_amount || 0)
end
# return a summary of the payment means used
def payment_means
res = []
res.push(means: :wallet, amount: wallet_amount) if wallet_transaction && wallet_amount.positive?
if paid_with_stripe?
res.push(means: :card, amount: amount_paid)
else
res.push(means: :other, amount: amount_paid)
end
res
end
def add_environment
self.environment = Rails.env
end
@ -244,21 +175,21 @@ class Invoice < ActiveRecord::Base
end
def set_wallet_transaction(amount, transaction_id)
if check_footprint
update_columns(wallet_amount: amount, wallet_transaction_id: transaction_id)
chain_record
else
raise InvalidFootprintError
end
raise InvalidFootprintError unless check_footprint
update_columns(wallet_amount: amount, wallet_transaction_id: transaction_id)
chain_record
end
def paid_with_stripe?
stp_payment_intent_id? || stp_invoice_id?
stp_payment_intent_id? || stp_invoice_id? || payment_method == 'stripe'
end
private
def generate_and_send_invoice
return if Rails.application.secrets.fablab_without_invoices == 'true'
unless Rails.env.test?
puts "Creating an InvoiceWorker job to generate the following invoice: id(#{id}), invoiced_id(#{invoiced_id}), " \
"invoiced_type(#{invoiced_type}), user_id(#{invoicing_profile.user_id})"
@ -266,50 +197,8 @@ class Invoice < ActiveRecord::Base
InvoiceWorker.perform_async(id, user&.subscription&.expired_at)
end
##
# Output the given integer with leading zeros. If the given value is longer than the given
# length, it will be truncated.
# @param value {Integer} the integer to pad
# @param length {Integer} the length of the resulting string.
##
def pad_and_truncate(value, length)
value.to_s.rjust(length, '0').gsub(/^.*(.{#{length},}?)$/m, '\1')
end
##
# Returns the number of current invoices in the given range around the current date.
# If range is invalid or not specified, the total number of invoices is returned.
# @param range {String} 'day', 'month', 'year'
# @return {Integer}
##
def number_of_invoices(range)
case range.to_s
when 'day'
start = DateTime.current.beginning_of_day
ending = DateTime.current.end_of_day
when 'month'
start = DateTime.current.beginning_of_month
ending = DateTime.current.end_of_month
when 'year'
start = DateTime.current.beginning_of_year
ending = DateTime.current.end_of_year
else
return id
end
return Invoice.count unless defined? start && defined? ending
Invoice.where('created_at >= :start_date AND created_at < :end_date', start_date: start, end_date: ending).length
end
def compute_footprint
previous = Invoice.where('id < ?', id)
.order('id DESC')
.limit(1)
columns = Invoice.columns.map(&:name)
.delete_if { |c| %w[footprint updated_at].include? c }
Checksum.text("#{columns.map { |c| self[c] }.join}#{previous.first ? previous.first.footprint : ''}")
FootprintService.compute_footprint(Invoice, self)
end
def log_changes

View File

@ -21,17 +21,29 @@ class InvoiceItem < ActiveRecord::Base
footprint == compute_footprint
end
def amount_after_coupon
# deduct coupon discount
coupon_service = CouponService.new
coupon_service.ventilate(invoice.total, amount, invoice.coupon)
end
# return the item amount, coupon discount deducted, if any, and VAT excluded, if applicable
def net_amount
# deduct VAT
vat_service = VatHistoryService.new
vat_rate = vat_service.invoice_vat(invoice)
Rational(amount_after_coupon / (vat_rate / 100.00 + 1)).round.to_f
end
# return the VAT amount for this item
def vat
amount_after_coupon - net_amount
end
private
def compute_footprint
previous = InvoiceItem.where('id < ?', id)
.order('id DESC')
.limit(1)
columns = InvoiceItem.columns.map(&:name)
.delete_if { |c| %w[footprint updated_at].include? c }
Checksum.text("#{columns.map { |c| self[c] }.join}#{previous.first ? previous.first.footprint : ''}")
FootprintService.compute_footprint(InvoiceItem, self)
end
def log_changes

View File

@ -1,3 +1,6 @@
# frozen_string_literal: true
# Machine is an hardware equipment hosted in the fablab that is available for reservation to the members
class Machine < ActiveRecord::Base
extend FriendlyId
friendly_id :name, use: :slugged
@ -7,12 +10,12 @@ class Machine < ActiveRecord::Base
has_many :machine_files, as: :viewable, dependent: :destroy
accepts_nested_attributes_for :machine_files, allow_destroy: true, reject_if: :all_blank
has_and_belongs_to_many :projects, join_table: :projects_machines
has_and_belongs_to_many :projects, join_table: 'projects_machines'
has_many :machines_availabilities, dependent: :destroy
has_many :availabilities, through: :machines_availabilities
has_and_belongs_to_many :trainings, join_table: :trainings_machines
has_and_belongs_to_many :trainings, join_table: 'trainings_machines'
validates :name, presence: true, length: { maximum: 50 }
validates :description, presence: true
@ -39,18 +42,20 @@ class Machine < ActiveRecord::Base
def create_statistic_subtype
index = StatisticIndex.where(es_type_key: 'machine')
StatisticSubType.create!({statistic_types: index.first.statistic_types, key: self.slug, label: self.name})
StatisticSubType.create!(statistic_types: index.first.statistic_types, key: slug, label: name)
end
def update_statistic_subtype
index = StatisticIndex.where(es_type_key: 'machine')
subtype = StatisticSubType.joins(statistic_type_sub_types: :statistic_type).where(key: self.slug, statistic_types: { statistic_index_id: index.first.id }).first
subtype.label = self.name
subtype = StatisticSubType.joins(statistic_type_sub_types: :statistic_type)
.where(key: slug, statistic_types: { statistic_index_id: index.first.id })
.first
subtype.label = name
subtype.save!
end
def remove_statistic_subtype
subtype = StatisticSubType.where(key: self.slug).first
subtype = StatisticSubType.where(key: slug).first
subtype.destroy!
end

View File

@ -46,6 +46,7 @@ class NotificationType
notify_admin_close_period_reminder
notify_admin_archive_complete
notify_privacy_policy_changed
notify_admin_import_complete
]
# deprecated:
# - notify_member_subscribed_plan_is_changed

View File

@ -1,3 +1,7 @@
# frozen_string_literal: true
# Plan is a generic description of a subscription plan, which can be subscribed by a member to benefit from advantageous prices.
# Subscribers can also get some Credits for some reservable items
class Plan < ActiveRecord::Base
belongs_to :group
@ -19,6 +23,7 @@ class Plan < ActiveRecord::Base
after_create :create_machines_prices
after_create :create_spaces_prices
after_create :create_statistic_type
after_create :set_name
validates :amount, :group, :base_name, presence: true
@ -53,13 +58,13 @@ class Plan < ActiveRecord::Base
def create_machines_prices
Machine.all.each do |machine|
Price.create(priceable: machine, plan: self, group_id: self.group_id, amount: 0)
Price.create(priceable: machine, plan: self, group_id: group_id, amount: 0)
end
end
def create_spaces_prices
Space.all.each do |space|
Price.create(priceable: space, plan: self, group_id: self.group_id, amount: 0)
Price.create(priceable: space, plan: self, group_id: group_id, amount: 0)
end
end
@ -105,8 +110,12 @@ class Plan < ActiveRecord::Base
if !stat_type.nil? && !stat_subtype.nil?
StatisticTypeSubType.create!(statistic_type: stat_type, statistic_sub_type: stat_subtype)
else
puts 'ERROR: Unable to create the statistics association for the new plan. '+
puts 'ERROR: Unable to create the statistics association for the new plan. ' \
'Possible causes: the type or the subtype were not created successfully.'
end
end
def set_name
update_columns(name: human_readable_name)
end
end

View File

@ -1,6 +1,9 @@
# frozen_string_literal: true
# CAO file attached to a project documentation
class ProjectCao < Asset
mount_uploader :attachment, ProjectCaoUploader
validates :attachment, file_size: { maximum: 20.megabytes.to_i }
validates :attachment, :file_mime_type => { :content_type => ENV['ALLOWED_MIME_TYPES'].split(' ') }
validates :attachment, file_size: { maximum: Rails.application.secrets.max_cao_size&.to_i || 5.megabytes.to_i }
validates :attachment, file_mime_type: { content_type: ENV['ALLOWED_MIME_TYPES'].split(' ') }
end

View File

@ -46,7 +46,6 @@ class Reservation < ActiveRecord::Base
raise LockedError if slot.availability.lock
end
case reservable
# === Machine reservation ===
@ -72,7 +71,6 @@ class Reservation < ActiveRecord::Base
)
end
# === Training reservation ===
when Training
base_amount = reservable.amount_by_group(user.group_id).amount
@ -99,12 +97,18 @@ class Reservation < ActiveRecord::Base
amount += ticket.booked * ticket.event_price_category.amount
end
slots.each do |slot|
description = "#{reservable.name} "
(slot.start_at.to_date..slot.end_at.to_date).each do |d|
description += "\n" if slot.start_at.to_date != slot.end_at.to_date
description += "#{I18n.l d, format: :long} #{I18n.l slot.start_at, format: :hour_minute}" \
" - #{I18n.l slot.end_at, format: :hour_minute}"
end
description = "#{reservable.name}\n"
description += if slot.start_at.to_date != slot.end_at.to_date
I18n.t('events.from_STARTDATE_to_ENDDATE',
STARTDATE: I18n.l(slot.start_at.to_date, format: :long),
ENDDATE: I18n.l(slot.end_at.to_date, format: :long)) + ' ' +
I18n.t('events.from_STARTTIME_to_ENDTIME',
STARTTIME: I18n.l(slot.start_at, format: :hour_minute),
ENDTIME: I18n.l(slot.end_at, format: :hour_minute))
else
"#{I18n.l slot.start_at.to_date, format: :long} #{I18n.l slot.start_at, format: :hour_minute}" \
" - #{I18n.l slot.end_at, format: :hour_minute}"
end
ii_amount = amount
ii_amount = 0 if slot.offered && on_site
invoice.invoice_items.push InvoiceItem.new(
@ -202,11 +206,14 @@ class Reservation < ActiveRecord::Base
end
def save_with_payment(operator_profile_id, coupon_code = nil, payment_intent_id = nil)
method = InvoicingProfile.find(operator_profile_id)&.user&.admin? ? nil : 'stripe'
build_invoice(
invoicing_profile: user.invoicing_profile,
statistic_profile: user.statistic_profile,
operator_profile_id: operator_profile_id,
stp_payment_intent_id: payment_intent_id
stp_payment_intent_id: payment_intent_id,
payment_method: method
)
generate_invoice_items(true, coupon_code)

View File

@ -40,7 +40,28 @@ class Setting < ActiveRecord::Base
visibility_yearly
visibility_others
display_name_enable
machines_sort_by] }
machines_sort_by
accounting_journal_code
accounting_card_client_code
accounting_card_client_label
accounting_wallet_client_code
accounting_wallet_client_label
accounting_other_client_code
accounting_other_client_label
accounting_wallet_code
accounting_wallet_label
accounting_VAT_code
accounting_VAT_label
accounting_subscription_code
accounting_subscription_label
accounting_Machine_code
accounting_Machine_label
accounting_Training_code
accounting_Training_label
accounting_Event_code
accounting_Event_label
accounting_Space_code
accounting_Space_label] }
after_update :update_stylesheet, :notify_privacy_policy_changed if :value_changed?

View File

@ -36,5 +36,4 @@ class StatisticProfile < ActiveRecord::Base
''
end
end
end

View File

@ -46,6 +46,7 @@ class Subscription < ActiveRecord::Base
def generate_invoice(operator_profile_id, coupon_code = nil, payment_intent_id = nil)
coupon_id = nil
total = plan.amount
method = InvoicingProfile.find(operator_profile_id)&.user&.admin? ? nil : 'stripe'
unless coupon_code.nil?
@coupon = Coupon.find_by(code: coupon_code)
@ -64,7 +65,8 @@ class Subscription < ActiveRecord::Base
total: total,
coupon_id: coupon_id,
operator_profile_id: operator_profile_id,
stp_payment_intent_id: payment_intent_id
stp_payment_intent_id: payment_intent_id,
payment_method: method
)
invoice.invoice_items.push InvoiceItem.new(
amount: plan.amount,

View File

@ -43,6 +43,7 @@ class User < ActiveRecord::Base
accepts_nested_attributes_for :tags, allow_destroy: true
has_many :exports, dependent: :destroy
has_many :imports, dependent: :nullify
# fix for create admin user
before_save do

View File

@ -121,6 +121,8 @@ class PDF::Invoice < Prawn::Document
data = [[I18n.t('invoices.details'), I18n.t('invoices.amount')]]
total_calc = 0
total_ht = 0
total_vat = 0
# going through invoice_items
invoice.invoice_items.each do |item|
@ -184,6 +186,8 @@ class PDF::Invoice < Prawn::Document
data += [[details, number_to_currency(price)]]
total_calc += price
total_ht += item.net_amount
total_vat += item.vat
end
## subtract the coupon, if any
@ -210,15 +214,13 @@ class PDF::Invoice < Prawn::Document
# discount textual description
literal_discount = cp.percent_off
if cp.type == 'amount_off'
literal_discount = number_to_currency(cp.amount_off / 100.00)
end
literal_discount = number_to_currency(cp.amount_off / 100.00) if cp.type == 'amount_off'
# add a row for the coupon
data += [[_t('invoices.coupon_CODE_discount_of_DISCOUNT',
CODE: cp.code,
DISCOUNT: literal_discount,
TYPE: cp.type), number_to_currency(-discount)] ]
TYPE: cp.type), number_to_currency(-discount)]]
end
# total verification
@ -226,20 +228,18 @@ class PDF::Invoice < Prawn::Document
puts "ERROR: totals are NOT equals => expected: #{total}, computed: #{total_calc}" if total_calc != total
# TVA
if Setting.find_by(name: 'invoice_VAT-active').value == 'true'
vat_service = VatHistoryService.new
vat_rate = vat_service.invoice_vat(invoice)
if vat_rate != 0
data += [[I18n.t('invoices.total_including_all_taxes'), number_to_currency(total)]]
vat_service = VatHistoryService.new
vat_rate = vat_service.invoice_vat(invoice)
vat = total / (vat_rate / 100.00 + 1)
data += [[I18n.t('invoices.including_VAT_RATE', RATE: vat_rate), number_to_currency(total - vat)]]
data += [[I18n.t('invoices.including_total_excluding_taxes'), number_to_currency(vat)]]
data += [[I18n.t('invoices.including_VAT_RATE', RATE: vat_rate), number_to_currency(total_vat / 100.00)]]
data += [[I18n.t('invoices.including_total_excluding_taxes'), number_to_currency(total_ht / 100.00)]]
data += [[I18n.t('invoices.including_amount_payed_on_ordering'), number_to_currency(total)]]
# checking the round number
rounded = sprintf('%.2f', vat).to_f + sprintf('%.2f', total - vat).to_f
rounded = sprintf('%.2f', total_vat / 100.00).to_f + sprintf('%.2f', total_ht / 100.00).to_f
if rounded != sprintf('%.2f', total_calc).to_f
puts 'ERROR: rounding the numbers cause an invoice inconsistency. ' +
puts 'ERROR: rounding the numbers cause an invoice inconsistency. ' \
"Total expected: #{sprintf('%.2f', total_calc)}, total computed: #{rounded}"
end
else
@ -279,7 +279,7 @@ class PDF::Invoice < Prawn::Document
move_down 20
if invoice.is_a?(Avoir)
payment_verbose = I18n.t('invoices.refund_on_DATE', DATE:I18n.l(invoice.avoir_date.to_date)) + ' '
case invoice.avoir_mode
case invoice.payment_method
when 'stripe'
payment_verbose += I18n.t('invoices.by_stripe_online_payment')
when 'cheque'

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
# Check the access policies for API::AccountingExportsController
class AccountingExportPolicy < ApplicationPolicy
def export?
user.admin?
end
end

View File

@ -1,5 +1,8 @@
# frozen_string_literal: true
# Check the access policies for API::CouponsController
class CouponPolicy < ApplicationPolicy
%w(index show create update destroy send_to).each do |action|
%w[index show create update destroy send_to].each do |action|
define_method "#{action}?" do
user.admin?
end

View File

@ -1,7 +1,11 @@
# frozen_string_literal: true
# Check the access policies for API::EventsController
class EventPolicy < ApplicationPolicy
# Defines the scope of the events index, depending on the role of the current user
class Scope < Scope
def resolve
if user.nil? or (user and !user.admin?)
if user.nil? || (user && !user.admin?)
scope.includes(:event_image, :event_files, :availability, :category)
.where('availabilities.start_at >= ?', Time.now)
.order('availabilities.start_at ASC')

View File

@ -1,5 +1,8 @@
class ExportPolicy < Struct.new(:user, :export)
%w(export_reservations export_members export_subscriptions export_availabilities download status).each do |action|
# frozen_string_literal: true
# Check the access policies for API::ExportsController
class ExportPolicy < ApplicationPolicy
%w[export_reservations export_members export_subscriptions export_availabilities download status].each do |action|
define_method "#{action}?" do
user.admin?
end

View File

@ -1,3 +1,6 @@
# frozen_string_literal: true
# Check the access policies for API::GroupsController
class GroupPolicy < ApplicationPolicy
def create?
user.admin?

View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
# Check the access policies for API::ImportsController
class ImportPolicy < ApplicationPolicy
def show?
user.admin?
end
def members?
user.admin?
end
end

View File

@ -14,4 +14,8 @@ class InvoicePolicy < ApplicationPolicy
def list?
user.admin?
end
def first?
user.admin?
end
end

View File

@ -1,5 +1,8 @@
# frozen_string_literal: true
# Check the access policies for API::SettingsController
class SettingPolicy < ApplicationPolicy
%w(update).each do |action|
%w[update bulk_update].each do |action|
define_method "#{action}?" do
user.admin?
end

View File

@ -1,4 +1,8 @@
# frozen_string_literal: true
# Check the access policies for API::MembersController and API::UsersController
class UserPolicy < ApplicationPolicy
# Defines the scope of the users index, depending on the role of the current user
class Scope < Scope
def resolve
if user.admin?

View File

@ -2,22 +2,36 @@
# Provides the routine to export the accounting data to an external accounting software
class AccountingExportService
attr_reader :encoding, :format, :separator, :journal_code, :date_format, :columns, :vat_service
include ActionView::Helpers::NumberHelper
def initialize(columns, encoding = 'UTF-8', format = 'CSV', separator = ';', date_format = '%d/%m/%Y')
attr_reader :encoding, :format, :separator, :journal_code, :date_format, :columns, :decimal_separator, :label_max_length,
:export_zeros
def initialize(columns, encoding: 'UTF-8', format: 'CSV', separator: ';')
@encoding = encoding
@format = format
@separator = separator
@decimal_separator = ','
@date_format = '%d/%m/%Y'
@label_max_length = 50
@export_zeros = false
@journal_code = Setting.find_by(name: 'accounting_journal_code')&.value || ''
@date_format = date_format
@columns = columns
@vat_service = VatHistoryService.new
end
def set_options(decimal_separator: ',', date_format: '%d/%m/%Y', label_max_length: 50, export_zeros: false)
@decimal_separator = decimal_separator
@date_format = date_format
@label_max_length = label_max_length
@export_zeros = export_zeros
end
def export(start_date, end_date, file)
# build CVS content
# build CSV content
content = header_row
invoices = Invoice.where('created_at >= ? AND created_at <= ?', start_date, end_date).order('created_at ASC')
invoices = invoices.where('total > 0') unless export_zeros
invoices.each do |i|
content << generate_rows(i)
end
@ -37,134 +51,98 @@ class AccountingExportService
end
def generate_rows(invoice)
"#{client_row(invoice)}\n" \
"#{items_rows(invoice)}" \
"#{vat_row(invoice)}\n"
rows = client_rows(invoice) + items_rows(invoice)
vat = vat_row(invoice)
rows += "#{vat}\n" unless vat.nil?
rows
end
# Generate the "subscription" and "reservation" rows associated with the provided invoice
def items_rows(invoice)
rows = invoice.subscription_invoice? ? "#{subscription_row(invoice)}\n" : ''
if invoice.invoiced_type == 'Reservation'
invoice.invoice_items.each do |item|
items = invoice.invoice_items.select { |ii| ii.subscription.nil? }
items.each do |item|
rows << "#{reservation_row(invoice, item)}\n"
end
elsif invoice.invoiced_type == 'WalletTransaction'
rows << "#{wallet_row(invoice)}\n"
end
rows
end
# Generate the "client" row, which contains the debit to the client account, all taxes included
def client_row(invoice)
total = invoice.total / 100.00
row = ''
columns.each do |column|
case column
when 'journal_code'
row << journal_code
when 'date'
row << invoice.created_at&.strftime(date_format)
when 'account_code'
row << account(invoice, :client)
when 'account_label'
row << account(invoice, :client, :label)
when 'piece'
row << invoice.reference
when 'line_label'
row << label(invoice.invoicing_profile.full_name)
when 'debit_origin'
row << debit_client(invoice, total)
when 'credit_origin'
row << credit_client(invoice, total)
when 'debit_euro'
row << debit_client(invoice, total)
when 'credit_euro'
row << credit_client(invoice, total)
when 'lettering'
row << ''
else
puts "Unsupported column: #{column}"
end
row << separator
# Generate the "client" rows, which contains the debit to the client account, all taxes included
def client_rows(invoice)
rows = ''
invoice.payment_means.each do |details|
rows << row(
invoice,
account(invoice, :client, means: details[:means]),
account(invoice, :client, means: details[:means], type: :label),
details[:amount] / 100.00,
line_label: label(invoice),
debit_method: :debit_client,
credit_method: :credit_client
)
rows << "\n"
end
row
rows
end
# Generate the "reservation" row, which contains the credit to the reservation account, all taxes excluded
def reservation_row(invoice, item)
wo_taxes = (item.amount / (vat_service.invoice_vat(invoice) / 100.00 + 1)) / 100.00
row = ''
columns.each do |column|
case column
when 'journal_code'
row << journal_code
when 'date'
row << invoice.created_at&.strftime(date_format)
when 'account_code'
row << account(invoice, :reservation)
when 'account_label'
row << account(invoice, :reservation, :label)
when 'piece'
row << invoice.reference
when 'line_label'
row << label(item.description)
when 'debit_origin'
row << debit(invoice, wo_taxes)
when 'credit_origin'
row << credit(invoice, wo_taxes)
when 'debit_euro'
row << debit(invoice, wo_taxes)
when 'credit_euro'
row << credit(invoice, wo_taxes)
when 'lettering'
row << ''
else
puts "Unsupported column: #{column}"
end
row << separator
end
row
row(
invoice,
account(invoice, :reservation),
account(invoice, :reservation, type: :label),
item.net_amount / 100.00,
line_label: label(invoice)
)
end
# Generate the "subscription" row, which contains the credit to the subscription account, all taxes excluded
def subscription_row(invoice)
subscription_item = invoice.invoice_items.select(&:subscription).first
wo_taxes = (subscription_item.amount / (vat_service.invoice_vat(invoice) / 100.00 + 1)) / 100.00
row = ''
columns.each do |column|
case column
when 'journal_code'
row << journal_code
when 'date'
row << invoice.created_at&.strftime(date_format)
when 'account_code'
row << account(invoice, :subscription)
when 'account_label'
row << account(invoice, :subscription, :label)
when 'piece'
row << invoice.reference
when 'line_label'
row << label(subscription_item.description)
when 'debit_origin'
row << debit(invoice, wo_taxes)
when 'credit_origin'
row << credit(invoice, wo_taxes)
when 'debit_euro'
row << debit(invoice, wo_taxes)
when 'credit_euro'
row << credit(invoice, wo_taxes)
when 'lettering'
row << ''
else
puts "Unsupported column: #{column}"
end
row << separator
end
row
row(
invoice,
account(invoice, :subscription),
account(invoice, :subscription, type: :label),
subscription_item.net_amount / 100.00,
line_label: label(invoice)
)
end
# Generate the "wallet" row, which contains the credit to the wallet account, all taxes excluded
# This applies to wallet crediting, when an Avoir is generated at this time
def wallet_row(invoice)
row(
invoice,
account(invoice, :wallet),
account(invoice, :wallet, type: :label),
invoice.invoice_items.first.net_amount / 100.00,
line_label: label(invoice)
)
end
# Generate the "VAT" row, which contains the credit to the VAT account, with VAT amount only
def vat_row(invoice)
vat = (invoice.total - (invoice.total / (vat_service.invoice_vat(invoice) / 100.00 + 1))) / 100.00
rate = VatHistoryService.new.invoice_vat(invoice)
# we do not render the VAT row if it was disabled for this invoice
return nil if rate.zero?
row(
invoice,
account(invoice, :vat),
account(invoice, :vat, type: :label),
invoice.invoice_items.map(&:vat).map(&:to_i).reduce(:+) / 100.00,
line_label: label(invoice)
)
end
# Generate a row of the export, filling the configured columns with the provided values
def row(invoice, account_code, account_label, amount, line_label: '', debit_method: :debit, credit_method: :credit)
row = ''
columns.each do |column|
case column
@ -173,21 +151,21 @@ class AccountingExportService
when 'date'
row << invoice.created_at&.strftime(date_format)
when 'account_code'
row << account(invoice, :vat)
row << account_code
when 'account_label'
row << account(invoice, :vat, :label)
row << account_label
when 'piece'
row << invoice.reference
when 'line_label'
row << I18n.t('accounting_export.VAT')
row << line_label
when 'debit_origin'
row << debit(invoice, vat)
row << method(debit_method).call(invoice, amount)
when 'credit_origin'
row << credit(invoice, vat)
row << method(credit_method).call(invoice, amount)
when 'debit_euro'
row << debit(invoice, vat)
row << method(debit_method).call(invoice, amount)
when 'credit_euro'
row << credit(invoice, vat)
row << method(credit_method).call(invoice, amount)
when 'lettering'
row << ''
else
@ -199,40 +177,45 @@ class AccountingExportService
end
# Get the account code (or label) for the given invoice and the specified line type (client, vat, subscription or reservation)
def account(invoice, account, type = :code)
res = case account
when :client
Setting.find_by(name: "accounting_client_#{type}")&.value
when :vat
Setting.find_by(name: "accounting_VAT_#{type}")&.value
when :subscription
if invoice.subscription_invoice?
Setting.find_by(name: "accounting_subscription_#{type}")&.value
else
puts "WARN: Invoice #{invoice.id} has no subscription"
end
when :reservation
if invoice.invoiced_type == 'Reservation'
Setting.find_by(name: "accounting_#{invoice.invoiced.reservable_type}_#{type}")&.value
else
puts "WARN: Invoice #{invoice.id} has no reservation"
end
else
puts "Unsupported account #{account}"
end
res || ''
def account(invoice, account, type: :code, means: :other)
case account
when :client
Setting.find_by(name: "accounting_#{means}_client_#{type}")&.value
when :vat
Setting.find_by(name: "accounting_VAT_#{type}")&.value
when :subscription
if invoice.subscription_invoice?
Setting.find_by(name: "accounting_subscription_#{type}")&.value
else
puts "WARN: Invoice #{invoice.id} has no subscription"
end
when :reservation
if invoice.invoiced_type == 'Reservation'
Setting.find_by(name: "accounting_#{invoice.invoiced.reservable_type}_#{type}")&.value
else
puts "WARN: Invoice #{invoice.id} has no reservation"
end
when :wallet
if invoice.invoiced_type == 'WalletTransaction'
Setting.find_by(name: "accounting_wallet_#{type}")&.value
else
puts "WARN: Invoice #{invoice.id} is not a wallet credit"
end
else
puts "Unsupported account #{account}"
end || ''
end
# Fill the value of the "debit" column: if the invoice is a refund, returns the given amount, returns 0 otherwise
def debit(invoice, amount)
avoir = invoice.is_a? Avoir
avoir ? amount.to_s : '0'
avoir ? format_number(amount) : '0'
end
# Fill the value of the "credit" column: if the invoice is a refund, returns 0, otherwise, returns the given amount
def credit(invoice, amount)
avoir = invoice.is_a? Avoir
avoir ? '0' : amount.to_s
avoir ? '0' : format_number(amount)
end
# Fill the value of the "debit" column for the client row: if the invoice is a refund, returns 0, otherwise, returns the given amount
@ -245,9 +228,22 @@ class AccountingExportService
debit(invoice, amount)
end
# Format the given text to match the accounting software rules for the labels
def label(text)
res = text.tr separator, ''
res.truncate(50)
# Format the given number as a string, using the configured separator
def format_number(num)
number_to_currency(num, unit: '', separator: decimal_separator, delimiter: '', precision: 2)
end
# Create a text from the given invoice, matching the accounting software rules for the labels
def label(invoice)
name = "#{invoice.invoicing_profile.last_name} #{invoice.invoicing_profile.first_name}".tr separator, ''
reference = invoice.reference
items = invoice.subscription_invoice? ? [I18n.t('accounting_export.subscription')] : []
items.push I18n.t("accounting_export.#{invoice.reservation.reservable_type}_reservation") if invoice.invoiced_type == 'Reservation'
items.push I18n.t('accounting_export.wallet') if invoice.invoiced_type == 'WalletTransaction'
summary = items.join(' + ')
res = "#{reference}, #{summary}"
"#{name.truncate(label_max_length - res.length)}, #{res}"
end
end

View File

@ -82,7 +82,7 @@ class Availabilities::StatusService
availability.slots.each do |s|
reserved_slots << s if s.canceled_at.nil?
end
reserved_slots.map(&:reservations).flatten.map(&:user_id).include? user.id
reserved_slots.map(&:reservations).flatten.map(&:statistic_profile_id).include? user.statistic_profile&.id
else
false
end

View File

@ -1,3 +1,6 @@
# frozen_string_literal: true
# This class provides helper methods to deal with coupons
class CouponService
##
# Apply the provided coupon, if active, to the given price. Usability tests will be run depending on the
@ -54,4 +57,14 @@ class CouponService
end
price
end
##
# Compute the total amount of the given invoice, without the applied coupon
# Invoice.total stores the amount payed by the customer, coupon deducted
# @param invoice {Invoice} invoice object, its total before discount will be computed
##
def invoice_total_no_coupon(invoice)
total = (invoice.invoice_items.map(&:amount).map(&:to_i).reduce(:+) or 0)
total / 100.0
end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
# Provides helper methods to compute footprints
class FootprintService
# Compute the footprint
# @param class_name Invoice|InvoiceItem|HistoryValue
# @param item an instance of the provided class
# @param sort the items in database by the provided criterion, to find the previous one
def self.compute_footprint(klass, item, sort_on = 'id')
raise TypeError unless item.is_a? klass
previous = klass.where("#{sort_on} < ?", item[sort_on])
.order("#{sort_on} DESC")
.limit(1)
columns = klass.columns.map(&:name)
.delete_if { |c| %w[footprint updated_at].include? c }
Checksum.text("#{columns.map { |c| item[c] }.join}#{previous.first ? previous.first.footprint : ''}")
end
end

View File

@ -0,0 +1,135 @@
# frozen_string_literal: true
# Provides methods to generate invoice references
class InvoiceReferenceService
class << self
def generate_reference(invoice, date: Time.now, avoir: false)
pattern = Setting.find_by(name: 'invoice_reference').value
reference = replace_invoice_number_pattern(pattern, invoice)
reference = replace_date_pattern(reference, date)
if avoir
# information about refund/avoir (R[text])
reference.gsub!(/R\[([^\]]+)\]/, '\1')
# remove information about online selling (X[text])
reference.gsub!(/X\[([^\]]+)\]/, ''.to_s)
else
# information about online selling (X[text])
if invoice.paid_with_stripe?
reference.gsub!(/X\[([^\]]+)\]/, '\1')
else
reference.gsub!(/X\[([^\]]+)\]/, ''.to_s)
end
# remove information about refunds (R[text])
reference.gsub!(/R\[([^\]]+)\]/, ''.to_s)
end
reference
end
def generate_order_number(invoice)
pattern = Setting.find_by(name: 'invoice_order-nb').value
# global invoice number (nn..nn)
reference = pattern.gsub(/n+(?![^\[]*\])/) do |match|
pad_and_truncate(number_of_invoices(invoice, 'global'), match.to_s.length)
end
reference = replace_invoice_number_pattern(reference, invoice)
replace_date_pattern(reference, invoice.created_at)
end
private
##
# Output the given integer with leading zeros. If the given value is longer than the given
# length, it will be truncated.
# @param value {Integer} the integer to pad
# @param length {Integer} the length of the resulting string.
##
def pad_and_truncate(value, length)
value.to_s.rjust(length, '0').gsub(/^.*(.{#{length},}?)$/m, '\1')
end
##
# Returns the number of current invoices in the given range around the current date.
# If range is invalid or not specified, the total number of invoices is returned.
# @param invoice {Invoice}
# @param range {String} 'day', 'month', 'year'
# @return {Integer}
##
def number_of_invoices(invoice, range)
case range.to_s
when 'day'
start = DateTime.current.beginning_of_day
ending = DateTime.current.end_of_day
when 'month'
start = DateTime.current.beginning_of_month
ending = DateTime.current.end_of_month
when 'year'
start = DateTime.current.beginning_of_year
ending = DateTime.current.end_of_year
else
return invoice.id
end
return Invoice.count unless defined? start && defined? ending
Invoice.where('created_at >= :start_date AND created_at < :end_date', start_date: start, end_date: ending).length
end
##
# Replace the date elements in the provided pattern with the date values, from the provided date
# @param reference {string}
# @param date {DateTime}
##
def replace_date_pattern(reference, date)
copy = reference.dup
# full year (YYYY)
copy.gsub!(/YYYY(?![^\[]*\])/, date.strftime('%Y'))
# year without century (YY)
copy.gsub!(/YY(?![^\[]*\])/, date.strftime('%y'))
# abbreviated month name (MMM)
copy.gsub!(/MMM(?![^\[]*\])/, date.strftime('%^b'))
# month of the year, zero-padded (MM)
copy.gsub!(/MM(?![^\[]*\])/, date.strftime('%m'))
# month of the year, non zero-padded (M)
copy.gsub!(/M(?![^\[]*\])/, date.strftime('%-m'))
# day of the month, zero-padded (DD)
copy.gsub!(/DD(?![^\[]*\])/, date.strftime('%d'))
# day of the month, non zero-padded (DD)
copy.gsub!(/DD(?![^\[]*\])/, date.strftime('%-d'))
copy
end
##
# Replace the invoice number elements in the provided pattern with counts from the database
# @param reference {string}
# @param invoice {Invoice}
##
def replace_invoice_number_pattern(reference, invoice)
copy = reference.dup
# invoice number per year (yy..yy)
copy.gsub!(/y+(?![^\[]*\])/) do |match|
pad_and_truncate(number_of_invoices(invoice, 'year'), match.to_s.length)
end
# invoice number per month (mm..mm)
copy.gsub!(/m+(?![^\[]*\])/) do |match|
pad_and_truncate(number_of_invoices(invoice, 'month'), match.to_s.length)
end
# invoice number per day (dd..dd)
copy.gsub!(/d+(?![^\[]*\])/) do |match|
pad_and_truncate(number_of_invoices(invoice, 'day'), match.to_s.length)
end
copy
end
end
end

View File

@ -0,0 +1,185 @@
# frozen_string_literal: true
# Provides helper methods to bulk-import some users from a CSV file
class Members::ImportService
class << self
def import(import)
require 'csv'
log = []
CSV.foreach(import.attachment.url, headers: true, col_sep: ';') do |row|
begin
password = hide_password(row)
log << { row: row.to_hash }
# try to find member based on import.update_field
user = User.find_by(import.update_field.to_sym => row[import.update_field])
params = row_to_params(row, user, password)
if user
service = Members::MembersService.new(user)
res = service.update(params)
log << { user: user.id, status: 'update', result: res }
else
user = User.new(params)
service = Members::MembersService.new(user)
res = service.create(import.user, params)
log << { user: user.id, status: 'create', result: res }
end
log << user.errors.to_hash unless user.errors.to_hash.empty?
rescue StandardError => e
log << e.to_s
puts e
puts e.backtrace
end
end
log
end
private
def hashify(row, property, value: row[property], key: property.to_sym)
res = {}
res[key] = value if row[property]
res
end
def row_to_params(row, user, password)
res = {}
res.merge! hashify(row, 'id')
res.merge! hashify(row, 'username')
res.merge! hashify(row, 'email')
res.merge! hashify(row, 'password', value: password)
res.merge! hashify(row, 'password', key: :password_confirmation, value: password)
res.merge! hashify(row, 'allow_contact', value: row['allow_contact'] == 'yes', key: :is_allow_contact)
res.merge! hashify(row, 'allow_newsletter', value: row['allow_newsletter'] == 'yes', key: :is_allow_newsletter)
res.merge! hashify(row, 'group', value: group_id(row), key: :group_id)
res.merge! hashify(row, 'tags', value: tag_ids(row), key: :tag_ids)
profile_attributes = profile(row, user)
res[:profile_attributes] = profile_attributes if profile_attributes
invoicing_profile_attributes = invoicing_profile(row, user)
res[:invoicing_profile_attributes] = invoicing_profile_attributes if invoicing_profile_attributes
statistic_profile_attributes = statistic_profile(row, user)
res[:statistic_profile_attributes] = statistic_profile_attributes if statistic_profile_attributes
res
end
def group_id(row)
return unless row['group']
Group.friendly.find(row['group'])&.id
end
def tag_ids(row)
return unless row['tags']
Tag.where(id: row['tags'].split(',')).map(&:id)
end
def profile(row, user)
res = {}
res.merge! hashify(row, 'first_name')
res.merge! hashify(row, 'last_name')
res.merge! hashify(row, 'phone')
res.merge! hashify(row, 'interests', key: :interest)
res.merge! hashify(row, 'softwares', key: :software_mastered)
res.merge! hashify(row, 'website')
res.merge! hashify(row, 'job')
res.merge! hashify(row, 'facebook')
res.merge! hashify(row, 'twitter')
res.merge! hashify(row, 'googleplus', key: :google_plus)
res.merge! hashify(row, 'viadeo')
res.merge! hashify(row, 'linkedin')
res.merge! hashify(row, 'instagram')
res.merge! hashify(row, 'youtube')
res.merge! hashify(row, 'vimeo')
res.merge! hashify(row, 'dailymotion')
res.merge! hashify(row, 'github')
res.merge! hashify(row, 'echosciences')
res.merge! hashify(row, 'pinterest')
res.merge! hashify(row, 'lastfm')
res.merge! hashify(row, 'flickr')
res[:id] = user.profile.id if user&.profile
res
end
def invoicing_profile(row, user)
res = {}
res[:id] = user.invoicing_profile.id if user&.invoicing_profile
address_attributes = address(row, user)
res[:address_attributes] = address_attributes if address_attributes
organization_attributes = organization(row, user)
res[:organization_attributes] = organization_attributes if organization_attributes
res
end
def statistic_profile(row, user)
res = {}
res.merge! hashify(row, 'gender', value: row['gender'] == 'male')
res.merge! hashify(row, 'birthdate', key: :birthday)
res[:id] = user.statistic_profile.id if user&.statistic_profile
training_ids = training_ids(row)
res[:training_ids] = training_ids if training_ids
res
end
def address(row, user)
return unless row['address']
res = { address: row['address'] }
res[:id] = user.invoicing_profile.address.id if user&.invoicing_profile&.address
res
end
def organization(row, user)
return unless row['organization_name']
res = { name: row['organization_name'] }
res[:id] = user.invoicing_profile.organization.id if user&.invoicing_profile&.organization
address_attributes = organization_address(row, user)
res[:address_attributes] = address_attributes if address_attributes
res
end
def organization_address(row, user)
return unless row['organization_address']
res = { address: row['organization_address'] }
res[:id] = user.invoicing_profile.organization.address.id if user&.invoicing_profile&.organization&.address
res
end
def training_ids(row)
return unless row['trainings']
Training.where(id: row['trainings'].split(',')).map(&:id)
end
def hide_password(row)
password = row['password']
row['password'] = '********' if row['password']
password
end
end
end

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
# Provides methods to verify the client captcha on Google's services
class RecaptchaService
class << self
def verify(client_response)
return { 'success' => true } unless recaptcha_enabled?
require 'uri'
require 'net/http'
data = { secret: secret_key, response: client_response }
url = URI.parse('https://www.google.com/recaptcha/api/siteverify')
res = Net::HTTP.post_form(url, data)
JSON.parse(res&.body)
end
def recaptcha_enabled?
secret_key.present? && site_key.present?
end
def secret_key
Rails.application.secrets.recaptcha_secret_key
end
def site_key
Rails.application.secrets.recaptcha_site_key
end
end
end

View File

@ -131,10 +131,12 @@ class StatisticService
next if i.invoice.is_a?(Avoir)
sub = i.subscription
next unless sub
ca = i.amount.to_i / 100.0
ca = CouponService.new.ventilate(get_invoice_total_no_coupon(i.invoice), ca, i.invoice.coupon) unless i.invoice.coupon_id.nil?
cs = CouponService.new
ca = cs.ventilate(cs.invoice_total_no_coupon(i.invoice), ca, i.invoice.coupon) unless i.invoice.coupon_id.nil?
profile = sub.statistic_profile
p = sub.plan
result.push OpenStruct.new({
@ -396,7 +398,8 @@ class StatisticService
end
end
# subtract coupon discount from invoices and refunds
ca = CouponService.new.ventilate(get_invoice_total_no_coupon(invoice), ca, invoice.coupon) unless invoice.coupon_id.nil?
cs = CouponService.new
ca = cs.ventilate(cs.invoice_total_no_coupon(invoice), ca, invoice.coupon) unless invoice.coupon_id.nil?
# divide the result by 100 to convert from centimes to monetary unit
ca.zero? ? ca : ca / 100.0
end
@ -407,7 +410,8 @@ class StatisticService
ca -= ii.amount.to_i
end
# subtract coupon discount from the refund
ca = CouponService.new.ventilate(get_invoice_total_no_coupon(invoice), ca, invoice.coupon) unless invoice.coupon_id.nil?
cs = CouponService.new
ca = cs.ventilate(cs.invoice_total_no_coupon(invoice), ca, invoice.coupon) unless invoice.coupon_id.nil?
ca.zero? ? ca : ca / 100.0
end
@ -488,11 +492,6 @@ class StatisticService
# user_subscription_ca.inject {|sum,x| sum.ca + x.ca } || 0
# end
def get_invoice_total_no_coupon(invoice)
total = (invoice.invoice_items.map(&:amount).map(&:to_i).reduce(:+) or 0)
total / 100.0
end
def find_or_create_user_info_info_list(profile, list)
found = list.select do |l|
l.statistic_profile_id == profile.id

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
require 'abstract_controller'
require 'action_controller'
require 'action_view'
@ -13,7 +15,7 @@ class StatisticsExportService
# query all stats with range arguments
query = MultiJson.load(export.query)
@results = Elasticsearch::Model.client.search({index: 'stats', scroll: '30s', body: query})
@results = Elasticsearch::Model.client.search(index: 'stats', scroll: '30s', body: query)
scroll_id = @results['_scroll_id']
while @results['hits']['hits'].size != @results['hits']['total']
scroll_res = Elasticsearch::Model.client.scroll(scroll: '30s', scroll_id: scroll_id)
@ -22,9 +24,9 @@ class StatisticsExportService
end
ids = @results['hits']['hits'].map { |u| u['_source']['userId'] }
@users = User.includes(:profile).where(:id => ids)
@users = User.includes(:profile).where(id: ids)
@indices = StatisticIndex.all.includes(:statistic_fields, :statistic_types => [:statistic_sub_types])
@indices = StatisticIndex.all.includes(:statistic_fields, statistic_types: [:statistic_sub_types])
ActionController::Base.prepend_view_path './app/views/'
# place data in view_assigns
@ -37,10 +39,10 @@ class StatisticsExportService
content = av.render template: 'exports/statistics_global.xlsx.axlsx'
# write content to file
File.open(export.file,"w+b") {|f| f.puts content }
File.open(export.file, 'w+b') { |f| f.puts content }
end
%w(account event machine project subscription training space).each do |path|
%w[account event machine project subscription training space].each do |path|
class_eval %{
def export_#{path}(export)
@ -76,7 +78,7 @@ class StatisticsExportService
# write content to file
File.open(export.file,"w+b") {|f| f.puts content }
end
}
}, __FILE__, __LINE__ - 35
end
end
end

View File

@ -26,13 +26,21 @@ class VatHistoryService
private
def vat_history
key_dates = []
Setting.find_by(name: 'invoice_VAT-rate').history_values.each do |rate|
key_dates.push(date: rate.created_at, rate: rate.value.to_i)
chronology = []
end_date = DateTime.now
Setting.find_by(name: 'invoice_VAT-active').history_values.order(created_at: 'DESC').each do |v|
chronology.push(start: v.created_at, end: end_date, enabled: v.value == 'true')
end_date = v.created_at
end
Setting.find_by(name: 'invoice_VAT-active').history_values.each do |v|
key_dates.push(date: v.created_at, rate: 0) if v.value == 'false'
date_rates = []
Setting.find_by(name: 'invoice_VAT-rate').history_values.order(created_at: 'ASC').each do |rate|
range = chronology.select { |p| rate.created_at.between?(p[:start], p[:end]) }.first
date = range[:enabled] ? rate.created_at : range[:end]
date_rates.push(date: date, rate: rate.value.to_i)
end
key_dates.sort_by { |k| k[:date] }
chronology.reverse_each do |period|
date_rates.push(date: period[:start], rate: 0) unless period[:enabled]
end
date_rates.sort_by { |k| k[:date] }
end
end
end

View File

@ -51,7 +51,7 @@ class WalletService
avoir.avoir_date = avoir_date
avoir.created_at = avoir_date
avoir.description = description
avoir.avoir_mode = 'wallet'
avoir.payment_method = 'wallet'
avoir.subscription_to_expire = false
avoir.invoicing_profile_id = wallet_transaction.wallet.user.invoicing_profile.id
avoir.total = wallet_transaction.amount * 100.0

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
# CarrierWave uploader for import files.
# This file defines the parameters for these uploads
class ImportUploader < CarrierWave::Uploader::Base
include UploadHelper
# Choose what kind of storage to use for this uploader:
storage :file
after :remove, :delete_empty_dirs
# Override the directory where uploaded files will be stored.
# This is a sensible default for uploaders that are meant to be mounted:
def store_dir
"#{base_store_dir}/#{model.id}"
end
def base_store_dir
'../imports'
end
# Add a white list of extensions which are allowed to be uploaded.
# For images you might use something like this:
def extension_white_list
['csv']
end
end

View File

@ -1,13 +1,13 @@
# frozen_string_literal: true
# CarrierWave uploader for project CAO attachments.
# This file defines the parameters for these uploads
class ProjectCaoUploader < CarrierWave::Uploader::Base
# Include RMagick or MiniMagick support:
# include CarrierWave::RMagick
#include CarrierWave::MiniMagick
include UploadHelper
# Choose what kind of storage to use for this uploader:
storage :file
after :remove, :delete_empty_dirs
# storage :fog
# Override the directory where uploaded files will be stored.
# This is a sensible default for uploaders that are meant to be mounted:
@ -20,31 +20,9 @@ class ProjectCaoUploader < CarrierWave::Uploader::Base
"uploads/#{model.class.to_s.underscore}"
end
# Provide a default URL as a default if there hasn't been a file uploaded:
# def default_url
# # For Rails 3.1+ asset pipeline compatibility:
# # ActionController::Base.helpers.asset_path("fallback/" + [version_name, "default.png"].compact.join('_'))
#
# "/images/fallback/" + [version_name, "default.png"].compact.join('_')
# end
# Process files as they are uploaded:
# process :scale => [200, 300]
#
# def scale(width, height)
# # do something
# end
# Add a white list of extensions which are allowed to be uploaded.
# For images you might use something like this:
def extension_white_list
ENV['ALLOWED_EXTENSIONS'].split(' ')
end
# Override the filename of the uploaded files:
# Avoid using model.id or version_name here, see uploader/store.rb for details.
#def filename
#"avatar.#{file.extension}" if original_filename
#end
end

View File

@ -4,9 +4,7 @@ json.link_to_sso_profile @provider.link_to_sso_profile
if @provider.providable_type == DatabaseProvider.name
json.link_to_sso_connect '/#'
else
json.link_to_sso_connect "/users/auth/#{@provider.strategy_name}"
json.link_to_sso_connect '/sso-redirect'
end
if @provider.providable_type == OAuth2Provider.name
json.domain @provider.providable.domain
end
json.domain @provider.providable.domain if @provider.providable_type == OAuth2Provider.name

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
json.extract! @import, :id, :category, :user_id, :update_field, :created_at, :updated_at
json.results @import.results_hash.to_json
json.user do
json.full_name @import.user&.profile&.full_name
end

View File

@ -1,4 +1,4 @@
json.extract! @avoir, :id, :created_at, :reference, :invoiced_type, :avoir_date, :avoir_mode, :invoice_id
json.extract! @avoir, :id, :created_at, :reference, :invoiced_type, :avoir_date, :payment_method, :invoice_id
json.user_id @avoir.invoicing_profile.user_id
json.total @avoir.total / 100.00
json.name @avoir.user.profile.full_name

View File

@ -0,0 +1 @@
json.date @first

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
json.title notification.notification_type
json.description t('.import_over', CATEGORY: t(".#{notification.attached_object.category}")) +
link_to(t('.view_results'), "#!/admin/members/import/#{notification.attached_object.id}/results")
json.url notification_url(notification, format: :json)

Some files were not shown because too many files have changed in this diff Show More