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:
commit
7d9f42da8f
@ -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
3
.gitignore
vendored
@ -34,6 +34,9 @@
|
||||
# XLSX exports
|
||||
/exports/*
|
||||
|
||||
# CSV imports
|
||||
/imports/*
|
||||
|
||||
# Archives of cLosed accounting periods
|
||||
/accounting/*
|
||||
|
||||
|
@ -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:
|
||||
|
47
CHANGELOG.md
47
CHANGELOG.md
@ -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
|
||||
|
||||
|
85
Dockerfile
85
Dockerfile
@ -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
12
Gemfile
@ -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'
|
||||
|
38
Gemfile.lock
38
Gemfile.lock
@ -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)
|
||||
|
2
Procfile
2
Procfile
@ -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
|
||||
|
11
README.md
11
README.md
@ -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
12
Vagrantfile
vendored
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
}]);
|
||||
|
@ -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)
|
||||
*/
|
||||
|
@ -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'),
|
||||
|
@ -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();
|
||||
}
|
||||
]);
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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: {
|
||||
|
@ -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);
|
7
app/assets/javascripts/services/import.js
Normal file
7
app/assets/javascripts/services/import.js
Normal file
@ -0,0 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
Application.Services.factory('Import', ['$resource', function ($resource) {
|
||||
return $resource('/api/imports/:id',
|
||||
{ id: '@id' }
|
||||
);
|
||||
}]);
|
@ -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'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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; }
|
||||
|
@ -65,6 +65,10 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.modal-xl {
|
||||
width: 900px;
|
||||
}
|
||||
|
||||
// component card
|
||||
.card {
|
||||
position: relative;
|
||||
|
@ -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; }
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
@ -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>
|
||||
|
@ -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:/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>
|
||||
|
@ -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">
|
||||
|
||||
|
||||
|
177
app/assets/templates/admin/members/import.html.erb
Normal file
177
app/assets/templates/admin/members/import.html.erb
Normal 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>
|
66
app/assets/templates/admin/members/import_result.html
Normal file
66
app/assets/templates/admin/members/import_result.html
Normal 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>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
35
app/controllers/api/accounting_exports_controller.rb
Normal file
35
app/controllers/api/accounting_exports_controller.rb
Normal 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
|
@ -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
|
||||
|
34
app/controllers/api/imports_controller.rb
Normal file
34
app/controllers/api/imports_controller.rb
Normal 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
|
@ -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
|
||||
|
||||
|
@ -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?
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
31
app/models/import.rb
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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?
|
||||
|
||||
|
@ -36,5 +36,4 @@ class StatisticProfile < ActiveRecord::Base
|
||||
''
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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'
|
||||
|
8
app/policies/accounting_export_policy.rb
Normal file
8
app/policies/accounting_export_policy.rb
Normal 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
|
@ -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
|
||||
|
@ -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')
|
||||
|
@ -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
|
||||
|
@ -1,3 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Check the access policies for API::GroupsController
|
||||
class GroupPolicy < ApplicationPolicy
|
||||
def create?
|
||||
user.admin?
|
||||
|
12
app/policies/import_policy.rb
Normal file
12
app/policies/import_policy.rb
Normal 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
|
@ -14,4 +14,8 @@ class InvoicePolicy < ApplicationPolicy
|
||||
def list?
|
||||
user.admin?
|
||||
end
|
||||
|
||||
def first?
|
||||
user.admin?
|
||||
end
|
||||
end
|
||||
|
@ -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
|
||||
|
@ -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?
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
21
app/services/footprint_service.rb
Normal file
21
app/services/footprint_service.rb
Normal 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
|
135
app/services/invoice_reference_service.rb
Normal file
135
app/services/invoice_reference_service.rb
Normal 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
|
185
app/services/members/import_service.rb
Normal file
185
app/services/members/import_service.rb
Normal 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
|
31
app/services/recaptcha_service.rb
Normal file
31
app/services/recaptcha_service.rb
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
28
app/uploaders/import_uploader.rb
Normal file
28
app/uploaders/import_uploader.rb
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
|
7
app/views/api/imports/show.json.jbuilder
Normal file
7
app/views/api/imports/show.json.jbuilder
Normal 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
|
@ -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
|
||||
|
1
app/views/api/invoices/first.json.jbuilder
Normal file
1
app/views/api/invoices/first.json.jbuilder
Normal file
@ -0,0 +1 @@
|
||||
json.date @first
|
@ -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
Loading…
Reference in New Issue
Block a user