diff --git a/.deploy/docker/cronjob.conf b/.deploy/docker/cronjob.conf new file mode 100644 index 0000000000..71aacd84eb --- /dev/null +++ b/.deploy/docker/cronjob.conf @@ -0,0 +1,11 @@ +[program:cron] +command=/usr/sbin/cron -f -L 15 +user=root +autostart=true +autorestart=true +stdout_events_enabled=true +stderr_events_enabled=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +startsecs=10 +startretries=3 diff --git a/.deploy/docker/entrypoint.sh b/.deploy/docker/entrypoint.sh index 2a6f7cf013..ff808c1990 100755 --- a/.deploy/docker/entrypoint.sh +++ b/.deploy/docker/entrypoint.sh @@ -22,8 +22,8 @@ chmod -R 775 $FIREFLY_PATH/storage # remove any lingering files that may break upgrades: rm -f $FIREFLY_PATH/storage/logs/laravel.log -cat .env.docker | envsubst > .env && cat .env +cat .env.docker | envsubst > .env composer dump-autoload php artisan package:discover php artisan firefly:instructions install -exec apache2-foreground +exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf --nodaemon \ No newline at end of file diff --git a/.deploy/docker/firefly-iii.conf b/.deploy/docker/firefly-iii.conf new file mode 100644 index 0000000000..5ee1c2eb28 --- /dev/null +++ b/.deploy/docker/firefly-iii.conf @@ -0,0 +1,6 @@ +[program:apache2] +command=/bin/bash -c "source /etc/apache2/envvars && exec /usr/sbin/apache2 -DFOREGROUND" +stdout_events_enabled=true +stderr_events_enabled=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 \ No newline at end of file diff --git a/.deploy/docker/supervisord.conf b/.deploy/docker/supervisord.conf new file mode 100644 index 0000000000..479120ed38 --- /dev/null +++ b/.deploy/docker/supervisord.conf @@ -0,0 +1,30 @@ +# supervisor config file +# Adapted from the config file distributed with the supervisor package on Debian +# Jessie + +# Enable supervisord in non-daemon mode. Disable the logfile as we receive +# log messages via stdout/err. Set up the child process log directory in case +# the user doesn't set logging to stdout/err. +[supervisord] +nodaemon = true +logfile = NONE +pidfile = /var/run/supervisord.pid +childlogdir = /var/log/supervisor +loglevel=debug + +# Enable supervisorctl via RPC interface over Unix socket +[unix_http_server] +file = /var/run/supervisor.sock +chmod = 0700 + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisorctl] +serverurl = unix:///var/run/supervisor.sock + + +# Include conf files for child processes +# Debian/Ubuntu packages use /etc/supervisor/conf.d +[include] +files = /etc/supervisor/conf.d/*.conf \ No newline at end of file diff --git a/.deploy/docker/vhost.conf b/.deploy/docker/vhost.conf new file mode 100644 index 0000000000..f5a07256b0 --- /dev/null +++ b/.deploy/docker/vhost.conf @@ -0,0 +1,26 @@ +server { + listen 80 default_server; + + server_name _ *.vm docker; + + root "/app/public"; + index index.php; + + include /opt/docker/etc/nginx/vhost.common.d/*.conf; +} + +############## +# SSL +############## + +server { + listen 443 default_server; + + server_name _ *.vm docker; + + root "/app/public"; + index index.php; + + include /opt/docker/etc/nginx/vhost.common.d/*.conf; + include /opt/docker/etc/nginx/vhost.ssl.conf; +} \ No newline at end of file diff --git a/.env.docker b/.env.docker index 502b81ef49..d88c6915a7 100644 --- a/.env.docker +++ b/.env.docker @@ -13,15 +13,31 @@ SITE_OWNER=${SITE_OWNER} # Change it to a string of exactly 32 chars or use command `php artisan key:generate` to generate it APP_KEY=${FF_APP_KEY} -# APP_URL and TRUSTED_PROXIES are useful when using Docker and/or a reverse proxy. +# Change this value to your preferred time zone. +# Example: Europe/Amsterdam +TZ=UTC + +# This variable must match your installation's external address but keep in mind that +# it's only used on the command line as a fallback value. APP_URL=${APP_URL} + +# TRUSTED_PROXIES is a useful variable when using Docker and/or a reverse proxy. TRUSTED_PROXIES=${TRUSTED_PROXIES} # The log channel defines where your log entries go to. -LOG_CHANNEL=${LOG_CHANNEL} +# 'daily' is the default logging mode giving you 5 daily rotated log files in /storage/logs/. +# Several other options exist. You can use 'single' for one big fat error log (not recommended). +# Also available are 'syslog', 'errorlog' and 'stdout' which will log to the system itself. +LOG_CHANNEL=stdout + +# Log level. You can set this from least severe to most severe: +# debug, info, notice, warning, error, critical, alert, emergency +# If you set it to debug your logs will grow large, and fast. If you set it to emergency probably +# nothing will get logged, ever. +APP_LOG_LEVEL=${APP_LOG_LEVEL} # Database credentials. Make sure the database exists. I recommend a dedicated user for Firefly III -# If you use SQLite, set connection to `sqlite` and remove the database, username and password settings. +# For other database types, please see the FAQ: http://firefly-iii.readthedocs.io/en/latest/support/faq.html DB_CONNECTION=${FF_DB_CONNECTION} DB_HOST=${FF_DB_HOST} DB_PORT=${FF_DB_PORT} @@ -29,17 +45,6 @@ DB_DATABASE=${FF_DB_NAME} DB_USERNAME=${FF_DB_USER} DB_PASSWORD=${FF_DB_PASSWORD} -# 'daily' is the default logging mode giving you 5 daily rotated log files in /storage/logs/. -# Several other options exist. You can use 'single' for one big fat error log (not recommended). -# Also available are 'syslog' and 'errorlog' which will log to the system itself. -APP_LOG=syslog - -# Log level. You can set this from least severe to most severe: -# debug, info, notice, warning, error, critical, alert, emergency -# If you set it to debug your logs will grow large, and fast. If you set it to emergency probably -# nothing will get logged, ever. -APP_LOG_LEVEL=info - # If you're looking for performance improvements, you could install memcached. CACHE_DRIVER=file SESSION_DRIVER=file @@ -103,4 +108,5 @@ IS_DOCKER=true IS_SANDSTORM=false IS_HEROKU=false BUNQ_USE_SANDBOX=false -TZ=${TZ} +MAILGUN_DOMAIN= +MAILGUN_SECRET= diff --git a/.env.example b/.env.example index 73afaf0470..571115cda6 100644 --- a/.env.example +++ b/.env.example @@ -15,15 +15,27 @@ APP_KEY=SomeRandomStringOf32CharsExactly # Change this value to your preferred time zone. # Example: Europe/Amsterdam -TZ=UTC +TZ=${TZ} -# APP_URL and TRUSTED_PROXIES are useful when using Docker and/or a reverse proxy. +# This variable must match your installation's external address but keep in mind that +# it's only used on the command line as a fallback value. APP_URL=http://localhost + +# TRUSTED_PROXIES is a useful variable when using Docker and/or a reverse proxy. TRUSTED_PROXIES= # The log channel defines where your log entries go to. +# 'daily' is the default logging mode giving you 5 daily rotated log files in /storage/logs/. +# Several other options exist. You can use 'single' for one big fat error log (not recommended). +# Also available are 'syslog', 'errorlog' and 'stdout' which will log to the system itself. LOG_CHANNEL=daily +# Log level. You can set this from least severe to most severe: +# debug, info, notice, warning, error, critical, alert, emergency +# If you set it to debug your logs will grow large, and fast. If you set it to emergency probably +# nothing will get logged, ever. +APP_LOG_LEVEL=notice + # Database credentials. Make sure the database exists. I recommend a dedicated user for Firefly III # For other database types, please see the FAQ: http://firefly-iii.readthedocs.io/en/latest/support/faq.html DB_CONNECTION=mysql @@ -33,17 +45,6 @@ DB_DATABASE=homestead DB_USERNAME=homestead DB_PASSWORD=secret -# 'daily' is the default logging mode giving you 5 daily rotated log files in /storage/logs/. -# Several other options exist. You can use 'single' for one big fat error log (not recommended). -# Also available are 'syslog' and 'errorlog' which will log to the system itself. -APP_LOG=daily - -# Log level. You can set this from least severe to most severe: -# debug, info, notice, warning, error, critical, alert, emergency -# If you set it to debug your logs will grow large, and fast. If you set it to emergency probably -# nothing will get logged, ever. -APP_LOG_LEVEL=notice - # If you're looking for performance improvements, you could install memcached. CACHE_DRIVER=file SESSION_DRIVER=file @@ -105,7 +106,7 @@ DEMO_USERNAME= DEMO_PASSWORD= IS_DOCKER=false IS_SANDSTORM=false -BUNQ_USE_SANDBOX=false IS_HEROKU=false +BUNQ_USE_SANDBOX=false MAILGUN_DOMAIN= MAILGUN_SECRET= diff --git a/.env.heroku b/.env.heroku index 55237bd98b..c16376ea76 100644 --- a/.env.heroku +++ b/.env.heroku @@ -17,12 +17,24 @@ APP_KEY=7ahyYVPVsmxjdhsweWCauGeJfwc92NP2 # Example: Europe/Amsterdam TZ=UTC -# APP_URL and TRUSTED_PROXIES are useful when using Docker and/or a reverse proxy. +# This variable must match your installation's external address but keep in mind that +# it's only used on the command line as a fallback value. APP_URL=http://localhost -TRUSTED_PROXIES= + +# TRUSTED_PROXIES is a useful variable when using Docker and/or a reverse proxy. +TRUSTED_PROXIES=** # The log channel defines where your log entries go to. -LOG_CHANNEL=syslog +# 'daily' is the default logging mode giving you 5 daily rotated log files in /storage/logs/. +# Several other options exist. You can use 'single' for one big fat error log (not recommended). +# Also available are 'syslog', 'errorlog' and 'stdout' which will log to the system itself. +LOG_CHANNEL=stdout + +# Log level. You can set this from least severe to most severe: +# debug, info, notice, warning, error, critical, alert, emergency +# If you set it to debug your logs will grow large, and fast. If you set it to emergency probably +# nothing will get logged, ever. +APP_LOG_LEVEL=debug # Database credentials. Make sure the database exists. I recommend a dedicated user for Firefly III # If you use SQLite, set connection to `sqlite` and remove the database, username and password settings. @@ -33,17 +45,6 @@ DB_CONNECTION=pgsql -# 'daily' is the default logging mode giving you 5 daily rotated log files in /storage/logs/. -# Several other options exist. You can use 'single' for one big fat error log (not recommended). -# Also available are 'syslog' and 'errorlog' which will log to the system itself. -APP_LOG=errorlog - -# Log level. You can set this from least severe to most severe: -# debug, info, notice, warning, error, critical, alert, emergency -# If you set it to debug your logs will grow large, and fast. If you set it to emergency probably -# nothing will get logged, ever. -APP_LOG_LEVEL=debug - # If you're looking for performance improvements, you could install memcached. CACHE_DRIVER=file SESSION_DRIVER=file @@ -105,7 +106,7 @@ DEMO_USERNAME= DEMO_PASSWORD= IS_DOCKER=false IS_SANDSTORM=false -BUNQ_USE_SANDBOX=false IS_HEROKU=true +BUNQ_USE_SANDBOX=false MAILGUN_DOMAIN= MAILGUN_SECRET= diff --git a/.env.sandstorm b/.env.sandstorm index 5895f63467..ce73089fe8 100755 --- a/.env.sandstorm +++ b/.env.sandstorm @@ -17,12 +17,24 @@ APP_KEY=SomeRandomStringOf32CharsExactly # Example: Europe/Amsterdam TZ=UTC -# APP_URL and TRUSTED_PROXIES are useful when using Docker and/or a reverse proxy. +# This variable must match your installation's external address but keep in mind that +# it's only used on the command line as a fallback value. APP_URL=http://localhost + +# TRUSTED_PROXIES is a useful variable when using Docker and/or a reverse proxy. TRUSTED_PROXIES= # The log channel defines where your log entries go to. -LOG_CHANNEL=syslog +# 'daily' is the default logging mode giving you 5 daily rotated log files in /storage/logs/. +# Several other options exist. You can use 'single' for one big fat error log (not recommended). +# Also available are 'syslog', 'errorlog' and 'stdout' which will log to the system itself. +LOG_CHANNEL=stdout + +# Log level. You can set this from least severe to most severe: +# debug, info, notice, warning, error, critical, alert, emergency +# If you set it to debug your logs will grow large, and fast. If you set it to emergency probably +# nothing will get logged, ever. +APP_LOG_LEVEL=debug # Database credentials. Make sure the database exists. I recommend a dedicated user for Firefly III # If you use SQLite, set connection to `sqlite` and remove the database, username and password settings. @@ -33,17 +45,6 @@ DB_DATABASE=firefly DB_USERNAME=firefly DB_PASSWORD=firefly -# 'daily' is the default logging mode giving you 5 daily rotated log files in /storage/logs/. -# Several other options exist. You can use 'single' for one big fat error log (not recommended). -# Also available are 'syslog' and 'errorlog' which will log to the system itself. -APP_LOG=syslog - -# Log level. You can set this from least severe to most severe: -# debug, info, notice, warning, error, critical, alert, emergency -# If you set it to debug your logs will grow large, and fast. If you set it to emergency probably -# nothing will get logged, ever. -APP_LOG_LEVEL=info - # If you're looking for performance improvements, you could install memcached. CACHE_DRIVER=file SESSION_DRIVER=file @@ -105,7 +106,7 @@ DEMO_USERNAME= DEMO_PASSWORD= IS_DOCKER=false IS_SANDSTORM=true -BUNQ_USE_SANDBOX=false IS_HEROKU=false +BUNQ_USE_SANDBOX=false MAILGUN_DOMAIN= MAILGUN_SECRET= diff --git a/.env.testing b/.env.testing index 6eec082239..27d1d955d5 100644 --- a/.env.testing +++ b/.env.testing @@ -17,21 +17,18 @@ APP_KEY=TestTestTestTestTestTestTestTest # Example: Europe/Amsterdam TZ=Europe/Amsterdam -# APP_URL and TRUSTED_PROXIES are useful when using Docker and/or a reverse proxy. +# This variable must match your installation's external address but keep in mind that +# it's only used on the command line as a fallback value. APP_URL=http://localhost + +# TRUSTED_PROXIES is a useful variable when using Docker and/or a reverse proxy. TRUSTED_PROXIES= # The log channel defines where your log entries go to. -LOG_CHANNEL=dailytest - -# Database credentials. Make sure the database exists. I recommend a dedicated user for Firefly III -# For other database types, please see the FAQ: http://firefly-iii.readthedocs.io/en/latest/support/faq.html -DB_CONNECTION=sqlite - # 'daily' is the default logging mode giving you 5 daily rotated log files in /storage/logs/. # Several other options exist. You can use 'single' for one big fat error log (not recommended). -# Also available are 'syslog' and 'errorlog' which will log to the system itself. -APP_LOG=daily +# Also available are 'syslog', 'errorlog' and 'stdout' which will log to the system itself. +LOG_CHANNEL=dailytest # Log level. You can set this from least severe to most severe: # debug, info, notice, warning, error, critical, alert, emergency @@ -39,6 +36,15 @@ APP_LOG=daily # nothing will get logged, ever. APP_LOG_LEVEL=debug +# Database credentials. Make sure the database exists. I recommend a dedicated user for Firefly III +# For other database types, please see the FAQ: http://firefly-iii.readthedocs.io/en/latest/support/faq.html +DB_CONNECTION=sqlite + + + + + + # If you're looking for performance improvements, you could install memcached. CACHE_DRIVER=file SESSION_DRIVER=file @@ -64,6 +70,9 @@ MAILGUN_SECRET= MANDRILL_SECRET= SPARKPOST_SECRET= +# Firefly III can send you the following messages +SEND_REGISTRATION_MAIL=true +SEND_ERROR_MESSAGE=false # Set a Mapbox API key here (see mapbox.com) so there might be a map available at various places. MAPBOX_API_KEY= @@ -97,7 +106,7 @@ DEMO_USERNAME= DEMO_PASSWORD= IS_DOCKER=false IS_SANDSTORM=false -BUNQ_USE_SANDBOX=true IS_HEROKU=false +BUNQ_USE_SANDBOX=true MAILGUN_DOMAIN= MAILGUN_SECRET= diff --git a/.sandstorm/changelog.md b/.sandstorm/changelog.md index cbf34ab81c..39054f0536 100644 --- a/.sandstorm/changelog.md +++ b/.sandstorm/changelog.md @@ -1,3 +1,46 @@ +# 4.7.6 +- [Issue 145](https://github.com/firefly-iii/firefly-iii/issues/145) You can now download transactions from YNAB. +- [Issue 306](https://github.com/firefly-iii/firefly-iii/issues/306) You can now add liabilities to Firefly III. +- [Issue 740](https://github.com/firefly-iii/firefly-iii/issues/740) Various charts are now currency aware. +- [Issue 833](https://github.com/firefly-iii/firefly-iii/issues/833) Bills can use non-default currencies. +- [Issue 1578](https://github.com/firefly-iii/firefly-iii/issues/1578) Firefly III will notify you if the cron job hasn't fired. +- [Issue 1623](https://github.com/firefly-iii/firefly-iii/issues/1623) New transactions will link back from the success message. +- [Issue 1624](https://github.com/firefly-iii/firefly-iii/issues/1624) transactions will link to the object. +- You can call the cron job over the web now (see docs). +- You don't need to call the cron job every minute any more. +- Various charts are now red/green to signify income and expenses. +- Option to add or remove accounts from the net worth calculations. +- This will be the last release on PHP 7.1. Future versions will require PHP 7.2. +- [Issue 1460](https://github.com/firefly-iii/firefly-iii/issues/1460) Downloading transactions from bunq should go more smoothly. +- [Issue 1464](https://github.com/firefly-iii/firefly-iii/issues/1464) Fixed the docker file to work on Raspberry Pi's. +- [Issue 1540](https://github.com/firefly-iii/firefly-iii/issues/1540) The Docker file now has a working cron job for recurring transactions. +- [Issue 1564](https://github.com/firefly-iii/firefly-iii/issues/1564) Fix double transfers when importing from bunq. +- [Issue 1575](https://github.com/firefly-iii/firefly-iii/issues/1575) Some views would give a XSRF token warning +- [Issue 1576](https://github.com/firefly-iii/firefly-iii/issues/1576) Fix assigning budgets +- [Issue 1580](https://github.com/firefly-iii/firefly-iii/issues/1580) Missing string for translation +- [Issue 1581](https://github.com/firefly-iii/firefly-iii/issues/1581) Expand help text +- [Issue 1584](https://github.com/firefly-iii/firefly-iii/issues/1584) Link to administration is back. +- [Issue 1586](https://github.com/firefly-iii/firefly-iii/issues/1586) Date fields in import were mislabeled. +- [Issue 1593](https://github.com/firefly-iii/firefly-iii/issues/1593) Link types are translatable. +- [Issue 1594](https://github.com/firefly-iii/firefly-iii/issues/1594) Very long breadcrumbs are weird. +- [Issue 1598](https://github.com/firefly-iii/firefly-iii/issues/1598) Fix budget calculations. +- [Issue 1597](https://github.com/firefly-iii/firefly-iii/issues/1597) Piggy banks are always inactive. +- [Issue 1605](https://github.com/firefly-iii/firefly-iii/issues/1605) System will ignore foreign currency setting if user doesn't indicate the amount. +- [Issue 1607](https://github.com/firefly-iii/firefly-iii/issues/1607) Firefly III trusts the Heroku load balancer, fixing deployment on Heroku. +- [Issue 1608](https://github.com/firefly-iii/firefly-iii/issues/1608) Spelling error in command line import. +- [Issue 1609](https://github.com/firefly-iii/firefly-iii/issues/1609) Link to budgets page was absolute. +- [Issue 1615](https://github.com/firefly-iii/firefly-iii/issues/1615) Fix currency bug in transactions. +- [Issue 1616](https://github.com/firefly-iii/firefly-iii/issues/1616) Fix null pointer exception in pie charts. +- [Issue 1617](https://github.com/firefly-iii/firefly-iii/issues/1617) Fix for complex tag names in URL's. +- [Issue 1620](https://github.com/firefly-iii/firefly-iii/issues/1620) Fixed index reference in API. +- [Issue 1642](https://github.com/firefly-iii/firefly-iii/issues/1642) Fix issue with split journals. +- [Issue 1643](https://github.com/firefly-iii/firefly-iii/issues/1643) Fix reconciliation issue. +- Users can no longer give expenses a budget. +- Fix bug in Spectre import. +- Heroku would not make you owner. +- Add `.htaccess` files to all public directories. +- New secure headers will make Firefly III slightly more secure. + # 4.7.5.3 - [Issue 1527](https://github.com/firefly-iii/firefly-iii/issues/1527), fixed views for transactions without a budget. - [Issue 1553](https://github.com/firefly-iii/firefly-iii/issues/1553), report could not handle transactions before the first one in the system. diff --git a/.sandstorm/sandstorm-pkgdef.capnp b/.sandstorm/sandstorm-pkgdef.capnp index 8d7814ae0d..8f55d0c5d9 100644 --- a/.sandstorm/sandstorm-pkgdef.capnp +++ b/.sandstorm/sandstorm-pkgdef.capnp @@ -15,8 +15,8 @@ const pkgdef :Spk.PackageDefinition = ( manifest = ( appTitle = (defaultText = "Firefly III"), - appVersion = 15, - appMarketingVersion = (defaultText = "4.7.5.3"), + appVersion = 16, + appMarketingVersion = (defaultText = "4.7.6"), actions = [ # Define your "new document" handlers here. diff --git a/Dockerfile b/Dockerfile index 6d6ca1799b..15dcc12043 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,16 @@ -# use PHP 7.1 and Apache as a base. FROM php:7.1-apache -# set working dir -ENV FIREFLY_PATH /var/www/firefly-iii +# If building on a RPi, use --build-arg cores=3 to use all cores when compiling +# to speed up the image build +ARG CORES +ENV CORES ${CORES:-1} + +ENV FIREFLY_PATH /var/www/firefly-iii/ ENV CURL_VERSION 7.60.0 ENV OPENSSL_VERSION 1.1.1-pre6 -WORKDIR $FIREFLY_PATH -ADD . $FIREFLY_PATH + +LABEL version="1.0" maintainer="thegrumpydictator@gmail.com" + # install packages RUN apt-get update -y && \ @@ -19,48 +23,62 @@ RUN apt-get update -y && \ libedit-dev \ libtidy-dev \ libxml2-dev \ + unzip \ libsqlite3-dev \ + nano \ libpq-dev \ libbz2-dev \ gettext-base \ cron \ + rsyslog \ + supervisor \ locales && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* -# Setup the Composer installer -RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer - # Install latest curl RUN cd /tmp && \ wget https://www.openssl.org/source/openssl-${OPENSSL_VERSION}.tar.gz && \ tar -xvf openssl-${OPENSSL_VERSION}.tar.gz && \ cd openssl-${OPENSSL_VERSION} && \ ./config && \ - make && \ + make -j${CORES} && \ make install RUN cd /tmp && \ wget https://curl.haxx.se/download/curl-${CURL_VERSION}.tar.gz && \ tar -xvf curl-${CURL_VERSION}.tar.gz && \ cd curl-${CURL_VERSION} && \ - ./configure --with-ssl && \ - make && \ + ./configure --with-ssl --host=$(gcc -dumpmachine) && \ + make -j${CORES} && \ make install +# Make sure that libcurl is using the newer curl libaries +RUN echo "/usr/local/lib" >> /etc/ld.so.conf.d/00-curl.conf && ldconfig -# Create the log file to be able to run tail -RUN touch /var/log/cron.log +# Mimic the Debian/Ubuntu config file structure for supervisor +COPY .deploy/docker/supervisord.conf /etc/supervisor/supervisord.conf +RUN mkdir -p /etc/supervisor/conf.d /var/log/supervisor -# Setup cron job -RUN (crontab -l ; echo "* * * * * root $FIREFLY_PATH/artisan schedule:run >> /var/log/cron.log") | crontab +# copy Firefly III supervisor conf file. +COPY ./.deploy/docker/firefly-iii.conf /etc/supervisor/conf.d/firefly-iii.conf +# copy cron job supervisor conf file. +COPY ./.deploy/docker/cronjob.conf /etc/supervisor/conf.d/cronjob.conf + +# test crons added via crontab +RUN echo "0 3 * * * /usr/local/bin/php /var/www/firefly-iii/artisan firefly:cron" | crontab - +#RUN (crontab -l ; echo "*/1 * * * * free >> /var/www/firefly-iii/public/cron.html") 2>&1 | crontab - # Install PHP exentions. -RUN docker-php-ext-install -j$(nproc) curl gd intl json readline tidy zip bcmath xml mbstring pdo_sqlite pdo_mysql bz2 pdo_pgsql +RUN docker-php-ext-install -j$(nproc) gd intl tidy zip bcmath pdo_mysql bz2 pdo_pgsql + +# Install composer +RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer # Generate locales supported by Firefly III RUN echo "en_US.UTF-8 UTF-8\nde_DE.UTF-8 UTF-8\nfr_FR.UTF-8 UTF-8\nit_IT.UTF-8 UTF-8\nnl_NL.UTF-8 UTF-8\npl_PL.UTF-8 UTF-8\npt_BR.UTF-8 UTF-8\nru_RU.UTF-8 UTF-8\ntr_TR.UTF-8 UTF-8\n\n" > /etc/locale.gen && locale-gen + # copy Apache config to correct spot. COPY ./.deploy/docker/apache2.conf /etc/apache2/apache2.conf @@ -70,7 +88,7 @@ RUN a2enmod rewrite # Enable apache mod ssl.. RUN a2enmod ssl -# Create volumes for several directories: +# Create volumes VOLUME $FIREFLY_PATH/storage/export $FIREFLY_PATH/storage/upload # Enable default site (Firefly III) @@ -79,15 +97,16 @@ COPY ./.deploy/docker/apache-firefly.conf /etc/apache2/sites-available/000-defau # Make sure we own Firefly III directory RUN chown -R www-data:www-data /var/www && chmod -R 775 $FIREFLY_PATH/storage +# Copy in Firefly Source +WORKDIR $FIREFLY_PATH +ADD . $FIREFLY_PATH + # Run composer +ENV COMPOSER_ALLOW_SUPERUSER 1 RUN composer install --prefer-dist --no-dev --no-scripts --no-suggest # Expose port 80 EXPOSE 80 -# Run the command on container startup -CMD cron - # Run entrypoint thing ENTRYPOINT [".deploy/docker/entrypoint.sh"] - diff --git a/app/Api/V1/Controllers/AccountController.php b/app/Api/V1/Controllers/AccountController.php index 9fef764538..bf3213cbd2 100644 --- a/app/Api/V1/Controllers/AccountController.php +++ b/app/Api/V1/Controllers/AccountController.php @@ -221,6 +221,11 @@ class AccountController extends Controller 'special' => [AccountType::CASH, AccountType::INITIAL_BALANCE, AccountType::IMPORT, AccountType::RECONCILIATION, AccountType::LOAN,], 'hidden' => [AccountType::INITIAL_BALANCE, AccountType::IMPORT, AccountType::RECONCILIATION, AccountType::LOAN,], + 'liability' => [AccountType::DEBT, AccountType::LOAN, AccountType::MORTGAGE, AccountType::CREDITCARD], + 'liabilities' => [AccountType::DEBT, AccountType::LOAN, AccountType::MORTGAGE, AccountType::CREDITCARD], + 'cc' => [AccountType::CREDITCARD], + 'creditcard' => [AccountType::CREDITCARD], + 'credit_card' => [AccountType::CREDITCARD], AccountType::DEFAULT => [AccountType::DEFAULT], AccountType::CASH => [AccountType::CASH], AccountType::ASSET => [AccountType::ASSET], @@ -231,6 +236,10 @@ class AccountController extends Controller AccountType::IMPORT => [AccountType::IMPORT], AccountType::RECONCILIATION => [AccountType::RECONCILIATION], AccountType::LOAN => [AccountType::LOAN], + AccountType::MORTGAGE => [AccountType::MORTGAGE], + AccountType::DEBT => [AccountType::DEBT], + AccountType::CREDITCARD => [AccountType::CREDITCARD], + ]; $return = $types['all']; if (isset($types[$type])) { diff --git a/app/Api/V1/Controllers/AvailableBudgetController.php b/app/Api/V1/Controllers/AvailableBudgetController.php index fa2697dbeb..13c56f1495 100644 --- a/app/Api/V1/Controllers/AvailableBudgetController.php +++ b/app/Api/V1/Controllers/AvailableBudgetController.php @@ -152,7 +152,10 @@ class AvailableBudgetController extends Controller public function store(AvailableBudgetRequest $request): JsonResponse { $data = $request->getAll(); - $currency = $this->currencyRepository->findNull($data['transaction_currency_id']); + $currency = $this->currencyRepository->findNull($data['currency_id']); + if (null === $currency) { + $currency = $this->currencyRepository->findByCodeNull($data['currency_code']); + } if (null === $currency) { throw new FireflyException('Could not find the indicated currency.'); } @@ -166,6 +169,8 @@ class AvailableBudgetController extends Controller return response()->json($manager->createData($resource)->toArray())->header('Content-Type', 'application/vnd.api+json'); } + + /** * Update the specified resource in storage. * diff --git a/app/Api/V1/Controllers/BudgetLimitController.php b/app/Api/V1/Controllers/BudgetLimitController.php index 16cf1d095a..99127989cb 100644 --- a/app/Api/V1/Controllers/BudgetLimitController.php +++ b/app/Api/V1/Controllers/BudgetLimitController.php @@ -34,7 +34,6 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; - use League\Fractal\Manager; use League\Fractal\Pagination\IlluminatePaginatorAdapter; use League\Fractal\Resource\Collection as FractalCollection; diff --git a/app/Api/V1/Controllers/ConfigurationController.php b/app/Api/V1/Controllers/ConfigurationController.php index aee513f7cb..c21b430b0c 100644 --- a/app/Api/V1/Controllers/ConfigurationController.php +++ b/app/Api/V1/Controllers/ConfigurationController.php @@ -54,6 +54,7 @@ class ConfigurationController extends Controller $admin = auth()->user(); if (!$this->repository->hasRole($admin, 'owner')) { + /** @noinspection ExceptionsAnnotatingAndHandlingInspection */ throw new FireflyException('No access to method.'); // @codeCoverageIgnore } diff --git a/app/Api/V1/Controllers/JournalLinkController.php b/app/Api/V1/Controllers/JournalLinkController.php index 7ae6b12792..c428fc0146 100644 --- a/app/Api/V1/Controllers/JournalLinkController.php +++ b/app/Api/V1/Controllers/JournalLinkController.php @@ -175,8 +175,7 @@ class JournalLinkController extends Controller $data['direction'] = 'inward'; $journalLink = $this->repository->storeLink($data, $inward, $outward); - - $resource = new Item($journalLink, new JournalLinkTransformer($this->parameters), 'journal_links'); + $resource = new Item($journalLink, new JournalLinkTransformer($this->parameters), 'journal_links'); return response()->json($manager->createData($resource)->toArray())->header('Content-Type', 'application/vnd.api+json'); diff --git a/app/Api/V1/Controllers/PiggyBankController.php b/app/Api/V1/Controllers/PiggyBankController.php index 4b1da3528f..8ab73dbcdc 100644 --- a/app/Api/V1/Controllers/PiggyBankController.php +++ b/app/Api/V1/Controllers/PiggyBankController.php @@ -161,7 +161,7 @@ class PiggyBankController extends Controller return response()->json($manager->createData($resource)->toArray())->header('Content-Type', 'application/vnd.api+json'); } - throw new FireflyException('Could not store new piggy bank.'); // @codeCoverageIgnore + throw new FireflyException('Could not store new piggy bank.'); } diff --git a/app/Api/V1/Controllers/RuleController.php b/app/Api/V1/Controllers/RuleController.php index 42e016332a..07ff7c870c 100644 --- a/app/Api/V1/Controllers/RuleController.php +++ b/app/Api/V1/Controllers/RuleController.php @@ -101,7 +101,7 @@ class RuleController extends Controller // make paginator: $paginator = new LengthAwarePaginator($rules, $count, $pageSize, $this->parameters->get('page')); - $paginator->setPath(route('api.v1.piggy_banks.index') . $this->buildParams()); + $paginator->setPath(route('api.v1.rules.index') . $this->buildParams()); // present to user. $manager->setSerializer(new JsonApiSerializer($baseUrl)); diff --git a/app/Api/V1/Controllers/RuleGroupController.php b/app/Api/V1/Controllers/RuleGroupController.php index 83185cec11..d0d9a8d07c 100644 --- a/app/Api/V1/Controllers/RuleGroupController.php +++ b/app/Api/V1/Controllers/RuleGroupController.php @@ -95,7 +95,7 @@ class RuleGroupController extends Controller // types to get, page size: $pageSize = (int)app('preferences')->getForUser(auth()->user(), 'listPageSize', 50)->data; - // get list of budgets. Count it and split it. + // get list of rule groups. Count it and split it. $collection = $this->ruleGroupRepository->get(); $count = $collection->count(); $ruleGroups = $collection->slice(($this->parameters->get('page') - 1) * $pageSize, $pageSize); diff --git a/app/Api/V1/Controllers/TransactionController.php b/app/Api/V1/Controllers/TransactionController.php index 91210a8525..9df8330527 100644 --- a/app/Api/V1/Controllers/TransactionController.php +++ b/app/Api/V1/Controllers/TransactionController.php @@ -26,7 +26,7 @@ namespace FireflyIII\Api\V1\Controllers; use FireflyIII\Api\V1\Requests\TransactionRequest; use FireflyIII\Exceptions\FireflyException; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Helpers\Filter\InternalTransferFilter; use FireflyIII\Helpers\Filter\NegativeAmountFilter; use FireflyIII\Helpers\Filter\PositiveAmountFilter; @@ -108,8 +108,8 @@ class TransactionController extends Controller /** @var User $admin */ $admin = auth()->user(); - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setUser($admin); $collector->withOpposingAccount()->withCategoryInformation()->withBudgetInformation(); $collector->setAllAssetAccounts(); @@ -123,7 +123,7 @@ class TransactionController extends Controller } $collector->setLimit($pageSize)->setPage($this->parameters->get('page')); $collector->setTypes($types); - $paginator = $collector->getPaginatedJournals(); + $paginator = $collector->getPaginatedTransactions(); $paginator->setPath(route('api.v1.transactions.index') . $this->buildParams()); $transactions = $paginator->getCollection(); @@ -155,7 +155,7 @@ class TransactionController extends Controller $manager->parseIncludes($include); // collect transactions using the journal collector - $collector = app(JournalCollectorInterface::class); + $collector = app(TransactionCollectorInterface::class); $collector->setUser(auth()->user()); $collector->withOpposingAccount()->withCategoryInformation()->withBudgetInformation(); // filter on specific journals. @@ -170,7 +170,7 @@ class TransactionController extends Controller $collector->addFilter(NegativeAmountFilter::class); } - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); $resource = new FractalCollection($transactions, new TransactionTransformer($this->parameters), 'transactions'); return response()->json($manager->createData($resource)->toArray())->header('Content-Type', 'application/vnd.api+json'); @@ -201,7 +201,7 @@ class TransactionController extends Controller $manager->parseIncludes($include); // collect transactions using the journal collector - $collector = app(JournalCollectorInterface::class); + $collector = app(TransactionCollectorInterface::class); $collector->setUser(auth()->user()); $collector->withOpposingAccount()->withCategoryInformation()->withBudgetInformation(); // filter on specific journals. @@ -216,7 +216,7 @@ class TransactionController extends Controller $collector->addFilter(NegativeAmountFilter::class); } - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); $resource = new FractalCollection($transactions, new TransactionTransformer($this->parameters), 'transactions'); return response()->json($manager->createData($resource)->toArray())->header('Content-Type', 'application/vnd.api+json'); @@ -247,7 +247,7 @@ class TransactionController extends Controller // needs a lot of extra data to match the journal collector. Or just expand that one. // collect transactions using the journal collector - $collector = app(JournalCollectorInterface::class); + $collector = app(TransactionCollectorInterface::class); $collector->setUser(auth()->user()); $collector->withOpposingAccount()->withCategoryInformation()->withBudgetInformation(); // filter on specific journals. @@ -262,7 +262,7 @@ class TransactionController extends Controller $collector->addFilter(NegativeAmountFilter::class); } - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); $resource = new FractalCollection($transactions, new TransactionTransformer($this->parameters), 'transactions'); return response()->json($manager->createData($resource)->toArray())->header('Content-Type', 'application/vnd.api+json'); diff --git a/app/Api/V1/Requests/AccountRequest.php b/app/Api/V1/Requests/AccountRequest.php index db0feadf7e..52d1db7726 100644 --- a/app/Api/V1/Requests/AccountRequest.php +++ b/app/Api/V1/Requests/AccountRequest.php @@ -50,6 +50,7 @@ class AccountRequest extends Request $data = [ 'name' => $this->string('name'), 'active' => $this->boolean('active'), + 'include_net_worth' => $this->boolean('include_net_worth'), 'accountType' => $this->string('type'), 'account_type_id' => null, 'currency_id' => $this->integer('currency_id'), @@ -64,7 +65,21 @@ class AccountRequest extends Request 'ccType' => $this->string('cc_type'), 'ccMonthlyPaymentDate' => $this->string('cc_monthly_payment_date'), 'notes' => $this->string('notes'), + 'interest' => $this->string('interest'), + 'interest_period' => $this->string('interest_period'), ]; + // new fields for liabilities + // 'liability_type' => $this->string('liability_type'), + // 'liability_start_date' => $this->date('liability_start_date'), + + + //]; + if ('liability' === $data['accountType']) { + $data['openingBalance'] = bcmul($this->string('liability_amount'), '-1'); + $data['openingBalanceDate'] = $this->date('liability_start_date'); + $data['accountType'] = $this->string('liability_type'); + $data['account_type_id'] = null; + } return $data; } @@ -91,10 +106,18 @@ class AccountRequest extends Request 'account_number' => 'between:1,255|nullable|uniqueAccountNumberForUser', 'account_role' => 'in:' . $accountRoles . '|required_if:type,asset', 'active' => 'required|boolean', + 'include_net_worth' => 'required|boolean', 'cc_type' => 'in:' . $ccPaymentTypes . '|required_if:account_role,ccAsset', 'cc_monthly_payment_date' => 'date' . '|required_if:account_role,ccAsset|required_if:cc_type,monthlyFull', 'type' => 'required|in:' . $types, 'notes' => 'min:0|max:65536', + // required fields for liabilities: + 'liability_type' => 'required_if:type,liability|in:loan,debt,mortgage,credit card', + 'liability_amount' => 'required_if:type,liability|min:0|numeric', + 'liability_start_date' => 'required_if:type,liability|date', + 'interest' => 'required_if:type,liability|between:0,100|numeric', + 'interest_period' => 'required_if:type,liability|in:daily,monthly,yearly', + ]; switch ($this->method()) { default: diff --git a/app/Api/V1/Requests/AvailableBudgetRequest.php b/app/Api/V1/Requests/AvailableBudgetRequest.php index dd6daf4e65..865b2b7fd5 100644 --- a/app/Api/V1/Requests/AvailableBudgetRequest.php +++ b/app/Api/V1/Requests/AvailableBudgetRequest.php @@ -47,10 +47,11 @@ class AvailableBudgetRequest extends Request public function getAll(): array { return [ - 'transaction_currency_id' => $this->integer('transaction_currency_id'), - 'amount' => $this->string('amount'), - 'start_date' => $this->date('start_date'), - 'end_date' => $this->date('end_date'), + 'currency_id' => $this->integer('currency_id'), + 'currency_code' => $this->string('currency_code'), + 'amount' => $this->string('amount'), + 'start_date' => $this->date('start_date'), + 'end_date' => $this->date('end_date'), ]; } @@ -62,10 +63,11 @@ class AvailableBudgetRequest extends Request public function rules(): array { $rules = [ - 'transaction_currency_id' => 'required|numeric|exists:transaction_currencies,id', - 'amount' => 'required|numeric|more:0', - 'start_date' => 'required|date|before:end_date', - 'end_date' => 'required|date|after:start_date', + 'currency_id' => 'numeric|exists:transaction_currencies,id|required_without:currency_code', + 'currency_code' => 'min:3|max:3|exists:transaction_currencies,code|required_without:currency_id', + 'amount' => 'required|numeric|more:0', + 'start_date' => 'required|date|before:end_date', + 'end_date' => 'required|date|after:start_date', ]; return $rules; diff --git a/app/Api/V1/Requests/JournalLinkRequest.php b/app/Api/V1/Requests/JournalLinkRequest.php index 19e5f8f430..2c31e0d889 100644 --- a/app/Api/V1/Requests/JournalLinkRequest.php +++ b/app/Api/V1/Requests/JournalLinkRequest.php @@ -23,6 +23,9 @@ declare(strict_types=1); namespace FireflyIII\Api\V1\Requests; +use FireflyIII\Repositories\Journal\JournalRepositoryInterface; +use FireflyIII\Repositories\LinkType\LinkTypeRepositoryInterface; +use Illuminate\Validation\Validator; /** * @@ -49,10 +52,11 @@ class JournalLinkRequest extends Request public function getAll(): array { return [ - 'link_type_id' => $this->integer('link_type_id'), - 'inward_id' => $this->integer('inward_id'), - 'outward_id' => $this->integer('outward_id'), - 'notes' => $this->string('notes'), + 'link_type_id' => $this->integer('link_type_id'), + 'link_type_name' => $this->string('link_type_name'), + 'inward_id' => $this->integer('inward_id'), + 'outward_id' => $this->integer('outward_id'), + 'notes' => $this->string('notes'), ]; } @@ -65,11 +69,63 @@ class JournalLinkRequest extends Request public function rules(): array { return [ - 'link_type_id' => 'required|exists:link_types,id', - 'inward_id' => 'required|belongsToUser:transaction_journals,id', - 'outward_id' => 'required|belongsToUser:transaction_journals,id', - 'notes' => 'between:0,65000', + 'link_type_id' => 'exists:link_types,id|required_without:link_type_name', + 'link_type_name' => 'exists:link_types,name|required_without:link_type_id', + 'inward_id' => 'required|belongsToUser:transaction_journals,id', + 'outward_id' => 'required|belongsToUser:transaction_journals,id', + 'notes' => 'between:0,65000', ]; } + /** + * Configure the validator instance. + * + * @param Validator $validator + * + * @return void + */ + public function withValidator(Validator $validator): void + { + $validator->after( + function (Validator $validator) { + $this->validateExistingLink($validator); + } + ); + } + + /** + * @param Validator $validator + */ + private function validateExistingLink(Validator $validator): void + { + /** @var LinkTypeRepositoryInterface $repository */ + $repository = app(LinkTypeRepositoryInterface::class); + $repository->setUser(auth()->user()); + + /** @var JournalRepositoryInterface $journalRepos */ + $journalRepos = app(JournalRepositoryInterface::class); + $journalRepos->setUser(auth()->user()); + + $data = $validator->getData(); + $inwardId = (int)($data['inward_id'] ?? 0); + $outwardId = (int)($data['outward_id'] ?? 0); + $inward = $journalRepos->findNull($inwardId); + $outward = $journalRepos->findNull($outwardId); + + if (null === $inward) { + $validator->errors()->add('inward_id', 'Invalid inward ID.'); + + return; + } + if (null === $outward) { + $validator->errors()->add('outward_id', 'Invalid outward ID.'); + + return; + } + + if ($repository->findLink($inward, $outward)) { + $validator->errors()->add('outward_id', 'Already have a link between inward and outward.'); + $validator->errors()->add('inward_id', 'Already have a link between inward and outward.'); + } + } } diff --git a/app/Api/V1/Requests/PiggyBankRequest.php b/app/Api/V1/Requests/PiggyBankRequest.php index b113fa8fe2..68ef285527 100644 --- a/app/Api/V1/Requests/PiggyBankRequest.php +++ b/app/Api/V1/Requests/PiggyBankRequest.php @@ -57,7 +57,7 @@ class PiggyBankRequest extends Request 'current_amount' => $this->string('current_amount'), 'start_date' => $this->date('start_date'), 'target_date' => $this->date('target_date'), - 'note' => $this->string('notes'), + 'notes' => $this->string('notes'), ]; } diff --git a/app/Api/V1/Requests/RecurrenceRequest.php b/app/Api/V1/Requests/RecurrenceRequest.php index fd348c733d..77adb8d242 100644 --- a/app/Api/V1/Requests/RecurrenceRequest.php +++ b/app/Api/V1/Requests/RecurrenceRequest.php @@ -84,7 +84,7 @@ class RecurrenceRequest extends Request */ public function rules(): array { - $today = Carbon::create()->addDay(); + $today = Carbon::now()->addDay(); return [ 'type' => 'required|in:withdrawal,transfer,deposit', diff --git a/app/Api/V1/Requests/RuleRequest.php b/app/Api/V1/Requests/RuleRequest.php index d44833852e..f8e50e4da2 100644 --- a/app/Api/V1/Requests/RuleRequest.php +++ b/app/Api/V1/Requests/RuleRequest.php @@ -56,27 +56,12 @@ class RuleRequest extends Request 'rule_group_title' => $this->string('rule_group_title'), 'trigger' => $this->string('trigger'), 'strict' => $this->boolean('strict'), - 'stop-processing' => $this->boolean('stop_processing'), + 'stop_processing' => $this->boolean('stop_processing'), 'active' => $this->boolean('active'), - 'rule-triggers' => [], - 'rule-actions' => [], + 'rule_triggers' => $this->getRuleTriggers(), + 'rule_actions' => $this->getRuleActions(), ]; - foreach ($this->get('rule-triggers') as $trigger) { - $data['rule-triggers'][] = [ - 'name' => $trigger['name'], - 'value' => $trigger['value'], - 'stop-processing' => 1 === (int)($trigger['stop-processing'] ?? 0), - ]; - } - foreach ($this->get('rule-actions') as $action) { - $data['rule-actions'][] = [ - 'name' => $action['name'], - 'value' => $action['value'], - 'stop-processing' => 1 === (int)($action['stop-processing'] ?? 0), - ]; - } - return $data; } @@ -99,12 +84,12 @@ class RuleRequest extends Request 'rule_group_id' => 'required|belongsToUser:rule_groups|required_without:rule_group_title', 'rule_group_title' => 'nullable|between:1,255|required_without:rule_group_id|belongsToUser:rule_groups,title', 'trigger' => 'required|in:store-journal,update-journal', - 'rule-triggers.*.name' => 'required|in:' . implode(',', $validTriggers), - 'rule-triggers.*.stop-processing' => 'boolean', - 'rule-triggers.*.value' => 'required|min:1|ruleTriggerValue', // - 'rule-actions.*.name' => 'required|in:' . implode(',', $validActions), - 'rule-actions.*.value' => 'required_if:rule-action.*.type,' . $contextActions . '|ruleActionValue', - 'rule-actions.*.stop-processing' => 'boolean', + 'rule_triggers.*.name' => 'required|in:' . implode(',', $validTriggers), + 'rule_triggers.*.stop_processing' => 'boolean', + 'rule_triggers.*.value' => 'required|min:1|ruleTriggerValue', + 'rule_actions.*.name' => 'required|in:' . implode(',', $validActions), + 'rule_actions.*.value' => 'required_if:rule_actions.*.type,' . $contextActions . '|ruleActionValue', + 'rule_actions.*.stop_processing' => 'boolean', 'strict' => 'required|boolean', 'stop_processing' => 'required|boolean', 'active' => 'required|boolean', @@ -138,7 +123,7 @@ class RuleRequest extends Request protected function atLeastOneAction(Validator $validator): void { $data = $validator->getData(); - $repetitions = $data['rule-actions'] ?? []; + $repetitions = $data['rule_actions'] ?? []; // need at least one transaction if (0 === \count($repetitions)) { $validator->errors()->add('title', (string)trans('validation.at_least_one_action')); @@ -153,10 +138,50 @@ class RuleRequest extends Request protected function atLeastOneTrigger(Validator $validator): void { $data = $validator->getData(); - $repetitions = $data['rule-triggers'] ?? []; + $repetitions = $data['rule_triggers'] ?? []; // need at least one transaction if (0 === \count($repetitions)) { $validator->errors()->add('title', (string)trans('validation.at_least_one_trigger')); } } + + /** + * @return array + */ + private function getRuleActions(): array + { + $actions = $this->get('rule_actions'); + $return = []; + if (\is_array($actions)) { + foreach ($actions as $action) { + $return[] = [ + 'name' => $action['name'], + 'value' => $action['value'], + 'stop_processing' => 1 === (int)($action['stop-processing'] ?? '0'), + ]; + } + } + + return $return; + } + + /** + * @return array + */ + private function getRuleTriggers(): array + { + $triggers = $this->get('rule_triggers'); + $return = []; + if (\is_array($triggers)) { + foreach ($triggers as $trigger) { + $return[] = [ + 'name' => $trigger['name'], + 'value' => $trigger['value'], + 'stop_processing' => 1 === (int)($trigger['stop-processing'] ?? '0'), + ]; + } + } + + return $return; + } } diff --git a/app/Console/Commands/CreateExport.php b/app/Console/Commands/CreateExport.php index 6896b409cf..73b4d3384f 100644 --- a/app/Console/Commands/CreateExport.php +++ b/app/Console/Commands/CreateExport.php @@ -39,6 +39,8 @@ use Storage; * Class CreateExport. * * Generates export from the command line. + * + * @codeCoverageIgnore */ class CreateExport extends Command { diff --git a/app/Console/Commands/CreateImport.php b/app/Console/Commands/CreateImport.php index 74910381e2..15234cb3aa 100644 --- a/app/Console/Commands/CreateImport.php +++ b/app/Console/Commands/CreateImport.php @@ -37,6 +37,8 @@ use Log; /** * Class CreateImport. + * + * @codeCoverageIgnore */ class CreateImport extends Command { @@ -150,7 +152,7 @@ class CreateImport extends Command if (true === $this->option('start')) { - $this->infoLine('The has started. The process is not visible. Please wait.'); + $this->infoLine('The import routine has started. The process is not visible. Please wait.'); Log::debug('Go for import!'); // run it! diff --git a/app/Console/Commands/Cron.php b/app/Console/Commands/Cron.php new file mode 100644 index 0000000000..807de0c39f --- /dev/null +++ b/app/Console/Commands/Cron.php @@ -0,0 +1,67 @@ +fire(); + } catch (FireflyException $e) { + $this->error($e->getMessage()); + + return 0; + } + if (false === $result) { + $this->line('The recurring transaction cron job did not fire.'); + } + if (true === $result) { + $this->line('The recurring transaction cron job fired successfully.'); + } + + $this->info('More feedback on the cron jobs can be found in the log files.'); + + return 0; + } + + +} diff --git a/app/Console/Commands/DecryptAttachment.php b/app/Console/Commands/DecryptAttachment.php index dfa57386e5..450eedd8bd 100644 --- a/app/Console/Commands/DecryptAttachment.php +++ b/app/Console/Commands/DecryptAttachment.php @@ -31,6 +31,8 @@ use Log; /** * Class DecryptAttachment. + * + * @codeCoverageIgnore */ class DecryptAttachment extends Command { diff --git a/app/Console/Commands/EncryptFile.php b/app/Console/Commands/EncryptFile.php index 64b19820a5..08d9683afa 100644 --- a/app/Console/Commands/EncryptFile.php +++ b/app/Console/Commands/EncryptFile.php @@ -29,6 +29,8 @@ use Illuminate\Console\Command; /** * Class EncryptFile. + * + * @codeCoverageIgnore */ class EncryptFile extends Command { diff --git a/app/Console/Commands/Import.php b/app/Console/Commands/Import.php index 0f952e6af3..7ee59ea03b 100644 --- a/app/Console/Commands/Import.php +++ b/app/Console/Commands/Import.php @@ -35,6 +35,8 @@ use Log; /** * Class Import. + * + * @codeCoverageIgnore */ class Import extends Command { @@ -64,7 +66,7 @@ class Import extends Command Log::debug('Start start-import command'); $jobKey = (string)$this->argument('key'); /** @var ImportJob $job */ - $job = ImportJob::where('key', $jobKey)->first(); + $job = ImportJob::where('key', $jobKey)->first(); if (null === $job) { $this->errorLine(sprintf('No job found with key "%s"', $jobKey)); diff --git a/app/Console/Commands/ScanAttachments.php b/app/Console/Commands/ScanAttachments.php index 0a77363a0a..034a1c2fa3 100644 --- a/app/Console/Commands/ScanAttachments.php +++ b/app/Console/Commands/ScanAttachments.php @@ -34,6 +34,8 @@ use Storage; /** * Class ScanAttachments. + * + * @codeCoverageIgnore */ class ScanAttachments extends Command { @@ -82,6 +84,7 @@ class ScanAttachments extends Command $attachment->save(); $this->line(sprintf('Fixed attachment #%d', $attachment->id)); } + return 0; } } diff --git a/app/Console/Commands/UpgradeDatabase.php b/app/Console/Commands/UpgradeDatabase.php index b126e38850..b1b393398b 100644 --- a/app/Console/Commands/UpgradeDatabase.php +++ b/app/Console/Commands/UpgradeDatabase.php @@ -63,6 +63,7 @@ use UnexpectedValueException; * Upgrade user database. * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @codeCoverageIgnore */ class UpgradeDatabase extends Command { diff --git a/app/Console/Commands/UpgradeFireflyInstructions.php b/app/Console/Commands/UpgradeFireflyInstructions.php index d2e4730204..37cd28f4ea 100644 --- a/app/Console/Commands/UpgradeFireflyInstructions.php +++ b/app/Console/Commands/UpgradeFireflyInstructions.php @@ -27,6 +27,8 @@ use Illuminate\Console\Command; /** * Class UpgradeFireflyInstructions. + * + * @codeCoverageIgnore */ class UpgradeFireflyInstructions extends Command { @@ -54,6 +56,7 @@ class UpgradeFireflyInstructions extends Command if ('install' === (string)$this->argument('task')) { $this->installInstructions(); } + return 0; } diff --git a/app/Console/Commands/UseEncryption.php b/app/Console/Commands/UseEncryption.php index bfc04db91c..55ef87eee9 100644 --- a/app/Console/Commands/UseEncryption.php +++ b/app/Console/Commands/UseEncryption.php @@ -28,6 +28,7 @@ use Illuminate\Support\Str; /** * Class UseEncryption. + * @codeCoverageIgnore */ class UseEncryption extends Command { @@ -62,6 +63,7 @@ class UseEncryption extends Command $this->handleObjects('Category', 'name', 'encrypted'); $this->handleObjects('PiggyBank', 'name', 'encrypted'); $this->handleObjects('TransactionJournal', 'description', 'encrypted'); + return 0; } diff --git a/app/Console/Commands/VerifiesAccessToken.php b/app/Console/Commands/VerifiesAccessToken.php index 857aeaa30b..a0224ae0d6 100644 --- a/app/Console/Commands/VerifiesAccessToken.php +++ b/app/Console/Commands/VerifiesAccessToken.php @@ -30,6 +30,7 @@ use Log; * Trait VerifiesAccessToken. * * Verifies user access token for sensitive commands. + * @codeCoverageIgnore */ trait VerifiesAccessToken { diff --git a/app/Console/Commands/VerifyDatabase.php b/app/Console/Commands/VerifyDatabase.php index 88635664ae..31b8b89658 100644 --- a/app/Console/Commands/VerifyDatabase.php +++ b/app/Console/Commands/VerifyDatabase.php @@ -50,6 +50,7 @@ use stdClass; * * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @codeCoverageIgnore */ class VerifyDatabase extends Command { diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 3c1fe481de..1b3fdb5311 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -24,13 +24,13 @@ declare(strict_types=1); namespace FireflyIII\Console; -use Carbon\Carbon; -use FireflyIII\Jobs\CreateRecurringTransactions; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; +use Log; /** * File to make sure commands work. + * @codeCoverageIgnore */ class Kernel extends ConsoleKernel { @@ -52,6 +52,21 @@ class Kernel extends ConsoleKernel */ protected function schedule(Schedule $schedule): void { - $schedule->job(new CreateRecurringTransactions(new Carbon))->daily(); + $schedule->call( + function () { + Log::error('Firefly III no longer users the Laravel scheduler to do cron jobs! Please read the instructions at https://firefly-iii.readthedocs.io/en/latest/'); + echo "\n"; + echo '------------'; + echo "\n"; + echo wordwrap('Firefly III no longer users the Laravel scheduler to do cron jobs! Please read the instructions here:'); + echo "\n"; + echo 'https://firefly-iii.readthedocs.io/en/latest/'; + echo "\n\n"; + echo 'Disable this cron job!'; + echo "\n"; + echo '------------'; + echo "\n"; + } + )->everyMinute(); } } diff --git a/app/Events/AdminRequestedTestMessage.php b/app/Events/AdminRequestedTestMessage.php index 800ffb909f..0a82c434cc 100644 --- a/app/Events/AdminRequestedTestMessage.php +++ b/app/Events/AdminRequestedTestMessage.php @@ -30,6 +30,7 @@ use Log; /** * Class AdminRequestedTestMessage. + * @codeCoverageIgnore */ class AdminRequestedTestMessage extends Event { diff --git a/app/Events/Event.php b/app/Events/Event.php index 41b3b0437c..84ea6bdb2b 100644 --- a/app/Events/Event.php +++ b/app/Events/Event.php @@ -26,6 +26,7 @@ namespace FireflyIII\Events; /** * Class Event. + * @codeCoverageIgnore */ abstract class Event { diff --git a/app/Events/RegisteredUser.php b/app/Events/RegisteredUser.php index 1bbb4b25dd..c576b16f94 100644 --- a/app/Events/RegisteredUser.php +++ b/app/Events/RegisteredUser.php @@ -29,6 +29,7 @@ use Illuminate\Queue\SerializesModels; /** * Class RegisteredUser. + * @codeCoverageIgnore */ class RegisteredUser extends Event { diff --git a/app/Events/RequestedNewPassword.php b/app/Events/RequestedNewPassword.php index 4452a8dfeb..7dce83c05d 100644 --- a/app/Events/RequestedNewPassword.php +++ b/app/Events/RequestedNewPassword.php @@ -29,6 +29,7 @@ use Illuminate\Queue\SerializesModels; /** * Class RequestedNewPassword. + * @codeCoverageIgnore */ class RequestedNewPassword extends Event { @@ -36,10 +37,10 @@ class RequestedNewPassword extends Event /** @var string The users IP address */ public $ipAddress; - /** @var User The user */ - public $user; /** @var string The token */ public $token; + /** @var User The user */ + public $user; /** * Create a new event instance. This event is triggered when a users tries to reset his or her password. diff --git a/app/Events/RequestedReportOnJournals.php b/app/Events/RequestedReportOnJournals.php index 9d9c0444a6..0bd2ac4c55 100644 --- a/app/Events/RequestedReportOnJournals.php +++ b/app/Events/RequestedReportOnJournals.php @@ -31,6 +31,8 @@ use Log; /** * Class RequestedReportOnJournals + * + * @codeCoverageIgnore */ class RequestedReportOnJournals { diff --git a/app/Events/RequestedVersionCheckStatus.php b/app/Events/RequestedVersionCheckStatus.php index dabe9ca7b8..a490ea0e38 100644 --- a/app/Events/RequestedVersionCheckStatus.php +++ b/app/Events/RequestedVersionCheckStatus.php @@ -30,6 +30,8 @@ use Illuminate\Queue\SerializesModels; /** * Class RequestedVersionCheckStatus + * + * @codeCoverageIgnore */ class RequestedVersionCheckStatus extends Event { diff --git a/app/Events/UserChangedEmail.php b/app/Events/UserChangedEmail.php index 917eef90ae..12e7aab446 100644 --- a/app/Events/UserChangedEmail.php +++ b/app/Events/UserChangedEmail.php @@ -29,6 +29,8 @@ use Illuminate\Queue\SerializesModels; /** * Class UserChangedEmail. + * + * @codeCoverageIgnore */ class UserChangedEmail extends Event { diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 5d6899c52b..72caac1261 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -38,6 +38,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** * Class Handler + * @codeCoverageIgnore */ class Handler extends ExceptionHandler { diff --git a/app/Export/Collector/AttachmentCollector.php b/app/Export/Collector/AttachmentCollector.php index 9eecde245b..a0a354451b 100644 --- a/app/Export/Collector/AttachmentCollector.php +++ b/app/Export/Collector/AttachmentCollector.php @@ -28,13 +28,15 @@ use Crypt; use FireflyIII\Models\Attachment; use FireflyIII\Repositories\Attachment\AttachmentRepositoryInterface; use Illuminate\Contracts\Encryption\DecryptException; -use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Support\Collection; use Log; use Storage; /** * Class AttachmentCollector. + * + * @deprecated + * @codeCoverageIgnore */ class AttachmentCollector extends BasicCollector implements CollectorInterface { @@ -108,7 +110,7 @@ class AttachmentCollector extends BasicCollector implements CollectorInterface if ($this->uploadDisk->exists($file)) { try { $decrypted = Crypt::decrypt($this->uploadDisk->get($file)); - } catch (FileNotFoundException|DecryptException $e) { + } catch (DecryptException $e) { Log::error('Catchable error: could not decrypt attachment #' . $attachment->id . ' because: ' . $e->getMessage()); return false; diff --git a/app/Export/Collector/BasicCollector.php b/app/Export/Collector/BasicCollector.php index 10215d6c58..c1db2a82a3 100644 --- a/app/Export/Collector/BasicCollector.php +++ b/app/Export/Collector/BasicCollector.php @@ -30,6 +30,9 @@ use Illuminate\Support\Collection; /** * Class BasicCollector. + * + * @codeCoverageIgnore + * @deprecated */ class BasicCollector { diff --git a/app/Export/Collector/CollectorInterface.php b/app/Export/Collector/CollectorInterface.php index b93dc1617b..c58e538ee1 100644 --- a/app/Export/Collector/CollectorInterface.php +++ b/app/Export/Collector/CollectorInterface.php @@ -29,6 +29,9 @@ use Illuminate\Support\Collection; /** * Interface CollectorInterface. + * + * @codeCoverageIgnore + * @deprecated */ interface CollectorInterface { diff --git a/app/Export/Collector/UploadCollector.php b/app/Export/Collector/UploadCollector.php index 5e1e6df02b..e2637b940f 100644 --- a/app/Export/Collector/UploadCollector.php +++ b/app/Export/Collector/UploadCollector.php @@ -31,6 +31,9 @@ use Storage; /** * Class UploadCollector. + * + * @codeCoverageIgnore + * @deprecated */ class UploadCollector extends BasicCollector implements CollectorInterface { diff --git a/app/Export/Entry/Entry.php b/app/Export/Entry/Entry.php index 8543c75646..9e0064d58e 100644 --- a/app/Export/Entry/Entry.php +++ b/app/Export/Entry/Entry.php @@ -43,6 +43,9 @@ use FireflyIII\Models\Transaction; * * @SuppressWarnings(PHPMD.LongVariable) * @SuppressWarnings(PHPMD.TooManyFields) + * + * @codeCoverageIgnore + * @deprecated */ final class Entry { diff --git a/app/Export/ExpandedProcessor.php b/app/Export/ExpandedProcessor.php index 27fdc83822..4adb9499cf 100644 --- a/app/Export/ExpandedProcessor.php +++ b/app/Export/ExpandedProcessor.php @@ -32,7 +32,7 @@ use FireflyIII\Export\Collector\AttachmentCollector; use FireflyIII\Export\Collector\UploadCollector; use FireflyIII\Export\Entry\Entry; use FireflyIII\Export\Exporter\ExporterInterface; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Helpers\Filter\InternalTransferFilter; use FireflyIII\Models\AccountMeta; use FireflyIII\Models\ExportJob; @@ -49,12 +49,15 @@ use ZipArchive; * Class ExpandedProcessor. * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) // its doing a lot. + * + * @codeCoverageIgnore + * @deprecated */ class ExpandedProcessor implements ProcessorInterface { /** @var Collection All accounts */ public $accounts; - /** @var string The export format*/ + /** @var string The export format */ public $exportFormat; /** @var bool Should include attachments */ public $includeAttachments; @@ -62,7 +65,7 @@ class ExpandedProcessor implements ProcessorInterface public $includeOldUploads; /** @var ExportJob The export job itself */ public $job; - /** @var array The settings*/ + /** @var array The settings */ public $settings; /** @var Collection The entries to export. */ private $exportEntries; @@ -107,13 +110,13 @@ class ExpandedProcessor implements ProcessorInterface public function collectJournals(): bool { // use journal collector thing. - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setUser($this->job->user); $collector->setAccounts($this->accounts)->setRange($this->settings['startDate'], $this->settings['endDate']) ->withOpposingAccount()->withBudgetInformation()->withCategoryInformation() ->removeFilter(InternalTransferFilter::class); - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); // get some more meta data for each entry: $ids = $transactions->pluck('journal_id')->toArray(); $assetIds = $transactions->pluck('account_id')->toArray(); @@ -222,7 +225,7 @@ class ExpandedProcessor implements ProcessorInterface { $exporterClass = config('firefly.export_formats.' . $this->exportFormat); /** @var ExporterInterface $exporter */ - $exporter = app($exporterClass); + $exporter = app($exporterClass); $exporter->setJob($this->job); $exporter->setEntries($this->exportEntries); $exporter->run(); @@ -260,7 +263,7 @@ class ExpandedProcessor implements ProcessorInterface /** * Delete files. */ - private function deleteFiles():void + private function deleteFiles(): void { $disk = Storage::disk('export'); foreach ($this->getFiles() as $file) { diff --git a/app/Export/Exporter/BasicExporter.php b/app/Export/Exporter/BasicExporter.php index fd1003ddfa..de7eaed474 100644 --- a/app/Export/Exporter/BasicExporter.php +++ b/app/Export/Exporter/BasicExporter.php @@ -29,6 +29,9 @@ use Illuminate\Support\Collection; /** * Class BasicExporter. + * + * @codeCoverageIgnore + * @deprecated */ class BasicExporter { diff --git a/app/Export/Exporter/CsvExporter.php b/app/Export/Exporter/CsvExporter.php index bbc66b8345..6e4a7b96d1 100644 --- a/app/Export/Exporter/CsvExporter.php +++ b/app/Export/Exporter/CsvExporter.php @@ -30,6 +30,9 @@ use Storage; /** * Class CsvExporter. + * + * @codeCoverageIgnore + * @deprecated */ class CsvExporter extends BasicExporter implements ExporterInterface { diff --git a/app/Export/Exporter/ExporterInterface.php b/app/Export/Exporter/ExporterInterface.php index cc44b69c08..998510f65b 100644 --- a/app/Export/Exporter/ExporterInterface.php +++ b/app/Export/Exporter/ExporterInterface.php @@ -29,6 +29,9 @@ use Illuminate\Support\Collection; /** * Interface ExporterInterface. + * + * @codeCoverageIgnore + * @deprecated */ interface ExporterInterface { diff --git a/app/Export/ProcessorInterface.php b/app/Export/ProcessorInterface.php index 641e26eaeb..330e69c2d2 100644 --- a/app/Export/ProcessorInterface.php +++ b/app/Export/ProcessorInterface.php @@ -28,6 +28,9 @@ use Illuminate\Support\Collection; /** * Interface ProcessorInterface. + * + * @codeCoverageIgnore + * @deprecated */ interface ProcessorInterface { diff --git a/app/Factory/AccountFactory.php b/app/Factory/AccountFactory.php index aeb9b62b3b..6be9b42330 100644 --- a/app/Factory/AccountFactory.php +++ b/app/Factory/AccountFactory.php @@ -32,6 +32,7 @@ use FireflyIII\Models\Account; use FireflyIII\Models\AccountType; use FireflyIII\Services\Internal\Support\AccountServiceTrait; use FireflyIII\User; +use Log; /** * Factory to create or return accounts. @@ -80,8 +81,9 @@ class AccountFactory 'iban' => $data['iban'], ]; - // remove virtual balance when not an asset account: - if ($type->type !== AccountType::ASSET) { + // remove virtual balance when not an asset account or a liability + $canHaveVirtual = [AccountType::ASSET, AccountType::DEBT, AccountType::LOAN, AccountType::MORTGAGE, AccountType::CREDITCARD]; + if (!\in_array($type->type, $canHaveVirtual, true)) { $databaseData['virtual_balance'] = '0'; } @@ -93,7 +95,7 @@ class AccountFactory $return = Account::create($databaseData); $this->updateMetaData($return, $data); - if ($type->type === AccountType::ASSET) { + if (\in_array($type->type, $canHaveVirtual, true)) { if ($this->validIBData($data)) { $this->updateIB($return, $data); } @@ -130,6 +132,7 @@ class AccountFactory } /** + * * @param string $accountName * @param string $accountType * @@ -188,9 +191,13 @@ class AccountFactory $result = AccountType::find($accountTypeId); } if (null === $result) { - /** @var string $type */ - $type = (string)config('firefly.accountTypeByIdentifier.' . $accountType); - $result = AccountType::whereType($type)->first(); + Log::debug(sprintf('No account type found by ID, continue search for "%s".', $accountType)); + /** @var array $types */ + $types = config('firefly.accountTypeByIdentifier.' . $accountType) ?? []; + if (\count($types) > 0) { + Log::debug(sprintf('%d accounts in list from config', \count($types)), $types); + $result = AccountType::whereIn('type', $types)->first(); + } if (null === $result && null !== $accountType) { // try as full name: $result = AccountType::whereType($accountType)->first(); diff --git a/app/Factory/AccountMetaFactory.php b/app/Factory/AccountMetaFactory.php index 67ec707046..5861e81b4d 100644 --- a/app/Factory/AccountMetaFactory.php +++ b/app/Factory/AccountMetaFactory.php @@ -65,7 +65,7 @@ class AccountMetaFactory // if $data has field and $entry is null, create new one: if (null === $entry) { Log::debug(sprintf('Created meta-field "%s":"%s" for account #%d ("%s") ', $field, $value, $account->id, $account->name)); - $this->create(['account_id' => $account->id, 'name' => $field, 'data' => $value]); + return $this->create(['account_id' => $account->id, 'name' => $field, 'data' => $value]); } // if $data has field and $entry is not null, update $entry: @@ -75,12 +75,13 @@ class AccountMetaFactory Log::debug(sprintf('Updated meta-field "%s":"%s" for #%d ("%s") ', $field, $value, $account->id, $account->name)); } } - if ('' === $value && null !== $entry && isset($data[$field])) { + if ('' === $value && null !== $entry) { try { $entry->delete(); - } catch (Exception $e) { - Log::debug(sprintf('Could not delete entry: %s', $e->getMessage())); + } catch (Exception $e) { // @codeCoverageIgnore + Log::debug(sprintf('Could not delete entry: %s', $e->getMessage())); // @codeCoverageIgnore } + return null; } return $entry; diff --git a/app/Factory/BillFactory.php b/app/Factory/BillFactory.php index a3de276298..c08cea5a8d 100644 --- a/app/Factory/BillFactory.php +++ b/app/Factory/BillFactory.php @@ -25,6 +25,7 @@ declare(strict_types=1); namespace FireflyIII\Factory; use FireflyIII\Models\Bill; +use FireflyIII\Models\TransactionCurrency; use FireflyIII\Services\Internal\Support\BillServiceTrait; use FireflyIII\User; use Illuminate\Support\Collection; @@ -46,6 +47,10 @@ class BillFactory */ public function create(array $data): ?Bill { + /** @var TransactionCurrencyFactory $factory */ + $factory = app(TransactionCurrencyFactory::class); + /** @var TransactionCurrency $currency */ + $currency = $factory->find((int)$data['currency_id'], (string)$data['currency_code']); /** @var Bill $bill */ $bill = Bill::create( [ @@ -53,7 +58,7 @@ class BillFactory 'match' => 'MIGRATED_TO_RULES', 'amount_min' => $data['amount_min'], 'user_id' => $this->user->id, - 'transaction_currency_id' => $data['transaction_currency_id'], + 'transaction_currency_id' => $currency->id, 'amount_max' => $data['amount_max'], 'date' => $data['date'], 'repeat_freq' => $data['repeat_freq'], diff --git a/app/Factory/TransactionFactory.php b/app/Factory/TransactionFactory.php index 39d483b46a..9168702251 100644 --- a/app/Factory/TransactionFactory.php +++ b/app/Factory/TransactionFactory.php @@ -59,10 +59,10 @@ class TransactionFactory $currencyId = isset($data['currency']) ? $data['currency']->id : $currencyId; if ('' === $data['amount']) { Log::error('Empty string in data.', $data); - throw new FireflyException('Amount is an empty string, which Firefly III cannot handle. Apologies.'); // @codeCoverageIgnore + throw new FireflyException('Amount is an empty string, which Firefly III cannot handle. Apologies.'); } if (null === $currencyId) { - throw new FireflyException('Cannot store transaction without currency information.'); // @codeCoverageIgnore + throw new FireflyException('Cannot store transaction without currency information.'); } $data['foreign_amount'] = '' === (string)$data['foreign_amount'] ? null : $data['foreign_amount']; Log::debug(sprintf('Create transaction for account #%d ("%s") with amount %s', $data['account']->id, $data['account']->name, $data['amount'])); @@ -121,13 +121,9 @@ class TransactionFactory } Log::debug(sprintf('Source type is "%s", destination type is "%s"', $sourceAccount->accountType->type, $destinationAccount->accountType->type)); - // throw big fat error when source type === dest type and it's not a transfer or reconciliation. - if ($sourceAccount->accountType->type === $destinationAccount->accountType->type && $journal->transactionType->type !== TransactionType::TRANSFER) { - throw new FireflyException(sprintf('Source and destination account cannot be both of the type "%s"', $destinationAccount->accountType->type)); - } - if ($sourceAccount->accountType->type !== AccountType::ASSET && $destinationAccount->accountType->type !== AccountType::ASSET) { - throw new FireflyException('At least one of the accounts must be an asset account.'); - } + + // based on the source type, destination type and transaction type, the system can start throwing FireflyExceptions. + $this->validateTransaction($sourceAccount->accountType->type, $destinationAccount->accountType->type, $journal->transactionType->type); $source = $this->create( [ @@ -154,7 +150,7 @@ class TransactionFactory ] ); if (null === $source || null === $dest) { - throw new FireflyException('Could not create transactions.'); + throw new FireflyException('Could not create transactions.'); // @codeCoverageIgnore } // set foreign currency @@ -194,5 +190,29 @@ class TransactionFactory $this->user = $user; } + /** + * @param string $sourceType + * @param string $destinationType + * @param string $transactionType + * + * @throws FireflyException + */ + private function validateTransaction(string $sourceType, string $destinationType, string $transactionType): void + { + // throw big fat error when source type === dest type and it's not a transfer or reconciliation. + if ($sourceType === $destinationType && $transactionType !== TransactionType::TRANSFER) { + throw new FireflyException(sprintf('Source and destination account cannot be both of the type "%s"', $destinationType)); + } + // source must be in this list AND dest must be in this list: + $list = [AccountType::DEFAULT, AccountType::ASSET, AccountType::CASH, AccountType::DEBT, AccountType::MORTGAGE, AccountType::LOAN, AccountType::MORTGAGE]; + if ( + !\in_array($sourceType, $list, true) && + !\in_array($destinationType, $list, true)) { + throw new FireflyException(sprintf('At least one of the accounts must be an asset account (%s, %s).', $sourceType, $destinationType)); + } + // either of these must be asset or default account. + + } + } diff --git a/app/Factory/TransactionJournalFactory.php b/app/Factory/TransactionJournalFactory.php index 9096d8099d..1dbd565934 100644 --- a/app/Factory/TransactionJournalFactory.php +++ b/app/Factory/TransactionJournalFactory.php @@ -104,7 +104,7 @@ class TransactionJournalFactory // store date meta fields (if present): $fields = ['sepa-cc', 'sepa-ct-op', 'sepa-ct-id', 'sepa-db', 'sepa-country', 'sepa-ep', 'sepa-ci', 'interest_date', 'book_date', 'process_date', 'due_date', 'recurrence_id', 'payment_date', 'invoice_date', 'internal_reference', 'bunq_payment_id', 'importHash', 'importHashV2', - 'external_id','sepa-batch-id']; + 'external_id', 'sepa-batch-id']; foreach ($fields as $field) { $this->storeMeta($journal, $data, $field); diff --git a/app/Factory/TransactionTypeFactory.php b/app/Factory/TransactionTypeFactory.php index 05f0fdf67d..d684555390 100644 --- a/app/Factory/TransactionTypeFactory.php +++ b/app/Factory/TransactionTypeFactory.php @@ -28,7 +28,6 @@ namespace FireflyIII\Factory; use FireflyIII\Models\TransactionType; /** - * @codeCoverageIgnore * Class TransactionTypeFactory */ class TransactionTypeFactory diff --git a/app/Generator/Chart/Basic/ChartJsGenerator.php b/app/Generator/Chart/Basic/ChartJsGenerator.php index c21522586e..28862b1706 100644 --- a/app/Generator/Chart/Basic/ChartJsGenerator.php +++ b/app/Generator/Chart/Basic/ChartJsGenerator.php @@ -39,6 +39,8 @@ class ChartJsGenerator implements GeneratorInterface * 'type' => bar or line, optional * 'yAxisID' => ID of yAxis, optional, will not be included when unused. * 'fill' => if to fill a line? optional, will not be included when unused. + * 'currency_symbol' => 'x', + * 'backgroundColor' => 'x', * 'entries' => * [ * 'label-of-entry' => 'value' @@ -120,7 +122,7 @@ class ChartJsGenerator implements GeneratorInterface // different sort when values are positive and when they're negative. asort($data); $next = next($data); - if (!\is_bool($next) && 1 === bccomp($next, '0')) { + if (!\is_bool($next) && 1 === bccomp((string)$next, '0')) { // next is positive, sort other way around. arsort($data); } @@ -129,7 +131,7 @@ class ChartJsGenerator implements GeneratorInterface $index = 0; foreach ($data as $key => $value) { // make larger than 0 - $chartData['datasets'][0]['data'][] = (float)app('steam')->positive($value); + $chartData['datasets'][0]['data'][] = (float)app('steam')->positive((string)$value); $chartData['datasets'][0]['backgroundColor'][] = ChartColour::getColour($index); $chartData['labels'][] = $key; ++$index; @@ -138,6 +140,46 @@ class ChartJsGenerator implements GeneratorInterface return $chartData; } + /** + * Expects data as:. + * + * key => [value => x, 'currency_symbol' => 'x'] + * + * @param array $data + * + * @return array + */ + public function multiCurrencyPieChart(array $data): array + { + $chartData = [ + 'datasets' => [ + 0 => [], + ], + 'labels' => [], + ]; + + $amounts = array_column($data, 'amount'); + $next = next($amounts); + $sortFlag = SORT_ASC; + if (!\is_bool($next) && 1 === bccomp((string)$next, '0')) { + $sortFlag = SORT_DESC; + } + array_multisort($amounts, $sortFlag, $data); + unset($next, $sortFlag, $amounts); + + $index = 0; + foreach ($data as $key => $valueArray) { + // make larger than 0 + $chartData['datasets'][0]['data'][] = (float)app('steam')->positive((string)$valueArray['amount']); + $chartData['datasets'][0]['backgroundColor'][] = ChartColour::getColour($index); + $chartData['datasets'][0]['currency_symbol'][] = $valueArray['currency_symbol']; + $chartData['labels'][] = $key; + ++$index; + } + + return $chartData; + } + /** * Will generate a (ChartJS) compatible array from the given input. Expects this format:. * diff --git a/app/Generator/Chart/Basic/GeneratorInterface.php b/app/Generator/Chart/Basic/GeneratorInterface.php index 5cfdd4d94f..cc7d55626c 100644 --- a/app/Generator/Chart/Basic/GeneratorInterface.php +++ b/app/Generator/Chart/Basic/GeneratorInterface.php @@ -27,6 +27,13 @@ namespace FireflyIII\Generator\Chart\Basic; */ interface GeneratorInterface { + /** + * @param array $data + * + * @return array + */ + public function multiCurrencyPieChart(array $data): array; + /** * Will generate a Chart JS compatible array from the given input. Expects this format. * diff --git a/app/Generator/Report/Account/MonthReportGenerator.php b/app/Generator/Report/Account/MonthReportGenerator.php index 49f0cbfbd7..f9a53e5979 100644 --- a/app/Generator/Report/Account/MonthReportGenerator.php +++ b/app/Generator/Report/Account/MonthReportGenerator.php @@ -25,9 +25,13 @@ namespace FireflyIII\Generator\Report\Account; use Carbon\Carbon; use FireflyIII\Generator\Report\ReportGeneratorInterface; use Illuminate\Support\Collection; +use Log; +use Throwable; /** * Class MonthReportGenerator. + * + * @codeCoverageIgnore */ class MonthReportGenerator implements ReportGeneratorInterface { @@ -44,7 +48,6 @@ class MonthReportGenerator implements ReportGeneratorInterface * Generate the report. * * @return string - * @throws \Throwable */ public function generate(): string { @@ -52,11 +55,17 @@ class MonthReportGenerator implements ReportGeneratorInterface $expenseIds = implode(',', $this->expense->pluck('id')->toArray()); $reportType = 'account'; $preferredPeriod = $this->preferredPeriod(); + try { + $result = view( + 'reports.account.report', + compact('accountIds', 'reportType', 'expenseIds', 'preferredPeriod') + )->with('start', $this->start)->with('end', $this->end)->render(); + } catch (Throwable $e) { + Log::error(sprintf('Cannot render reports.account.report: %s', $e->getMessage())); + $result = 'Could not render report view.'; + } - return view( - 'reports.account.report', - compact('accountIds', 'reportType', 'expenseIds', 'preferredPeriod') - )->with('start', $this->start)->with('end', $this->end)->render(); + return $result; } /** diff --git a/app/Generator/Report/Account/MultiYearReportGenerator.php b/app/Generator/Report/Account/MultiYearReportGenerator.php index a4d17d0d83..ada1211667 100644 --- a/app/Generator/Report/Account/MultiYearReportGenerator.php +++ b/app/Generator/Report/Account/MultiYearReportGenerator.php @@ -24,6 +24,8 @@ namespace FireflyIII\Generator\Report\Account; /** * Class MultiYearReportGenerator. + * + * @codeCoverageIgnore */ class MultiYearReportGenerator extends MonthReportGenerator { diff --git a/app/Generator/Report/Account/YearReportGenerator.php b/app/Generator/Report/Account/YearReportGenerator.php index c1e890ba19..bdee367fed 100644 --- a/app/Generator/Report/Account/YearReportGenerator.php +++ b/app/Generator/Report/Account/YearReportGenerator.php @@ -24,6 +24,8 @@ namespace FireflyIII\Generator\Report\Account; /** * Class YearReportGenerator. + * + * @codeCoverageIgnore */ class YearReportGenerator extends MonthReportGenerator { diff --git a/app/Generator/Report/Audit/MonthReportGenerator.php b/app/Generator/Report/Audit/MonthReportGenerator.php index 386f27ded2..cadc73b157 100644 --- a/app/Generator/Report/Audit/MonthReportGenerator.php +++ b/app/Generator/Report/Audit/MonthReportGenerator.php @@ -28,12 +28,14 @@ namespace FireflyIII\Generator\Report\Audit; use Carbon\Carbon; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Generator\Report\ReportGeneratorInterface; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Models\Account; use FireflyIII\Models\Transaction; use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface; use Illuminate\Support\Collection; +use Log; +use Throwable; /** * Class MonthReportGenerator. @@ -52,7 +54,7 @@ class MonthReportGenerator implements ReportGeneratorInterface * * @return string * @throws FireflyException - * @throws \Throwable + * @codeCoverageIgnore */ public function generate(): string { @@ -78,100 +80,16 @@ class MonthReportGenerator implements ReportGeneratorInterface 'internal_reference', 'notes', 'create_date', 'update_date', ]; + try { + $result = view('reports.audit.report', compact('reportType', 'accountIds', 'auditData', 'hideable', 'defaultShow')) + ->with('start', $this->start)->with('end', $this->end)->with('accounts', $this->accounts) + ->render(); + } catch (Throwable $e) { + Log::error(sprintf('Cannot render reports.audit.report: %s', $e->getMessage())); + $result = 'Could not render report view.'; + } - return view('reports.audit.report', compact('reportType', 'accountIds', 'auditData', 'hideable', 'defaultShow')) - ->with('start', $this->start)->with('end', $this->end)->with('accounts', $this->accounts) - ->render(); - } - - /** - * Account collection setter. - * - * @param Collection $accounts - * - * @return ReportGeneratorInterface - */ - public function setAccounts(Collection $accounts): ReportGeneratorInterface - { - $this->accounts = $accounts; - - return $this; - } - - /** - * Budget collection setter. - * - * @param Collection $budgets - * - * @return ReportGeneratorInterface - */ - public function setBudgets(Collection $budgets): ReportGeneratorInterface - { - return $this; - } - - /** - * Category collection setter. - * - * @param Collection $categories - * - * @return ReportGeneratorInterface - */ - public function setCategories(Collection $categories): ReportGeneratorInterface - { - return $this; - } - - /** - * End date setter. - * - * @param Carbon $date - * - * @return ReportGeneratorInterface - */ - public function setEndDate(Carbon $date): ReportGeneratorInterface - { - $this->end = $date; - - return $this; - } - - /** - * Expenses collection setter. - * - * @param Collection $expense - * - * @return ReportGeneratorInterface - */ - public function setExpense(Collection $expense): ReportGeneratorInterface - { - return $this; - } - - /** - * Start date collection setter. - * - * @param Carbon $date - * - * @return ReportGeneratorInterface - */ - public function setStartDate(Carbon $date): ReportGeneratorInterface - { - $this->start = $date; - - return $this; - } - - /** - * Tags collection setter. - * - * @param Collection $tags - * - * @return ReportGeneratorInterface - */ - public function setTags(Collection $tags): ReportGeneratorInterface - { - return $this; + return $result; } /** @@ -185,7 +103,7 @@ class MonthReportGenerator implements ReportGeneratorInterface * @SuppressWarnings(PHPMD.ExcessiveMethodLength) // not that long * @throws FireflyException */ - private function getAuditReport(Account $account, Carbon $date): array + public function getAuditReport(Account $account, Carbon $date): array { /** @var CurrencyRepositoryInterface $currencyRepos */ $currencyRepos = app(CurrencyRepositoryInterface::class); @@ -194,10 +112,10 @@ class MonthReportGenerator implements ReportGeneratorInterface $accountRepository = app(AccountRepositoryInterface::class); $accountRepository->setUser($account->user); - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts(new Collection([$account]))->setRange($this->start, $this->end); - $journals = $collector->getJournals(); + $journals = $collector->getTransactions(); $journals = $journals->reverse(); $dayBeforeBalance = app('steam')->balance($account, $date); $startBalance = $dayBeforeBalance; @@ -232,4 +150,101 @@ class MonthReportGenerator implements ReportGeneratorInterface return $return; } + + /** + * Account collection setter. + * + * @param Collection $accounts + * + * @return ReportGeneratorInterface + * @codeCoverageIgnore + */ + public function setAccounts(Collection $accounts): ReportGeneratorInterface + { + $this->accounts = $accounts; + + return $this; + } + + /** + * Budget collection setter. + * + * @param Collection $budgets + * + * @return ReportGeneratorInterface + * @codeCoverageIgnore + */ + public function setBudgets(Collection $budgets): ReportGeneratorInterface + { + return $this; + } + + /** + * Category collection setter. + * + * @param Collection $categories + * + * @return ReportGeneratorInterface + * @codeCoverageIgnore + */ + public function setCategories(Collection $categories): ReportGeneratorInterface + { + return $this; + } + + /** + * End date setter. + * + * @param Carbon $date + * + * @return ReportGeneratorInterface + * @codeCoverageIgnore + */ + public function setEndDate(Carbon $date): ReportGeneratorInterface + { + $this->end = $date; + + return $this; + } + + /** + * Expenses collection setter. + * + * @param Collection $expense + * + * @return ReportGeneratorInterface + * @codeCoverageIgnore + */ + public function setExpense(Collection $expense): ReportGeneratorInterface + { + return $this; + } + + /** + * Start date collection setter. + * + * @param Carbon $date + * + * @return ReportGeneratorInterface + * @codeCoverageIgnore + */ + public function setStartDate(Carbon $date): ReportGeneratorInterface + { + $this->start = $date; + + return $this; + } + + /** + * Tags collection setter. + * + * @param Collection $tags + * + * @return ReportGeneratorInterface + * @codeCoverageIgnore + */ + public function setTags(Collection $tags): ReportGeneratorInterface + { + return $this; + } } diff --git a/app/Generator/Report/Audit/MultiYearReportGenerator.php b/app/Generator/Report/Audit/MultiYearReportGenerator.php index 0bd3b5697f..ade7d0b84b 100644 --- a/app/Generator/Report/Audit/MultiYearReportGenerator.php +++ b/app/Generator/Report/Audit/MultiYearReportGenerator.php @@ -24,6 +24,7 @@ namespace FireflyIII\Generator\Report\Audit; /** * Class MultiYearReportGenerator. + * @codeCoverageIgnore */ class MultiYearReportGenerator extends MonthReportGenerator { diff --git a/app/Generator/Report/Audit/YearReportGenerator.php b/app/Generator/Report/Audit/YearReportGenerator.php index e341718728..4ff82713f4 100644 --- a/app/Generator/Report/Audit/YearReportGenerator.php +++ b/app/Generator/Report/Audit/YearReportGenerator.php @@ -24,6 +24,7 @@ namespace FireflyIII\Generator\Report\Audit; /** * Class YearReportGenerator. + * @codeCoverageIgnore */ class YearReportGenerator extends MonthReportGenerator { diff --git a/app/Generator/Report/Budget/MonthReportGenerator.php b/app/Generator/Report/Budget/MonthReportGenerator.php index ff4229ab26..6b7539c0d4 100644 --- a/app/Generator/Report/Budget/MonthReportGenerator.php +++ b/app/Generator/Report/Budget/MonthReportGenerator.php @@ -27,7 +27,7 @@ namespace FireflyIII\Generator\Report\Budget; use Carbon\Carbon; use FireflyIII\Generator\Report\ReportGeneratorInterface; use FireflyIII\Generator\Report\Support; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Helpers\Filter\OpposingAccountFilter; use FireflyIII\Helpers\Filter\PositiveAmountFilter; use FireflyIII\Helpers\Filter\TransferFilter; @@ -35,9 +35,12 @@ use FireflyIII\Models\Transaction; use FireflyIII\Models\TransactionType; use Illuminate\Support\Collection; use Log; +use Throwable; /** * Class MonthReportGenerator. + * + * @codeCoverageIgnore */ class MonthReportGenerator extends Support implements ReportGeneratorInterface { @@ -64,7 +67,6 @@ class MonthReportGenerator extends Support implements ReportGeneratorInterface * Generates the report. * * @return string - * @throws \Throwable */ public function generate(): string { @@ -77,11 +79,18 @@ class MonthReportGenerator extends Support implements ReportGeneratorInterface $topExpenses = $this->getTopExpenses(); // render! - return view('reports.budget.month', compact('accountIds', 'budgetIds', 'accountSummary', 'budgetSummary', 'averageExpenses', 'topExpenses')) - ->with('start', $this->start)->with('end', $this->end) - ->with('budgets', $this->budgets) - ->with('accounts', $this->accounts) - ->render(); + try { + $result = view('reports.budget.month', compact('accountIds', 'budgetIds', 'accountSummary', 'budgetSummary', 'averageExpenses', 'topExpenses')) + ->with('start', $this->start)->with('end', $this->end) + ->with('budgets', $this->budgets) + ->with('accounts', $this->accounts) + ->render(); + } catch (Throwable $e) { + Log::error(sprintf('Cannot render reports.account.report: %s', $e->getMessage())); + $result = 'Could not render report view.'; + } + + return $result; } /** @@ -189,8 +198,8 @@ class MonthReportGenerator extends Support implements ReportGeneratorInterface return $this->expenses; } - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts($this->accounts)->setRange($this->start, $this->end) ->setTypes([TransactionType::WITHDRAWAL]) ->setBudgets($this->budgets)->withOpposingAccount(); @@ -199,7 +208,7 @@ class MonthReportGenerator extends Support implements ReportGeneratorInterface $collector->addFilter(OpposingAccountFilter::class); $collector->addFilter(PositiveAmountFilter::class); - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); $this->expenses = $transactions; return $transactions; diff --git a/app/Generator/Report/Budget/MultiYearReportGenerator.php b/app/Generator/Report/Budget/MultiYearReportGenerator.php index 1e32b8d8ce..ecfad642be 100644 --- a/app/Generator/Report/Budget/MultiYearReportGenerator.php +++ b/app/Generator/Report/Budget/MultiYearReportGenerator.php @@ -24,6 +24,7 @@ namespace FireflyIII\Generator\Report\Budget; /** * Class MultiYearReportGenerator. + * @codeCoverageIgnore */ class MultiYearReportGenerator extends MonthReportGenerator { diff --git a/app/Generator/Report/Budget/YearReportGenerator.php b/app/Generator/Report/Budget/YearReportGenerator.php index a025513451..4323ce2ddd 100644 --- a/app/Generator/Report/Budget/YearReportGenerator.php +++ b/app/Generator/Report/Budget/YearReportGenerator.php @@ -24,6 +24,7 @@ namespace FireflyIII\Generator\Report\Budget; /** * Class YearReportGenerator. + * @codeCoverageIgnore */ class YearReportGenerator extends MonthReportGenerator { diff --git a/app/Generator/Report/Category/MonthReportGenerator.php b/app/Generator/Report/Category/MonthReportGenerator.php index ef187a575b..ad477f15f1 100644 --- a/app/Generator/Report/Category/MonthReportGenerator.php +++ b/app/Generator/Report/Category/MonthReportGenerator.php @@ -27,7 +27,7 @@ namespace FireflyIII\Generator\Report\Category; use Carbon\Carbon; use FireflyIII\Generator\Report\ReportGeneratorInterface; use FireflyIII\Generator\Report\Support; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Helpers\Filter\NegativeAmountFilter; use FireflyIII\Helpers\Filter\OpposingAccountFilter; use FireflyIII\Helpers\Filter\PositiveAmountFilter; @@ -36,9 +36,11 @@ use FireflyIII\Models\Transaction; use FireflyIII\Models\TransactionType; use Illuminate\Support\Collection; use Log; +use Throwable; /** * Class MonthReportGenerator. + * @codeCoverageIgnore */ class MonthReportGenerator extends Support implements ReportGeneratorInterface { @@ -68,7 +70,6 @@ class MonthReportGenerator extends Support implements ReportGeneratorInterface * Generates the report. * * @return string - * @throws \Throwable */ public function generate(): string { @@ -85,24 +86,23 @@ class MonthReportGenerator extends Support implements ReportGeneratorInterface $topIncome = $this->getTopIncome(); // render! - return view( - 'reports.category.month', - compact( - 'accountIds', - 'categoryIds', - 'topIncome', - 'reportType', - 'accountSummary', - 'categorySummary', - 'averageExpenses', - 'averageIncome', - 'topExpenses' + try { + return view( + 'reports.category.month', compact( + 'accountIds', 'categoryIds', 'topIncome', 'reportType', 'accountSummary', 'categorySummary', 'averageExpenses', + 'averageIncome', 'topExpenses' + ) ) - ) - ->with('start', $this->start)->with('end', $this->end) - ->with('categories', $this->categories) - ->with('accounts', $this->accounts) - ->render(); + ->with('start', $this->start)->with('end', $this->end) + ->with('categories', $this->categories) + ->with('accounts', $this->accounts) + ->render(); + } catch (Throwable $e) { + Log::error(sprintf('Cannot render reports.category.month: %s', $e->getMessage())); + $result = 'Could not render report view.'; + } + + return $result; } /** @@ -210,8 +210,8 @@ class MonthReportGenerator extends Support implements ReportGeneratorInterface return $this->expenses; } - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts($this->accounts)->setRange($this->start, $this->end) ->setTypes([TransactionType::WITHDRAWAL, TransactionType::TRANSFER]) ->setCategories($this->categories)->withOpposingAccount(); @@ -220,7 +220,7 @@ class MonthReportGenerator extends Support implements ReportGeneratorInterface $collector->addFilter(OpposingAccountFilter::class); $collector->addFilter(PositiveAmountFilter::class); - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); $this->expenses = $transactions; return $transactions; @@ -237,8 +237,8 @@ class MonthReportGenerator extends Support implements ReportGeneratorInterface return $this->income; } - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts($this->accounts)->setRange($this->start, $this->end) ->setTypes([TransactionType::DEPOSIT, TransactionType::TRANSFER]) ->setCategories($this->categories)->withOpposingAccount(); @@ -246,7 +246,7 @@ class MonthReportGenerator extends Support implements ReportGeneratorInterface $collector->addFilter(OpposingAccountFilter::class); $collector->addFilter(NegativeAmountFilter::class); - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); $this->income = $transactions; return $transactions; diff --git a/app/Generator/Report/Category/MultiYearReportGenerator.php b/app/Generator/Report/Category/MultiYearReportGenerator.php index 2fe96eb9cd..39a881178e 100644 --- a/app/Generator/Report/Category/MultiYearReportGenerator.php +++ b/app/Generator/Report/Category/MultiYearReportGenerator.php @@ -24,6 +24,7 @@ namespace FireflyIII\Generator\Report\Category; /** * Class MultiYearReportGenerator. + * @codeCoverageIgnore */ class MultiYearReportGenerator extends MonthReportGenerator { diff --git a/app/Generator/Report/Category/YearReportGenerator.php b/app/Generator/Report/Category/YearReportGenerator.php index 920ea366e7..57f07fd975 100644 --- a/app/Generator/Report/Category/YearReportGenerator.php +++ b/app/Generator/Report/Category/YearReportGenerator.php @@ -24,6 +24,7 @@ namespace FireflyIII\Generator\Report\Category; /** * Class YearReportGenerator. + * @codeCoverageIgnore */ class YearReportGenerator extends MonthReportGenerator { diff --git a/app/Generator/Report/ReportGeneratorFactory.php b/app/Generator/Report/ReportGeneratorFactory.php index 6e06480a33..04e264bb3d 100644 --- a/app/Generator/Report/ReportGeneratorFactory.php +++ b/app/Generator/Report/ReportGeneratorFactory.php @@ -27,6 +27,7 @@ use FireflyIII\Exceptions\FireflyException; /** * Class ReportGeneratorFactory. + * @codeCoverageIgnore */ class ReportGeneratorFactory { diff --git a/app/Generator/Report/Standard/MonthReportGenerator.php b/app/Generator/Report/Standard/MonthReportGenerator.php index e27f4d79ed..ba6fc749a3 100644 --- a/app/Generator/Report/Standard/MonthReportGenerator.php +++ b/app/Generator/Report/Standard/MonthReportGenerator.php @@ -26,9 +26,12 @@ use Carbon\Carbon; use FireflyIII\Generator\Report\ReportGeneratorInterface; use FireflyIII\Helpers\Report\ReportHelperInterface; use Illuminate\Support\Collection; +use Log; +use Throwable; /** * Class MonthReportGenerator. + * @codeCoverageIgnore */ class MonthReportGenerator implements ReportGeneratorInterface { @@ -43,7 +46,6 @@ class MonthReportGenerator implements ReportGeneratorInterface * Generates the report. * * @return string - * @throws \Throwable */ public function generate(): string { @@ -53,11 +55,17 @@ class MonthReportGenerator implements ReportGeneratorInterface $accountIds = implode(',', $this->accounts->pluck('id')->toArray()); $reportType = 'default'; - // continue! - return view( - 'reports.default.month', - compact('bills', 'accountIds', 'reportType') - )->with('start', $this->start)->with('end', $this->end)->render(); + try { + return view( + 'reports.default.month', + compact('bills', 'accountIds', 'reportType') + )->with('start', $this->start)->with('end', $this->end)->render(); + } catch (Throwable $e) { + Log::error(sprintf('Cannot render reports.default.month: %s', $e->getMessage())); + $result = 'Could not render report view.'; + } + + return $result; } /** diff --git a/app/Generator/Report/Standard/MultiYearReportGenerator.php b/app/Generator/Report/Standard/MultiYearReportGenerator.php index ec507b9386..e9ba9d40f5 100644 --- a/app/Generator/Report/Standard/MultiYearReportGenerator.php +++ b/app/Generator/Report/Standard/MultiYearReportGenerator.php @@ -25,9 +25,12 @@ namespace FireflyIII\Generator\Report\Standard; use Carbon\Carbon; use FireflyIII\Generator\Report\ReportGeneratorInterface; use Illuminate\Support\Collection; +use Log; +use Throwable; /** * Class MonthReportGenerator. + * @codeCoverageIgnore */ class MultiYearReportGenerator implements ReportGeneratorInterface { @@ -42,7 +45,6 @@ class MultiYearReportGenerator implements ReportGeneratorInterface * Generates the report. * * @return string - * @throws \Throwable */ public function generate(): string { @@ -50,11 +52,17 @@ class MultiYearReportGenerator implements ReportGeneratorInterface $accountIds = implode(',', $this->accounts->pluck('id')->toArray()); $reportType = 'default'; - // continue! - return view( - 'reports.default.multi-year', - compact('accountIds', 'reportType') - )->with('start', $this->start)->with('end', $this->end)->render(); + try { + return view( + 'reports.default.multi-year', + compact('accountIds', 'reportType') + )->with('start', $this->start)->with('end', $this->end)->render(); + } catch (Throwable $e) { + Log::error(sprintf('Cannot render reports.default.multi-year: %s', $e->getMessage())); + $result = 'Could not render report view.'; + } + + return $result; } /** diff --git a/app/Generator/Report/Standard/YearReportGenerator.php b/app/Generator/Report/Standard/YearReportGenerator.php index 47f92f47ad..7479aa7024 100644 --- a/app/Generator/Report/Standard/YearReportGenerator.php +++ b/app/Generator/Report/Standard/YearReportGenerator.php @@ -25,9 +25,12 @@ namespace FireflyIII\Generator\Report\Standard; use Carbon\Carbon; use FireflyIII\Generator\Report\ReportGeneratorInterface; use Illuminate\Support\Collection; +use Log; +use Throwable; /** * Class MonthReportGenerator. + * @codeCoverageIgnore */ class YearReportGenerator implements ReportGeneratorInterface { @@ -42,7 +45,6 @@ class YearReportGenerator implements ReportGeneratorInterface * Generates the report. * * @return string - * @throws \Throwable */ public function generate(): string { @@ -50,13 +52,20 @@ class YearReportGenerator implements ReportGeneratorInterface $accountIds = implode(',', $this->accounts->pluck('id')->toArray()); $reportType = 'default'; - // continue! - return view( - 'reports.default.year', - compact('accountIds', 'reportType') - )->with('start', $this->start)->with('end', $this->end)->render(); + try { + $result = view( + 'reports.default.year', + compact('accountIds', 'reportType') + )->with('start', $this->start)->with('end', $this->end)->render(); + } catch (Throwable $e) { + Log::error(sprintf('Cannot render reports.account.report: %s', $e->getMessage())); + $result = 'Could not render report view.'; + } + + return $result; } + /** * Set the accounts. * diff --git a/app/Generator/Report/Support.php b/app/Generator/Report/Support.php index 0d5e42f4f2..d2b6c76f32 100644 --- a/app/Generator/Report/Support.php +++ b/app/Generator/Report/Support.php @@ -31,6 +31,7 @@ use Illuminate\Support\Collection; * Class Support. * @method Collection getExpenses() * @method Collection getIncome() + * @codeCoverageIgnore */ class Support { diff --git a/app/Generator/Report/Tag/MonthReportGenerator.php b/app/Generator/Report/Tag/MonthReportGenerator.php index e367283624..975a1080aa 100644 --- a/app/Generator/Report/Tag/MonthReportGenerator.php +++ b/app/Generator/Report/Tag/MonthReportGenerator.php @@ -28,7 +28,7 @@ namespace FireflyIII\Generator\Report\Tag; use Carbon\Carbon; use FireflyIII\Generator\Report\ReportGeneratorInterface; use FireflyIII\Generator\Report\Support; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Helpers\Filter\NegativeAmountFilter; use FireflyIII\Helpers\Filter\OpposingAccountFilter; use FireflyIII\Helpers\Filter\PositiveAmountFilter; @@ -38,9 +38,11 @@ use FireflyIII\Models\Transaction; use FireflyIII\Models\TransactionType; use Illuminate\Support\Collection; use Log; +use Throwable; /** * Class MonthReportGenerator. + * @codeCoverageIgnore */ class MonthReportGenerator extends Support implements ReportGeneratorInterface { @@ -70,7 +72,6 @@ class MonthReportGenerator extends Support implements ReportGeneratorInterface * Generate the report. * * @return string - * @throws \Throwable */ public function generate(): string { @@ -87,20 +88,19 @@ class MonthReportGenerator extends Support implements ReportGeneratorInterface $topIncome = $this->getTopIncome(); // render! - return view( - 'reports.tag.month', - compact( - 'accountIds', - 'tagTags', - 'reportType', - 'accountSummary', - 'tagSummary', - 'averageExpenses', - 'averageIncome', - 'topIncome', - 'topExpenses' - ) - )->with('start', $this->start)->with('end', $this->end)->with('tags', $this->tags)->with('accounts', $this->accounts)->render(); + try { + $result = view( + 'reports.tag.month', compact( + 'accountIds', 'tagTags', 'reportType', 'accountSummary', 'tagSummary', 'averageExpenses', 'averageIncome', 'topIncome', + 'topExpenses' + ) + )->with('start', $this->start)->with('end', $this->end)->with('tags', $this->tags)->with('accounts', $this->accounts)->render(); + } catch (Throwable $e) { + Log::error(sprintf('Cannot render reports.tag.month: %s', $e->getMessage())); + $result = 'Could not render report view.'; + } + + return $result; } /** @@ -208,8 +208,8 @@ class MonthReportGenerator extends Support implements ReportGeneratorInterface return $this->expenses; } - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts($this->accounts)->setRange($this->start, $this->end) ->setTypes([TransactionType::WITHDRAWAL, TransactionType::TRANSFER]) ->setTags($this->tags)->withOpposingAccount(); @@ -218,7 +218,7 @@ class MonthReportGenerator extends Support implements ReportGeneratorInterface $collector->addFilter(OpposingAccountFilter::class); $collector->addFilter(PositiveAmountFilter::class); - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); $this->expenses = $transactions; @@ -236,8 +236,8 @@ class MonthReportGenerator extends Support implements ReportGeneratorInterface return $this->income; } - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts($this->accounts)->setRange($this->start, $this->end) ->setTypes([TransactionType::DEPOSIT, TransactionType::TRANSFER]) ->setTags($this->tags)->withOpposingAccount(); @@ -245,7 +245,7 @@ class MonthReportGenerator extends Support implements ReportGeneratorInterface $collector->addFilter(OpposingAccountFilter::class); $collector->addFilter(NegativeAmountFilter::class); - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); $this->income = $transactions; return $transactions; diff --git a/app/Generator/Report/Tag/MultiYearReportGenerator.php b/app/Generator/Report/Tag/MultiYearReportGenerator.php index 0d6380416b..c34136289a 100644 --- a/app/Generator/Report/Tag/MultiYearReportGenerator.php +++ b/app/Generator/Report/Tag/MultiYearReportGenerator.php @@ -24,6 +24,7 @@ namespace FireflyIII\Generator\Report\Tag; /** * Class MultiYearReportGenerator. + * @codeCoverageIgnore */ class MultiYearReportGenerator extends MonthReportGenerator { diff --git a/app/Generator/Report/Tag/YearReportGenerator.php b/app/Generator/Report/Tag/YearReportGenerator.php index 0cb3f216a0..087485a166 100644 --- a/app/Generator/Report/Tag/YearReportGenerator.php +++ b/app/Generator/Report/Tag/YearReportGenerator.php @@ -24,6 +24,7 @@ namespace FireflyIII\Generator\Report\Tag; /** * Class YearReportGenerator. + * @codeCoverageIgnore */ class YearReportGenerator extends MonthReportGenerator { diff --git a/app/Handlers/Events/APIEventHandler.php b/app/Handlers/Events/APIEventHandler.php index b35130f0d0..0b93895ee1 100644 --- a/app/Handlers/Events/APIEventHandler.php +++ b/app/Handlers/Events/APIEventHandler.php @@ -65,10 +65,10 @@ class APIEventHandler Log::error($e->getTraceAsString()); Session::flash('error', 'Possible email error: ' . $e->getMessage()); } + // @codeCoverageIgnoreEnd Log::debug('If no error above this line, message was sent.'); } - // @codeCoverageIgnoreEnd return true; diff --git a/app/Handlers/Events/AdminEventHandler.php b/app/Handlers/Events/AdminEventHandler.php index 1bc55af2ba..6d3c09a62b 100644 --- a/app/Handlers/Events/AdminEventHandler.php +++ b/app/Handlers/Events/AdminEventHandler.php @@ -57,16 +57,17 @@ class AdminEventHandler Log::debug('Trying to send message...'); Mail::to($email)->send(new AdminTestMail($email, $ipAddress)); // @codeCoverageIgnoreStart + // Laravel cannot pretend this process failed during testing. } catch (Exception $e) { Log::debug('Send message failed! :('); Log::error($e->getMessage()); Log::error($e->getTraceAsString()); Session::flash('error', 'Possible email error: ' . $e->getMessage()); } + // @codeCoverageIgnoreEnd Log::debug('If no error above this line, message was sent.'); } - // @codeCoverageIgnoreEnd return true; } } diff --git a/app/Handlers/Events/AutomationHandler.php b/app/Handlers/Events/AutomationHandler.php index f7549ae84b..2b1464010c 100644 --- a/app/Handlers/Events/AutomationHandler.php +++ b/app/Handlers/Events/AutomationHandler.php @@ -55,12 +55,14 @@ class AutomationHandler Mail::to($user->email)->send(new ReportNewJournalsMail($user->email, '127.0.0.1', $event->journals)); // @codeCoverageIgnoreStart } catch (Exception $e) { + Log::debug('Send message failed! :('); Log::error($e->getMessage()); + Log::error($e->getTraceAsString()); } + // @codeCoverageIgnoreEnd Log::debug('Done!'); } - // @codeCoverageIgnoreEnd return true; } } diff --git a/app/Handlers/Events/StoredJournalEventHandler.php b/app/Handlers/Events/StoredJournalEventHandler.php index db3fcb41f2..f42d814b49 100644 --- a/app/Handlers/Events/StoredJournalEventHandler.php +++ b/app/Handlers/Events/StoredJournalEventHandler.php @@ -25,8 +25,6 @@ namespace FireflyIII\Handlers\Events; use FireflyIII\Events\StoredTransactionJournal; use FireflyIII\Models\Rule; use FireflyIII\Models\RuleGroup; -use FireflyIII\Repositories\Journal\JournalRepositoryInterface; -use FireflyIII\Repositories\PiggyBank\PiggyBankRepositoryInterface; use FireflyIII\Repositories\RuleGroup\RuleGroupRepositoryInterface; use FireflyIII\TransactionRules\Processor; @@ -35,28 +33,6 @@ use FireflyIII\TransactionRules\Processor; */ class StoredJournalEventHandler { - /** @var JournalRepositoryInterface The journal repository. */ - public $journalRepository; - /** @var PiggyBankRepositoryInterface The Piggy bank repository */ - public $repository; - /** @var RuleGroupRepositoryInterface The rule group repository */ - public $ruleGroupRepository; - - /** - * StoredJournalEventHandler constructor. - * - * @param PiggyBankRepositoryInterface $repository - * @param JournalRepositoryInterface $journalRepository - * @param RuleGroupRepositoryInterface $ruleGroupRepository - */ - public function __construct( - PiggyBankRepositoryInterface $repository, JournalRepositoryInterface $journalRepository, RuleGroupRepositoryInterface $ruleGroupRepository - ) { - $this->repository = $repository; - $this->journalRepository = $journalRepository; - $this->ruleGroupRepository = $ruleGroupRepository; - } - /** * This method grabs all the users rules and processes them. * @@ -67,16 +43,22 @@ class StoredJournalEventHandler */ public function processRules(StoredTransactionJournal $storedJournalEvent): bool { - // get all the user's rule groups, with the rules, order by 'order'. $journal = $storedJournalEvent->journal; - $groups = $this->ruleGroupRepository->getActiveGroups($journal->user); + + // create objects: + /** @var RuleGroupRepositoryInterface $ruleGroupRepos */ + $ruleGroupRepos = app(RuleGroupRepositoryInterface::class); + $ruleGroupRepos->setUser($journal->user); + $groups = $ruleGroupRepos->getActiveGroups($journal->user); /** @var RuleGroup $group */ foreach ($groups as $group) { - $rules = $this->ruleGroupRepository->getActiveStoreRules($group); + $rules = $ruleGroupRepos->getActiveStoreRules($group); /** @var Rule $rule */ foreach ($rules as $rule) { - $processor = Processor::make($rule); + /** @var Processor $processor */ + $processor = app(Processor::class); + $processor->make($rule); $processor->handleTransactionJournal($journal); if ($rule->stop_processing) { diff --git a/app/Handlers/Events/UpdatedJournalEventHandler.php b/app/Handlers/Events/UpdatedJournalEventHandler.php index f6308e08e3..30ffeb83ab 100644 --- a/app/Handlers/Events/UpdatedJournalEventHandler.php +++ b/app/Handlers/Events/UpdatedJournalEventHandler.php @@ -33,19 +33,6 @@ use FireflyIII\TransactionRules\Processor; */ class UpdatedJournalEventHandler { - /** @var RuleGroupRepositoryInterface The rule group repository */ - public $repository; - - /** - * StoredJournalEventHandler constructor. - * - * @param RuleGroupRepositoryInterface $ruleGroupRepository - */ - public function __construct(RuleGroupRepositoryInterface $ruleGroupRepository) - { - $this->repository = $ruleGroupRepository; - } - /** * This method will check all the rules when a journal is updated. * @@ -58,14 +45,21 @@ class UpdatedJournalEventHandler { // get all the user's rule groups, with the rules, order by 'order'. $journal = $updatedJournalEvent->journal; - $groups = $this->repository->getActiveGroups($journal->user); + + /** @var RuleGroupRepositoryInterface $ruleGroupRepos */ + $ruleGroupRepos = app(RuleGroupRepositoryInterface::class); + $ruleGroupRepos->setUser($journal->user); + + $groups = $ruleGroupRepos->getActiveGroups($journal->user); /** @var RuleGroup $group */ foreach ($groups as $group) { - $rules = $this->repository->getActiveUpdateRules($group); + $rules = $ruleGroupRepos->getActiveUpdateRules($group); /** @var Rule $rule */ foreach ($rules as $rule) { - $processor = Processor::make($rule); + /** @var Processor $processor */ + $processor = app(Processor::class); + $processor->make($rule); $processor->handleTransactionJournal($journal); if ($rule->stop_processing) { diff --git a/app/Handlers/Events/UserEventHandler.php b/app/Handlers/Events/UserEventHandler.php index e40ff67b55..52ef3c34e0 100644 --- a/app/Handlers/Events/UserEventHandler.php +++ b/app/Handlers/Events/UserEventHandler.php @@ -61,6 +61,7 @@ class UserEventHandler // first user ever? if (1 === $repository->count()) { + Log::debug('User count is one, attach role.'); $repository->attachRole($event->user, 'owner'); } @@ -145,8 +146,8 @@ class UserEventHandler } catch (Exception $e) { Log::error($e->getMessage()); } - // @codeCoverageIgnoreEnd + return true; } @@ -171,8 +172,8 @@ class UserEventHandler } catch (Exception $e) { Log::error($e->getMessage()); } - // @codeCoverageIgnoreEnd + return true; } @@ -198,7 +199,6 @@ class UserEventHandler } catch (Exception $e) { Log::error($e->getMessage()); } - // @codeCoverageIgnoreEnd return true; diff --git a/app/Handlers/Events/VersionCheckEventHandler.php b/app/Handlers/Events/VersionCheckEventHandler.php index 46b371772e..5b7efb4e45 100644 --- a/app/Handlers/Events/VersionCheckEventHandler.php +++ b/app/Handlers/Events/VersionCheckEventHandler.php @@ -27,14 +27,13 @@ namespace FireflyIII\Handlers\Events; use FireflyConfig; use FireflyIII\Events\RequestedVersionCheckStatus; - use FireflyIII\Helpers\Update\UpdateTrait; +use FireflyIII\Models\Configuration; use FireflyIII\Repositories\User\UserRepositoryInterface; - - use FireflyIII\User; use Log; + /** * Class VersionCheckEventHandler */ @@ -52,10 +51,13 @@ class VersionCheckEventHandler */ public function checkForUpdates(RequestedVersionCheckStatus $event): void { + Log::debug('Now in checkForUpdates()'); // in Sandstorm, cannot check for updates: $sandstorm = 1 === (int)getenv('SANDSTORM'); if (true === $sandstorm) { - return; // @codeCoverageIgnore + Log::debug('This is Sandstorm instance, done.'); + + return; } /** @var UserRepositoryInterface $repository */ @@ -63,25 +65,27 @@ class VersionCheckEventHandler /** @var User $user */ $user = $event->user; if (!$repository->hasRole($user, 'owner')) { + Log::debug('User is not admin, done.'); + return; } + /** @var Configuration $lastCheckTime */ $lastCheckTime = FireflyConfig::get('last_update_check', time()); $now = time(); $diff = $now - $lastCheckTime->data; - Log::debug(sprintf('Difference is %d seconds.', $diff)); + Log::debug(sprintf('Last check time is %d, current time is %d, difference is %d', $lastCheckTime->data, $now, $diff)); if ($diff < 604800) { Log::debug(sprintf('Checked for updates less than a week ago (on %s).', date('Y-m-d H:i:s', $lastCheckTime->data))); - //return; - + return; } // last check time was more than a week ago. Log::debug('Have not checked for a new version in a week!'); $latestRelease = $this->getLatestRelease(); $versionCheck = $this->versionCheck($latestRelease); - $resultString = $this->parseResult($versionCheck, $latestRelease); + $resultString = $this->parseResult($versionCheck, $latestRelease); if (0 !== $versionCheck && '' !== $resultString) { // flash info session()->flash('info', $resultString); diff --git a/app/Helpers/Attachments/AttachmentHelper.php b/app/Helpers/Attachments/AttachmentHelper.php index d840a2a812..9c9c7bf55e 100644 --- a/app/Helpers/Attachments/AttachmentHelper.php +++ b/app/Helpers/Attachments/AttachmentHelper.php @@ -25,7 +25,6 @@ namespace FireflyIII\Helpers\Attachments; use Crypt; use FireflyIII\Models\Attachment; use Illuminate\Contracts\Encryption\DecryptException; -use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Illuminate\Support\MessageBag; @@ -80,7 +79,7 @@ class AttachmentHelper implements AttachmentHelperInterface try { $content = Crypt::decrypt($this->uploadDisk->get(sprintf('at-%d.data', $attachment->id))); - } catch (DecryptException|FileNotFoundException $e) { + } catch (DecryptException $e) { Log::error(sprintf('Could not decrypt data of attachment #%d: %s', $attachment->id, $e->getMessage())); $content = ''; } @@ -145,9 +144,11 @@ class AttachmentHelper implements AttachmentHelperInterface { $resource = tmpfile(); if (false === $resource) { + // @codeCoverageIgnoreStart Log::error('Cannot create temp-file for file upload.'); return false; + // @codeCoverageIgnoreEnd } $path = stream_get_meta_data($resource)['uri']; fwrite($resource, $content); @@ -342,9 +343,12 @@ class AttachmentHelper implements AttachmentHelperInterface if (!$this->validMime($file)) { $result = false; } + // @codeCoverageIgnoreStart + // can't seem to reach this point. if (true === $result && !$this->validSize($file)) { $result = false; } + // @codeCoverageIgnoreEnd if (true === $result && $this->hasFile($file, $model)) { $result = false; } diff --git a/app/Helpers/Chart/MetaPieChart.php b/app/Helpers/Chart/MetaPieChart.php index f1082cda43..a6c97ef32d 100644 --- a/app/Helpers/Chart/MetaPieChart.php +++ b/app/Helpers/Chart/MetaPieChart.php @@ -24,7 +24,7 @@ declare(strict_types=1); namespace FireflyIII\Helpers\Chart; use Carbon\Carbon; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Helpers\Filter\NegativeAmountFilter; use FireflyIII\Helpers\Filter\OpposingAccountFilter; use FireflyIII\Helpers\Filter\PositiveAmountFilter; @@ -108,12 +108,12 @@ class MetaPieChart implements MetaPieChartInterface // also collect all other transactions if ($this->collectOtherObjects && 'expense' === $direction) { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setUser($this->user); $collector->setAccounts($this->accounts)->setRange($this->start, $this->end)->setTypes([TransactionType::WITHDRAWAL]); - $journals = $collector->getJournals(); + $journals = $collector->getTransactions(); $sum = (string)$journals->sum('transaction_amount'); $sum = bcmul($sum, '-1'); $sum = bcsub($sum, $this->total); @@ -121,11 +121,11 @@ class MetaPieChart implements MetaPieChartInterface } if ($this->collectOtherObjects && 'income' === $direction) { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setUser($this->user); $collector->setAccounts($this->accounts)->setRange($this->start, $this->end)->setTypes([TransactionType::DEPOSIT]); - $journals = $collector->getJournals(); + $journals = $collector->getTransactions(); $sum = (string)$journals->sum('transaction_amount'); $sum = bcsub($sum, $this->total); $chartData[$key] = $sum; @@ -271,8 +271,8 @@ class MetaPieChart implements MetaPieChartInterface */ protected function getTransactions(string $direction): Collection { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $types = [TransactionType::DEPOSIT, TransactionType::TRANSFER]; $collector->addFilter(NegativeAmountFilter::class); if ('expense' === $direction) { @@ -304,7 +304,7 @@ class MetaPieChart implements MetaPieChartInterface // @codeCoverageIgnoreEnd - return $collector->getJournals(); + return $collector->getTransactions(); } /** @@ -360,7 +360,8 @@ class MetaPieChart implements MetaPieChartInterface foreach ($array as $objectId => $amount) { if (!isset($names[$objectId])) { $object = $repository->findNull((int)$objectId); - $names[$objectId] = $object->name ?? $object->tag; + $name = null === $object ? '(no name)' : $object->name; + $names[$objectId] = $name ?? $object->tag; } $amount = app('steam')->positive($amount); $this->total = bcadd($this->total, $amount); diff --git a/app/Helpers/Collection/Balance.php b/app/Helpers/Collection/Balance.php index b10674cefd..71573853ea 100644 --- a/app/Helpers/Collection/Balance.php +++ b/app/Helpers/Collection/Balance.php @@ -26,6 +26,8 @@ use Illuminate\Support\Collection; /** * Class Balance. + * + * @codeCoverageIgnore */ class Balance { diff --git a/app/Helpers/Collection/BalanceEntry.php b/app/Helpers/Collection/BalanceEntry.php index 8f2e4fb195..86eddf607b 100644 --- a/app/Helpers/Collection/BalanceEntry.php +++ b/app/Helpers/Collection/BalanceEntry.php @@ -26,6 +26,8 @@ use FireflyIII\Models\Account as AccountModel; /** * Class BalanceEntry. + * + * @codeCoverageIgnore */ class BalanceEntry { diff --git a/app/Helpers/Collection/BalanceHeader.php b/app/Helpers/Collection/BalanceHeader.php index 46c3091333..3769b0f5c0 100644 --- a/app/Helpers/Collection/BalanceHeader.php +++ b/app/Helpers/Collection/BalanceHeader.php @@ -27,6 +27,8 @@ use Illuminate\Support\Collection; /** * Class BalanceHeader. + * + * @codeCoverageIgnore */ class BalanceHeader { diff --git a/app/Helpers/Collection/BalanceLine.php b/app/Helpers/Collection/BalanceLine.php index a679ef66ff..89668f0963 100644 --- a/app/Helpers/Collection/BalanceLine.php +++ b/app/Helpers/Collection/BalanceLine.php @@ -29,6 +29,8 @@ use Illuminate\Support\Collection; /** * Class BalanceLine. + * + * @codeCoverageIgnore */ class BalanceLine { diff --git a/app/Helpers/Collection/Bill.php b/app/Helpers/Collection/Bill.php index 9ce761345d..f950a93914 100644 --- a/app/Helpers/Collection/Bill.php +++ b/app/Helpers/Collection/Bill.php @@ -29,6 +29,8 @@ use Log; /** * Class Bill. + * + * @codeCoverageIgnore */ class Bill { diff --git a/app/Helpers/Collection/BillLine.php b/app/Helpers/Collection/BillLine.php index 4be29cb07a..eb1e4c8fee 100644 --- a/app/Helpers/Collection/BillLine.php +++ b/app/Helpers/Collection/BillLine.php @@ -27,6 +27,8 @@ use FireflyIII\Models\Bill as BillModel; /** * Class BillLine. + * + * @codeCoverageIgnore */ class BillLine { diff --git a/app/Helpers/Collection/Category.php b/app/Helpers/Collection/Category.php index 9868bdb945..ce774387ae 100644 --- a/app/Helpers/Collection/Category.php +++ b/app/Helpers/Collection/Category.php @@ -27,6 +27,8 @@ use Illuminate\Support\Collection; /** * Class Category. + * + * @codeCoverageIgnore */ class Category { diff --git a/app/Helpers/Collector/JournalCollector.php b/app/Helpers/Collector/TransactionCollector.php similarity index 85% rename from app/Helpers/Collector/JournalCollector.php rename to app/Helpers/Collector/TransactionCollector.php index 26f481bdce..680ff6c944 100644 --- a/app/Helpers/Collector/JournalCollector.php +++ b/app/Helpers/Collector/TransactionCollector.php @@ -1,7 +1,7 @@ . */ -/** @noinspection PhpDynamicAsStaticMethodCallInspection */ -/** @noinspection PropertyCanBeStaticInspection */ + declare(strict_types=1); namespace FireflyIII\Helpers\Collector; + use Carbon\Carbon; use DB; use FireflyIII\Exceptions\FireflyException; @@ -51,15 +51,11 @@ use Illuminate\Support\Collection; use Log; /** - * Maybe this is a good idea after all... - * - * Class JournalCollector - * @SuppressWarnings(PHPMD.TooManyPublicMethods) - * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * Class TransactionCollector * + * @codeCoverageIgnore */ -class JournalCollector implements JournalCollectorInterface +class TransactionCollector implements TransactionCollectorInterface { /** @var array */ @@ -91,6 +87,7 @@ class JournalCollector implements JournalCollectorInterface 'transactions.amount as transaction_amount', 'transactions.transaction_currency_id as transaction_currency_id', + 'transaction_currencies.name as transaction_currency_name', 'transaction_currencies.code as transaction_currency_code', 'transaction_currencies.symbol as transaction_currency_symbol', 'transaction_currencies.decimal_places as transaction_currency_dp', @@ -135,9 +132,9 @@ class JournalCollector implements JournalCollectorInterface /** * @param string $filter * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function addFilter(string $filter): JournalCollectorInterface + public function addFilter(string $filter): TransactionCollectorInterface { $interfaces = class_implements($filter); if (\in_array(FilterInterface::class, $interfaces, true) && !\in_array($filter, $this->filters, true)) { @@ -151,9 +148,9 @@ class JournalCollector implements JournalCollectorInterface /** * @param string $amount * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function amountIs(string $amount): JournalCollectorInterface + public function amountIs(string $amount): TransactionCollectorInterface { $this->query->where( function (EloquentBuilder $q) use ($amount) { @@ -168,9 +165,9 @@ class JournalCollector implements JournalCollectorInterface /** * @param string $amount * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function amountLess(string $amount): JournalCollectorInterface + public function amountLess(string $amount): TransactionCollectorInterface { $this->query->where( function (EloquentBuilder $q1) use ($amount) { @@ -196,9 +193,9 @@ class JournalCollector implements JournalCollectorInterface /** * @param string $amount * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function amountMore(string $amount): JournalCollectorInterface + public function amountMore(string $amount): TransactionCollectorInterface { $this->query->where( function (EloquentBuilder $q1) use ($amount) { @@ -229,7 +226,7 @@ class JournalCollector implements JournalCollectorInterface public function count(): int { if (true === $this->run) { - throw new FireflyException('Cannot count after run in JournalCollector.'); + throw new FireflyException('Cannot count after run in TransactionCollector.'); } $countQuery = clone $this->query; @@ -246,13 +243,10 @@ class JournalCollector implements JournalCollectorInterface return $this->count; } - /** @noinspection MultipleReturnStatementsInspection */ /** * @return Collection - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - public function getJournals(): Collection + public function getTransactions(): Collection { $this->run = true; @@ -307,16 +301,15 @@ class JournalCollector implements JournalCollectorInterface /** * @return LengthAwarePaginator - * * @throws FireflyException */ - public function getPaginatedJournals(): LengthAwarePaginator + public function getPaginatedTransactions(): LengthAwarePaginator { if (true === $this->run) { - throw new FireflyException('Cannot getPaginatedJournals after run in JournalCollector.'); + throw new FireflyException('Cannot getPaginatedTransactions after run in TransactionCollector.'); } $this->count(); - $set = $this->getJournals(); + $set = $this->getTransactions(); $journals = new LengthAwarePaginator($set, $this->count, $this->limit, $this->page); return $journals; @@ -331,9 +324,9 @@ class JournalCollector implements JournalCollectorInterface } /** - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function ignoreCache(): JournalCollectorInterface + public function ignoreCache(): TransactionCollectorInterface { $this->ignoreCache = true; @@ -343,9 +336,9 @@ class JournalCollector implements JournalCollectorInterface /** * @param string $filter * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function removeFilter(string $filter): JournalCollectorInterface + public function removeFilter(string $filter): TransactionCollectorInterface { $key = array_search($filter, $this->filters, true); if (!(false === $key)) { @@ -359,9 +352,9 @@ class JournalCollector implements JournalCollectorInterface /** * @param Collection $accounts * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setAccounts(Collection $accounts): JournalCollectorInterface + public function setAccounts(Collection $accounts): TransactionCollectorInterface { if ($accounts->count() > 0) { $accountIds = $accounts->pluck('id')->toArray(); @@ -380,21 +373,21 @@ class JournalCollector implements JournalCollectorInterface /** * @param Carbon $after * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setAfter(Carbon $after): JournalCollectorInterface + public function setAfter(Carbon $after): TransactionCollectorInterface { $afterStr = $after->format('Y-m-d 00:00:00'); $this->query->where('transaction_journals.date', '>=', $afterStr); - Log::debug(sprintf('JournalCollector range is now after %s (inclusive)', $afterStr)); + Log::debug(sprintf('TransactionCollector range is now after %s (inclusive)', $afterStr)); return $this; } /** - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setAllAssetAccounts(): JournalCollectorInterface + public function setAllAssetAccounts(): TransactionCollectorInterface { /** @var AccountRepositoryInterface $repository */ $repository = app(AccountRepositoryInterface::class); @@ -416,13 +409,13 @@ class JournalCollector implements JournalCollectorInterface /** * @param Carbon $before * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setBefore(Carbon $before): JournalCollectorInterface + public function setBefore(Carbon $before): TransactionCollectorInterface { $beforeStr = $before->format('Y-m-d 00:00:00'); $this->query->where('transaction_journals.date', '<=', $beforeStr); - Log::debug(sprintf('JournalCollector range is now before %s (inclusive)', $beforeStr)); + Log::debug(sprintf('TransactionCollector range is now before %s (inclusive)', $beforeStr)); return $this; } @@ -430,9 +423,9 @@ class JournalCollector implements JournalCollectorInterface /** * @param Collection $bills * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setBills(Collection $bills): JournalCollectorInterface + public function setBills(Collection $bills): TransactionCollectorInterface { if ($bills->count() > 0) { $billIds = $bills->pluck('id')->toArray(); @@ -445,9 +438,9 @@ class JournalCollector implements JournalCollectorInterface /** * @param Budget $budget * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setBudget(Budget $budget): JournalCollectorInterface + public function setBudget(Budget $budget): TransactionCollectorInterface { $this->joinBudgetTables(); @@ -464,9 +457,9 @@ class JournalCollector implements JournalCollectorInterface /** * @param Collection $budgets * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setBudgets(Collection $budgets): JournalCollectorInterface + public function setBudgets(Collection $budgets): TransactionCollectorInterface { $budgetIds = $budgets->pluck('id')->toArray(); if (0 !== \count($budgetIds)) { @@ -487,9 +480,9 @@ class JournalCollector implements JournalCollectorInterface /** * @param Collection $categories * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setCategories(Collection $categories): JournalCollectorInterface + public function setCategories(Collection $categories): TransactionCollectorInterface { $categoryIds = $categories->pluck('id')->toArray(); if (0 !== \count($categoryIds)) { @@ -509,9 +502,9 @@ class JournalCollector implements JournalCollectorInterface /** * @param Category $category * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setCategory(Category $category): JournalCollectorInterface + public function setCategory(Category $category): TransactionCollectorInterface { $this->joinCategoryTables(); @@ -528,9 +521,9 @@ class JournalCollector implements JournalCollectorInterface /** * @param Collection $journals * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setJournals(Collection $journals): JournalCollectorInterface + public function setJournals(Collection $journals): TransactionCollectorInterface { $ids = $journals->pluck('id')->toArray(); $this->query->where( @@ -545,9 +538,9 @@ class JournalCollector implements JournalCollectorInterface /** * @param int $limit * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setLimit(int $limit): JournalCollectorInterface + public function setLimit(int $limit): TransactionCollectorInterface { $this->limit = $limit; $this->query->limit($limit); @@ -559,9 +552,9 @@ class JournalCollector implements JournalCollectorInterface /** * @param int $offset * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setOffset(int $offset): JournalCollectorInterface + public function setOffset(int $offset): TransactionCollectorInterface { $this->offset = $offset; @@ -571,9 +564,9 @@ class JournalCollector implements JournalCollectorInterface /** * @param Collection $accounts * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setOpposingAccounts(Collection $accounts): JournalCollectorInterface + public function setOpposingAccounts(Collection $accounts): TransactionCollectorInterface { $this->withOpposingAccount(); @@ -585,9 +578,9 @@ class JournalCollector implements JournalCollectorInterface /** * @param int $page * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setPage(int $page): JournalCollectorInterface + public function setPage(int $page): TransactionCollectorInterface { if ($page < 1) { $page = 1; @@ -614,16 +607,16 @@ class JournalCollector implements JournalCollectorInterface * @param Carbon $start * @param Carbon $end * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setRange(Carbon $start, Carbon $end): JournalCollectorInterface + public function setRange(Carbon $start, Carbon $end): TransactionCollectorInterface { if ($start <= $end) { $startStr = $start->format('Y-m-d 00:00:00'); $endStr = $end->format('Y-m-d 23:59:59'); $this->query->where('transaction_journals.date', '>=', $startStr); $this->query->where('transaction_journals.date', '<=', $endStr); - Log::debug(sprintf('JournalCollector range is now %s - %s (inclusive)', $startStr, $endStr)); + Log::debug(sprintf('TransactionCollector range is now %s - %s (inclusive)', $startStr, $endStr)); } return $this; @@ -632,9 +625,9 @@ class JournalCollector implements JournalCollectorInterface /** * @param Tag $tag * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setTag(Tag $tag): JournalCollectorInterface + public function setTag(Tag $tag): TransactionCollectorInterface { $this->joinTagTables(); $this->query->where('tag_transaction_journal.tag_id', $tag->id); @@ -645,9 +638,9 @@ class JournalCollector implements JournalCollectorInterface /** * @param Collection $tags * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setTags(Collection $tags): JournalCollectorInterface + public function setTags(Collection $tags): TransactionCollectorInterface { $this->joinTagTables(); $tagIds = $tags->pluck('id')->toArray(); @@ -659,9 +652,9 @@ class JournalCollector implements JournalCollectorInterface /** * @param array $types * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setTypes(array $types): JournalCollectorInterface + public function setTypes(array $types): TransactionCollectorInterface { if (\count($types) > 0) { Log::debug('Set query collector types', $types); @@ -686,7 +679,7 @@ class JournalCollector implements JournalCollectorInterface */ public function startQuery(): void { - Log::debug('journalCollector::startQuery'); + Log::debug('TransactionCollector::startQuery'); /** @var EloquentBuilder $query */ $query = Transaction::leftJoin('transaction_journals', 'transaction_journals.id', '=', 'transactions.transaction_journal_id') ->leftJoin('transaction_types', 'transaction_types.id', 'transaction_journals.transaction_type_id') @@ -709,9 +702,9 @@ class JournalCollector implements JournalCollectorInterface } /** - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function withBudgetInformation(): JournalCollectorInterface + public function withBudgetInformation(): TransactionCollectorInterface { $this->joinBudgetTables(); @@ -719,9 +712,9 @@ class JournalCollector implements JournalCollectorInterface } /** - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function withCategoryInformation(): JournalCollectorInterface + public function withCategoryInformation(): TransactionCollectorInterface { $this->joinCategoryTables(); @@ -729,9 +722,9 @@ class JournalCollector implements JournalCollectorInterface } /** - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function withOpposingAccount(): JournalCollectorInterface + public function withOpposingAccount(): TransactionCollectorInterface { $this->joinOpposingTables(); @@ -739,9 +732,9 @@ class JournalCollector implements JournalCollectorInterface } /** - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function withoutBudget(): JournalCollectorInterface + public function withoutBudget(): TransactionCollectorInterface { $this->joinBudgetTables(); @@ -756,9 +749,9 @@ class JournalCollector implements JournalCollectorInterface } /** - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function withoutCategory(): JournalCollectorInterface + public function withoutCategory(): TransactionCollectorInterface { $this->joinCategoryTables(); @@ -901,4 +894,4 @@ class JournalCollector implements JournalCollectorInterface $this->query->leftJoin('tag_transaction_journal', 'tag_transaction_journal.transaction_journal_id', '=', 'transaction_journals.id'); } } -} +} \ No newline at end of file diff --git a/app/Helpers/Collector/JournalCollectorInterface.php b/app/Helpers/Collector/TransactionCollectorInterface.php similarity index 52% rename from app/Helpers/Collector/JournalCollectorInterface.php rename to app/Helpers/Collector/TransactionCollectorInterface.php index f3d3dbbb7b..ef42ffbc9c 100644 --- a/app/Helpers/Collector/JournalCollectorInterface.php +++ b/app/Helpers/Collector/TransactionCollectorInterface.php @@ -1,7 +1,7 @@ . */ + declare(strict_types=1); namespace FireflyIII\Helpers\Collector; @@ -32,9 +33,10 @@ use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; /** - * Interface JournalCollectorInterface. + * Interface TransactionCollectorInterface + * */ -interface JournalCollectorInterface +interface TransactionCollectorInterface { /** @@ -42,36 +44,36 @@ interface JournalCollectorInterface * * @param string $filter * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function addFilter(string $filter): JournalCollectorInterface; + public function addFilter(string $filter): TransactionCollectorInterface; /** * Get transactions with a specific amount. * * @param string $amount * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function amountIs(string $amount): JournalCollectorInterface; + public function amountIs(string $amount): TransactionCollectorInterface; /** * Get transactions where the amount is less than. * * @param string $amount * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function amountLess(string $amount): JournalCollectorInterface; + public function amountLess(string $amount): TransactionCollectorInterface; /** * Get transactions where the amount is more than. * * @param string $amount * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function amountMore(string $amount): JournalCollectorInterface; + public function amountMore(string $amount): TransactionCollectorInterface; /** * Count the result. @@ -81,18 +83,18 @@ interface JournalCollectorInterface public function count(): int; /** - * Get all journals. + * Get all transactions. * * @return Collection */ - public function getJournals(): Collection; + public function getTransactions(): Collection; /** * Get a paginated result. * * @return LengthAwarePaginator */ - public function getPaginatedJournals(): LengthAwarePaginator; + public function getPaginatedTransactions(): LengthAwarePaginator; /** * Get the query. @@ -104,142 +106,142 @@ interface JournalCollectorInterface /** * Set to ignore the cache. * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function ignoreCache(): JournalCollectorInterface; + public function ignoreCache(): TransactionCollectorInterface; /** * Remove a filter. * * @param string $filter * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function removeFilter(string $filter): JournalCollectorInterface; + public function removeFilter(string $filter): TransactionCollectorInterface; /** * Set the accounts to collect from. * * @param Collection $accounts * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setAccounts(Collection $accounts): JournalCollectorInterface; + public function setAccounts(Collection $accounts): TransactionCollectorInterface; /** - * Collect journals after a specific date. + * Collect transactions after a specific date. * * @param Carbon $after * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setAfter(Carbon $after): JournalCollectorInterface; + public function setAfter(Carbon $after): TransactionCollectorInterface; /** * Include all asset accounts. * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setAllAssetAccounts(): JournalCollectorInterface; + public function setAllAssetAccounts(): TransactionCollectorInterface; /** - * Collect journals before a specific date. + * Collect transactions before a specific date. * * @param Carbon $before * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setBefore(Carbon $before): JournalCollectorInterface; + public function setBefore(Carbon $before): TransactionCollectorInterface; /** * Set the bills to filter on. * * @param Collection $bills * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setBills(Collection $bills): JournalCollectorInterface; + public function setBills(Collection $bills): TransactionCollectorInterface; /** * Set the budget to filter on. * * @param Budget $budget * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setBudget(Budget $budget): JournalCollectorInterface; + public function setBudget(Budget $budget): TransactionCollectorInterface; /** * Set the budgets to filter on. * * @param Collection $budgets * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setBudgets(Collection $budgets): JournalCollectorInterface; + public function setBudgets(Collection $budgets): TransactionCollectorInterface; /** * Set the categories to filter on. * * @param Collection $categories * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setCategories(Collection $categories): JournalCollectorInterface; + public function setCategories(Collection $categories): TransactionCollectorInterface; /** * Set the category to filter on. * * @param Category $category * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setCategory(Category $category): JournalCollectorInterface; + public function setCategory(Category $category): TransactionCollectorInterface; /** * Set the journals to filter on. * * @param Collection $journals * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setJournals(Collection $journals): JournalCollectorInterface; + public function setJournals(Collection $journals): TransactionCollectorInterface; /** * Set the page limit. * * @param int $limit * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setLimit(int $limit): JournalCollectorInterface; + public function setLimit(int $limit): TransactionCollectorInterface; /** * Set the offset. * * @param int $offset * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setOffset(int $offset): JournalCollectorInterface; + public function setOffset(int $offset): TransactionCollectorInterface; /** * Set the opposing accounts to collect from. * * @param Collection $accounts * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setOpposingAccounts(Collection $accounts): JournalCollectorInterface; + public function setOpposingAccounts(Collection $accounts): TransactionCollectorInterface; /** * Set the page to get. * * @param int $page * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setPage(int $page): JournalCollectorInterface; + public function setPage(int $page): TransactionCollectorInterface; /** * Set the date range. @@ -247,36 +249,36 @@ interface JournalCollectorInterface * @param Carbon $start * @param Carbon $end * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setRange(Carbon $start, Carbon $end): JournalCollectorInterface; + public function setRange(Carbon $start, Carbon $end): TransactionCollectorInterface; /** * Set the tag to collect from. * * @param Tag $tag * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setTag(Tag $tag): JournalCollectorInterface; + public function setTag(Tag $tag): TransactionCollectorInterface; /** * Set the tags to collect from. * * @param Collection $tags * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setTags(Collection $tags): JournalCollectorInterface; + public function setTags(Collection $tags): TransactionCollectorInterface; /** * Set the types to collect. * * @param array $types * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function setTypes(array $types): JournalCollectorInterface; + public function setTypes(array $types): TransactionCollectorInterface; /** * Set the user. @@ -295,35 +297,35 @@ interface JournalCollectorInterface /** * Include budget information. * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function withBudgetInformation(): JournalCollectorInterface; + public function withBudgetInformation(): TransactionCollectorInterface; /** * Include category information. * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function withCategoryInformation(): JournalCollectorInterface; + public function withCategoryInformation(): TransactionCollectorInterface; /** * Include opposing account information. * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function withOpposingAccount(): JournalCollectorInterface; + public function withOpposingAccount(): TransactionCollectorInterface; /** * Include tranactions without a budget. * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function withoutBudget(): JournalCollectorInterface; + public function withoutBudget(): TransactionCollectorInterface; /** * Include tranactions without a category. * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface */ - public function withoutCategory(): JournalCollectorInterface; -} + public function withoutCategory(): TransactionCollectorInterface; +} \ No newline at end of file diff --git a/app/Helpers/Filter/CountAttachmentsFilter.php b/app/Helpers/Filter/CountAttachmentsFilter.php index bcb61fa3c2..c47cb838d9 100644 --- a/app/Helpers/Filter/CountAttachmentsFilter.php +++ b/app/Helpers/Filter/CountAttachmentsFilter.php @@ -31,6 +31,7 @@ use Illuminate\Support\Collection; /** * Class CountAttachmentsFilter + * @codeCoverageIgnore */ class CountAttachmentsFilter implements FilterInterface { diff --git a/app/Helpers/Filter/EmptyFilter.php b/app/Helpers/Filter/EmptyFilter.php index 2235fe2bfa..716b3d9cdb 100644 --- a/app/Helpers/Filter/EmptyFilter.php +++ b/app/Helpers/Filter/EmptyFilter.php @@ -26,6 +26,8 @@ use Illuminate\Support\Collection; /** * Class EmptyFilter. + * + * @codeCoverageIgnore */ class EmptyFilter implements FilterInterface { diff --git a/app/Helpers/Filter/InternalTransferFilter.php b/app/Helpers/Filter/InternalTransferFilter.php index 1442db3d20..a72474c4e0 100644 --- a/app/Helpers/Filter/InternalTransferFilter.php +++ b/app/Helpers/Filter/InternalTransferFilter.php @@ -32,6 +32,8 @@ use Log; * This filter removes any filters that are from A to B or from B to A given a set of * account id's (in $parameters) where A and B are mentioned. So transfers between the mentioned * accounts will be removed. + * + * @codeCoverageIgnore */ class InternalTransferFilter implements FilterInterface { diff --git a/app/Helpers/Filter/NegativeAmountFilter.php b/app/Helpers/Filter/NegativeAmountFilter.php index b6b0f10cf6..58ebc6ed3b 100644 --- a/app/Helpers/Filter/NegativeAmountFilter.php +++ b/app/Helpers/Filter/NegativeAmountFilter.php @@ -30,6 +30,8 @@ use Log; * Class NegativeAmountFilter. * * This filter removes entries with a negative amount (the original modifier is -1). + * + * @codeCoverageIgnore */ class NegativeAmountFilter implements FilterInterface { diff --git a/app/Helpers/Filter/OpposingAccountFilter.php b/app/Helpers/Filter/OpposingAccountFilter.php index 5df2170ce6..681a4c26b2 100644 --- a/app/Helpers/Filter/OpposingAccountFilter.php +++ b/app/Helpers/Filter/OpposingAccountFilter.php @@ -31,6 +31,8 @@ use Log; * * This filter is similar to the internal transfer filter but only removes transactions when the opposing account is * amongst $parameters (list of account ID's). + * + * @codeCoverageIgnore */ class OpposingAccountFilter implements FilterInterface { diff --git a/app/Helpers/Filter/PositiveAmountFilter.php b/app/Helpers/Filter/PositiveAmountFilter.php index 155c6c3f33..257bdf43b4 100644 --- a/app/Helpers/Filter/PositiveAmountFilter.php +++ b/app/Helpers/Filter/PositiveAmountFilter.php @@ -33,6 +33,8 @@ use Log; * * This filter removes transactions with either a positive amount ($parameters = 1) or a negative amount * ($parameter = -1). This is helpful when a Collection has you with both transactions in a journal. + * + * @codeCoverageIgnore */ class PositiveAmountFilter implements FilterInterface { diff --git a/app/Helpers/Filter/SplitIndicatorFilter.php b/app/Helpers/Filter/SplitIndicatorFilter.php index afc42c30c5..609c3a424e 100644 --- a/app/Helpers/Filter/SplitIndicatorFilter.php +++ b/app/Helpers/Filter/SplitIndicatorFilter.php @@ -30,6 +30,8 @@ use Illuminate\Support\Collection; /** * Class SplitIndicatorFilter + * + * @codeCoverageIgnore */ class SplitIndicatorFilter implements FilterInterface { diff --git a/app/Helpers/Filter/TransactionViewFilter.php b/app/Helpers/Filter/TransactionViewFilter.php index 1c8dc2938a..baae12173d 100644 --- a/app/Helpers/Filter/TransactionViewFilter.php +++ b/app/Helpers/Filter/TransactionViewFilter.php @@ -35,6 +35,8 @@ use Log; * * This is used in the mass-edit routine. * + * @codeCoverageIgnore + * */ class TransactionViewFilter implements FilterInterface { diff --git a/app/Helpers/Filter/TransferFilter.php b/app/Helpers/Filter/TransferFilter.php index 40f2037a3b..26037700a8 100644 --- a/app/Helpers/Filter/TransferFilter.php +++ b/app/Helpers/Filter/TransferFilter.php @@ -30,6 +30,8 @@ use Illuminate\Support\Collection; * Class TransferFilter. * * This filter removes any transfers that are in the collection twice (from A to B and from B to A). + * + * @codeCoverageIgnore */ class TransferFilter implements FilterInterface { diff --git a/app/Helpers/Help/Help.php b/app/Helpers/Help/Help.php index 787d4710fe..a07818d6cd 100644 --- a/app/Helpers/Help/Help.php +++ b/app/Helpers/Help/Help.php @@ -43,6 +43,8 @@ class Help implements HelpInterface /** * Get from cache. * + * @codeCoverageIgnore + * * @param string $route * @param string $language * @@ -70,14 +72,14 @@ class Help implements HelpInterface $opt = ['headers' => ['User-Agent' => $this->userAgent]]; $content = ''; $statusCode = 500; - $client = new Client; + $client = app(Client::class); try { $res = $client->request('GET', $uri, $opt); $statusCode = $res->getStatusCode(); $content = trim($res->getBody()->getContents()); } catch (GuzzleException|Exception $e) { - Log::error($e->getMessage()); - Log::error($e->getTraceAsString()); + Log::info($e->getMessage()); + Log::info($e->getTraceAsString()); } Log::debug(sprintf('Status code is %d', $statusCode)); @@ -94,6 +96,8 @@ class Help implements HelpInterface /** * Do we have the route? * + * @codeCoverageIgnore + * * @param string $route * * @return bool @@ -106,6 +110,8 @@ class Help implements HelpInterface /** * Is in cache? * + * @codeCoverageIgnore + * * @param string $route * @param string $language * @@ -128,6 +134,8 @@ class Help implements HelpInterface /** * Put help text in cache. * + * @codeCoverageIgnore + * * @param string $route * @param string $language * @param string $content diff --git a/app/Helpers/Report/BalanceReportHelper.php b/app/Helpers/Report/BalanceReportHelper.php index 1daec53c1c..ae1404c83f 100644 --- a/app/Helpers/Report/BalanceReportHelper.php +++ b/app/Helpers/Report/BalanceReportHelper.php @@ -34,6 +34,8 @@ use Log; /** * Class BalanceReportHelper. + * + * @codeCoverageIgnore */ class BalanceReportHelper implements BalanceReportHelperInterface { @@ -160,6 +162,7 @@ class BalanceReportHelper implements BalanceReportHelperInterface { $set = $balance->getBalanceLines(); $newSet = new Collection; + /** @var BalanceLine $entry */ foreach ($set as $entry) { if (null !== $entry->getBudget()->id) { $sum = '0'; diff --git a/app/Helpers/Report/BudgetReportHelper.php b/app/Helpers/Report/BudgetReportHelper.php index 5d33e5ca71..dde31c246c 100644 --- a/app/Helpers/Report/BudgetReportHelper.php +++ b/app/Helpers/Report/BudgetReportHelper.php @@ -30,6 +30,8 @@ use Illuminate\Support\Collection; /** * Class BudgetReportHelper. + * + * @codeCoverageIgnore */ class BudgetReportHelper implements BudgetReportHelperInterface { diff --git a/app/Helpers/Report/NetWorth.php b/app/Helpers/Report/NetWorth.php new file mode 100644 index 0000000000..8e27cfc519 --- /dev/null +++ b/app/Helpers/Report/NetWorth.php @@ -0,0 +1,148 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Helpers\Report; + +use Carbon\Carbon; +use FireflyIII\Models\Account; +use FireflyIII\Models\AccountType; +use FireflyIII\Repositories\Account\AccountRepositoryInterface; +use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface; +use FireflyIII\Support\CacheProperties; +use FireflyIII\User; +use Illuminate\Support\Collection; +use Log; + +/** + * + * Class NetWorth + */ +class NetWorth implements NetWorthInterface +{ + + /** @var AccountRepositoryInterface */ + private $accountRepository; + + /** @var CurrencyRepositoryInterface */ + private $currencyRepos; + /** @var User */ + private $user; + + /** + * Returns the user's net worth in an array with the following layout: + * + * - + * - currency: TransactionCurrency object + * - date: the current date + * - amount: the user's net worth in that currency. + * + * This repeats for each currency the user has transactions in. + * Result of this method is cached. + * + * @param Collection $accounts + * @param Carbon $date + * + * @return array + */ + public function getNetWorthByCurrency(Collection $accounts, Carbon $date): array + { + + // start in the past, end in the future? use $date + $cache = new CacheProperties; + $cache->addProperty($date); + $cache->addProperty('net-worth-by-currency'); + $cache->addProperty(implode(',', $accounts->pluck('id')->toArray())); + if ($cache->has()) { + return $cache->get(); // @codeCoverageIgnore + } + + $netWorth = []; + $result = []; + Log::debug(sprintf('Now in getNetWorthByCurrency(%s)', $date->format('Y-m-d'))); + + // get default currency + $default = app('amount')->getDefaultCurrencyByUser($this->user); + + // get all balances: + $balances = app('steam')->balancesByAccounts($accounts, $date); + + // get the preferred currency for this account + /** @var Account $account */ + foreach ($accounts as $account) { + Log::debug(sprintf('Now at account #%d: "%s"', $account->id, $account->name)); + $currencyId = (int)$this->accountRepository->getMetaValue($account, 'currency_id'); + $currencyId = 0 === $currencyId ? $default->id : $currencyId; + + Log::debug(sprintf('Currency ID is #%d', $currencyId)); + + // balance in array: + $balance = $balances[$account->id] ?? '0'; + + Log::debug(sprintf('Balance is %s', $balance)); + + // if the account is a credit card, subtract the virtual balance from the balance, + // to better reflect that this is not money that is actually "yours". + $role = (string)$this->accountRepository->getMetaValue($account, 'accountRole'); + $virtualBalance = (string)$account->virtual_balance; + if ('ccAsset' === $role && '' !== $virtualBalance && (float)$virtualBalance > 0) { + $balance = bcsub($balance, $virtualBalance); + } + + Log::debug(sprintf('Balance corrected to %s', $balance)); + + if (!isset($netWorth[$currencyId])) { + $netWorth[$currencyId] = '0'; + } + $netWorth[$currencyId] = bcadd($balance, $netWorth[$currencyId]); + + Log::debug(sprintf('Total net worth for currency #%d is %s', $currencyId, $netWorth[$currencyId])); + } + ksort($netWorth); + + // loop results and add currency information: + foreach ($netWorth as $currencyId => $balance) { + $result[] = [ + 'currency' => $this->currencyRepos->findNull($currencyId), + 'balance' => $balance, + ]; + } + $cache->store($result); + + return $result; + } + + /** + * @param User $user + */ + public function setUser(User $user): void + { + $this->user = $user; + + // make repository: + $this->accountRepository = app(AccountRepositoryInterface::class); + $this->accountRepository->setUser($this->user); + + $this->currencyRepos = app(CurrencyRepositoryInterface::class); + $this->currencyRepos->setUser($this->user); + } +} \ No newline at end of file diff --git a/app/Helpers/Report/NetWorthInterface.php b/app/Helpers/Report/NetWorthInterface.php new file mode 100644 index 0000000000..8e7e532211 --- /dev/null +++ b/app/Helpers/Report/NetWorthInterface.php @@ -0,0 +1,60 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Helpers\Report; + +use Carbon\Carbon; +use FireflyIII\User; +use Illuminate\Support\Collection; + +/** + * Interface NetWorthInterface + * + * @package FireflyIII\Helpers\Report + */ +interface NetWorthInterface +{ + /** + * Returns the user's net worth in an array with the following layout: + * + * - + * - currency: TransactionCurrency object + * - date: the current date + * - amount: the user's net worth in that currency. + * + * This repeats for each currency the user has transactions in. + * Result of this method is cached. + * + * @param Collection $accounts + * @param Carbon $date + * + * @return array + */ + public function getNetWorthByCurrency(Collection $accounts, Carbon $date): array; + + /** + * @param User $user + */ + public function setUser(User $user): void; + +} \ No newline at end of file diff --git a/app/Helpers/Report/PopupReport.php b/app/Helpers/Report/PopupReport.php index c0dac93458..a50b248377 100644 --- a/app/Helpers/Report/PopupReport.php +++ b/app/Helpers/Report/PopupReport.php @@ -22,7 +22,7 @@ declare(strict_types=1); namespace FireflyIII\Helpers\Report; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Models\Account; use FireflyIII\Models\Budget; use FireflyIII\Models\Category; @@ -33,6 +33,8 @@ use Illuminate\Support\Collection; /** * Class PopupReport. + * + * @codeCoverageIgnore */ class PopupReport implements PopupReportInterface { @@ -47,11 +49,11 @@ class PopupReport implements PopupReportInterface */ public function balanceForBudget(Budget $budget, Account $account, array $attributes): Collection { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts(new Collection([$account]))->setRange($attributes['startDate'], $attributes['endDate'])->setBudget($budget); - return $collector->getJournals(); + return $collector->getTransactions(); } /** @@ -64,15 +66,15 @@ class PopupReport implements PopupReportInterface */ public function balanceForNoBudget(Account $account, array $attributes): Collection { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector ->setAccounts(new Collection([$account])) ->setTypes([TransactionType::WITHDRAWAL]) ->setRange($attributes['startDate'], $attributes['endDate']) ->withoutBudget(); - return $collector->getJournals(); + return $collector->getTransactions(); } /** @@ -85,8 +87,8 @@ class PopupReport implements PopupReportInterface */ public function byBudget(Budget $budget, array $attributes): Collection { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts($attributes['accounts'])->setRange($attributes['startDate'], $attributes['endDate']); @@ -97,7 +99,7 @@ class PopupReport implements PopupReportInterface $collector->setBudget($budget); } - return $collector->getJournals(); + return $collector->getTransactions(); } /** @@ -110,13 +112,13 @@ class PopupReport implements PopupReportInterface */ public function byCategory(Category $category, array $attributes): Collection { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts($attributes['accounts'])->setTypes([TransactionType::WITHDRAWAL, TransactionType::TRANSFER]) ->setRange($attributes['startDate'], $attributes['endDate'])->withOpposingAccount() ->setCategory($category); - return $collector->getJournals(); + return $collector->getTransactions(); } /** @@ -133,17 +135,17 @@ class PopupReport implements PopupReportInterface $repository = app(JournalRepositoryInterface::class); $repository->setUser($account->user); - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts(new Collection([$account]))->setRange($attributes['startDate'], $attributes['endDate']) ->setTypes([TransactionType::WITHDRAWAL, TransactionType::TRANSFER]); - $journals = $collector->getJournals(); + $transactions = $collector->getTransactions(); $report = $attributes['accounts']->pluck('id')->toArray(); // accounts used in this report // filter for transfers and withdrawals TO the given $account - $journals = $journals->filter( + $transactions = $transactions->filter( function (Transaction $transaction) use ($report, $repository) { // get the destinations: $sources = $repository->getJournalSourceAccounts($transaction->transactionJournal)->pluck('id')->toArray(); @@ -153,7 +155,7 @@ class PopupReport implements PopupReportInterface } ); - return $journals; + return $transactions; } /** @@ -169,15 +171,15 @@ class PopupReport implements PopupReportInterface /** @var JournalRepositoryInterface $repository */ $repository = app(JournalRepositoryInterface::class); $repository->setUser($account->user); - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts(new Collection([$account]))->setRange($attributes['startDate'], $attributes['endDate']) ->setTypes([TransactionType::DEPOSIT, TransactionType::TRANSFER]); - $journals = $collector->getJournals(); + $transactions = $collector->getTransactions(); $report = $attributes['accounts']->pluck('id')->toArray(); // accounts used in this report // filter the set so the destinations outside of $attributes['accounts'] are not included. - $journals = $journals->filter( + $transactions = $transactions->filter( function (Transaction $transaction) use ($report, $repository) { // get the destinations: $journal = $transaction->transactionJournal; @@ -188,6 +190,6 @@ class PopupReport implements PopupReportInterface } ); - return $journals; + return $transactions; } } diff --git a/app/Helpers/Report/ReportHelper.php b/app/Helpers/Report/ReportHelper.php index 8f1cad60b1..bd8c5af15a 100644 --- a/app/Helpers/Report/ReportHelper.php +++ b/app/Helpers/Report/ReportHelper.php @@ -25,7 +25,7 @@ namespace FireflyIII\Helpers\Report; use Carbon\Carbon; use FireflyIII\Helpers\Collection\Bill as BillCollection; use FireflyIII\Helpers\Collection\BillLine; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Helpers\FiscalHelperInterface; use FireflyIII\Models\Bill; use FireflyIII\Models\Transaction; @@ -35,6 +35,8 @@ use Illuminate\Support\Collection; /** * Class ReportHelper. + * + * @codeCoverageIgnore */ class ReportHelper implements ReportHelperInterface { @@ -83,10 +85,10 @@ class ReportHelper implements ReportHelperInterface foreach ($expectedDates as $payDate) { $endOfPayPeriod = app('navigation')->endOfX($payDate, $bill->repeat_freq, null); - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts($accounts)->setRange($payDate, $endOfPayPeriod)->setBills($bills); - $journals = $collector->getJournals(); + $transactions = $collector->getTransactions(); $billLine = new BillLine; $billLine->setBill($bill); @@ -95,7 +97,7 @@ class ReportHelper implements ReportHelperInterface $billLine->setMin((string)$bill->amount_min); $billLine->setMax((string)$bill->amount_max); $billLine->setHit(false); - $entry = $journals->filter( + $entry = $transactions->filter( function (Transaction $transaction) use ($bill) { return $transaction->bill_id === $bill->id; } diff --git a/app/Helpers/Update/UpdateTrait.php b/app/Helpers/Update/UpdateTrait.php index 775c44e186..e01d1b4891 100644 --- a/app/Helpers/Update/UpdateTrait.php +++ b/app/Helpers/Update/UpdateTrait.php @@ -42,6 +42,7 @@ trait UpdateTrait */ public function getLatestRelease(): ?Release { + Log::debug('Now in getLatestRelease()'); $return = null; /** @var UpdateRequest $request */ $request = app(UpdateRequest::class); @@ -53,11 +54,15 @@ trait UpdateTrait // get releases from array. $releases = $request->getReleases(); + + Log::debug(sprintf('Found %d releases', \count($releases))); + if (\count($releases) > 0) { // first entry should be the latest entry: /** @var Release $first */ $first = reset($releases); $return = $first; + Log::debug(sprintf('Number of releases found is larger than zero. Return %s ', $first->getTitle())); } return $return; @@ -73,17 +78,21 @@ trait UpdateTrait */ public function parseResult(int $versionCheck, Release $release = null): string { + Log::debug(sprintf('Now in parseResult(%d)', $versionCheck)); $current = (string)config('firefly.version'); $return = ''; if ($versionCheck === -2) { + Log::debug('-2, so give error.'); $return = (string)trans('firefly.update_check_error'); } if ($versionCheck === -1 && null !== $release) { + Log::debug('New version!'); // there is a new FF version! // has it been released for at least three days? $today = new Carbon; $releaseDate = $release->getUpdated(); if ($today->diffInDays($releaseDate, true) > 3) { + Log::debug('New version is older than 3 days!'); $monthAndDayFormat = (string)trans('config.month_and_day'); $return = (string)trans( 'firefly.update_new_version_alert', @@ -97,10 +106,12 @@ trait UpdateTrait } if (0 === $versionCheck) { + Log::debug('User is running current version.'); // you are running the current version! $return = (string)trans('firefly.update_current_version_alert', ['version' => $current]); } if (1 === $versionCheck && null !== $release) { + Log::debug('User is running NEWER version.'); // you are running a newer version! $return = (string)trans('firefly.update_newer_version_alert', ['your_version' => $current, 'new_version' => $release->getTitle()]); } @@ -117,7 +128,9 @@ trait UpdateTrait */ public function versionCheck(Release $release = null): int { + Log::debug('Now in versionCheck()'); if (null === $release) { + Log::debug('Release is null, return -2.'); return -2; } $current = (string)config('firefly.version'); diff --git a/app/Http/Controllers/Account/CreateController.php b/app/Http/Controllers/Account/CreateController.php index 10da6e9c81..11848ca120 100644 --- a/app/Http/Controllers/Account/CreateController.php +++ b/app/Http/Controllers/Account/CreateController.php @@ -78,8 +78,34 @@ class CreateController extends Controller $roles[$role] = (string)trans('firefly.account_role_' . $role); } + // types of liability: + $debt = $this->repository->getAccountTypeByType(AccountType::DEBT); + $loan = $this->repository->getAccountTypeByType(AccountType::LOAN); + $mortgage = $this->repository->getAccountTypeByType(AccountType::MORTGAGE); + $creditCard = $this->repository->getAccountTypeByType(AccountType::CREDITCARD); + $liabilityTypes = [ + $debt->id => (string)trans('firefly.account_type_' . AccountType::DEBT), + $loan->id => (string)trans('firefly.account_type_' . AccountType::LOAN), + $mortgage->id => (string)trans('firefly.account_type_' . AccountType::MORTGAGE), + $creditCard->id => (string)trans('firefly.account_type_' . AccountType::CREDITCARD), + ]; + asort($liabilityTypes); + + // interest calculation periods: + $interestPeriods = [ + 'daily' => (string)trans('firefly.interest_calc_daily'), + 'monthly' => (string)trans('firefly.interest_calc_monthly'), + 'yearly' => (string)trans('firefly.interest_calc_yearly'), + ]; + // pre fill some data - $request->session()->flash('preFilled', ['currency_id' => $defaultCurrency->id]); + $hasOldInput = null !== $request->old('_token'); + $request->session()->flash( + 'preFilled', [ + 'currency_id' => $defaultCurrency->id, + 'include_net_worth' => $hasOldInput ? (bool)$request->old('include_net_worth') : true, + ] + ); // put previous url in session if not redirect from store (not "create another"). if (true !== session('accounts.create.fromStore')) { @@ -87,7 +113,7 @@ class CreateController extends Controller } $request->session()->forget('accounts.create.fromStore'); - return view('accounts.create', compact('subTitleIcon', 'what', 'subTitle', 'roles')); + return view('accounts.create', compact('subTitleIcon', 'what', 'interestPeriods', 'subTitle', 'roles', 'liabilityTypes')); } @@ -100,6 +126,7 @@ class CreateController extends Controller */ public function store(AccountFormRequest $request) { + $data = $request->getAccountData(); $account = $this->repository->store($data); $request->session()->flash('success', (string)trans('firefly.stored_new_account', ['name' => $account->name])); diff --git a/app/Http/Controllers/Account/DeleteController.php b/app/Http/Controllers/Account/DeleteController.php index 700a0660c7..3ec08fe917 100644 --- a/app/Http/Controllers/Account/DeleteController.php +++ b/app/Http/Controllers/Account/DeleteController.php @@ -69,12 +69,13 @@ class DeleteController extends Controller $typeName = config('firefly.shortNamesByFullName.' . $account->accountType->type); $subTitle = (string)trans('firefly.delete_' . $typeName . '_account', ['name' => $account->name]); $accountList = app('expandedform')->makeSelectListWithEmpty($this->repository->getAccountsByType([$account->accountType->type])); + $what = $typeName; unset($accountList[$account->id]); // put previous url in session $this->rememberPreviousUri('accounts.delete.uri'); - return view('accounts.delete', compact('account', 'subTitle', 'accountList')); + return view('accounts.delete', compact('account', 'subTitle', 'accountList', 'what')); } /** diff --git a/app/Http/Controllers/Account/EditController.php b/app/Http/Controllers/Account/EditController.php index 3bab623ed8..2ce4d98aa7 100644 --- a/app/Http/Controllers/Account/EditController.php +++ b/app/Http/Controllers/Account/EditController.php @@ -27,6 +27,7 @@ namespace FireflyIII\Http\Controllers\Account; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Http\Requests\AccountFormRequest; use FireflyIII\Models\Account; +use FireflyIII\Models\AccountType; use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface; use Illuminate\Http\Request; @@ -85,6 +86,26 @@ class EditController extends Controller $roles[$role] = (string)trans('firefly.account_role_' . $role); } + // types of liability: + $debt = $this->repository->getAccountTypeByType(AccountType::DEBT); + $loan = $this->repository->getAccountTypeByType(AccountType::LOAN); + $mortgage = $this->repository->getAccountTypeByType(AccountType::MORTGAGE); + $creditCard = $this->repository->getAccountTypeByType(AccountType::CREDITCARD); + $liabilityTypes = [ + $debt->id => (string)trans('firefly.account_type_' . AccountType::DEBT), + $loan->id => (string)trans('firefly.account_type_' . AccountType::LOAN), + $mortgage->id => (string)trans('firefly.account_type_' . AccountType::MORTGAGE), + $creditCard->id => (string)trans('firefly.account_type_' . AccountType::CREDITCARD), + ]; + asort($liabilityTypes); + + // interest calculation periods: + $interestPeriods = [ + 'daily' => (string)trans('firefly.interest_calc_daily'), + 'monthly' => (string)trans('firefly.interest_calc_monthly'), + 'yearly' => (string)trans('firefly.interest_calc_yearly'), + ]; + // put previous url in session if not redirect from store (not "return_to_edit"). if (true !== session('accounts.edit.fromUpdate')) { $this->rememberPreviousUri('accounts.edit.uri'); @@ -95,6 +116,11 @@ class EditController extends Controller $openingBalanceDate = $repository->getOpeningBalanceDate($account); $default = app('amount')->getDefaultCurrency(); $currency = $this->currencyRepos->findNull((int)$repository->getMetaValue($account, 'currency_id')); + + // include this account in net-worth charts? + $includeNetWorth = $repository->getMetaValue($account, 'include_net_worth'); + $includeNetWorth = null === $includeNetWorth ? true : '1' === $includeNetWorth; + if (null === $currency) { $currency = $default; } @@ -108,16 +134,25 @@ class EditController extends Controller 'ccMonthlyPaymentDate' => $repository->getMetaValue($account, 'ccMonthlyPaymentDate'), 'BIC' => $repository->getMetaValue($account, 'BIC'), 'openingBalanceDate' => $openingBalanceDate, + 'liability_type_id' => $account->account_type_id, 'openingBalance' => $openingBalanceAmount, 'virtualBalance' => $account->virtual_balance, 'currency_id' => $currency->id, + 'include_net_worth' => $includeNetWorth, + 'interest' => $repository->getMetaValue($account, 'interest'), + 'interest_period' => $repository->getMetaValue($account, 'interest_period'), 'notes' => $this->repository->getNoteText($account), 'active' => $hasOldInput ? (bool)$request->old('active') : $account->active, ]; + if ('liabilities' === $what) { + $preFilled['openingBalance'] = bcmul($preFilled['openingBalance'], '-1'); + } $request->session()->flash('preFilled', $preFilled); - return view('accounts.edit', compact('account', 'currency', 'subTitle', 'subTitleIcon', 'what', 'roles', 'preFilled')); + return view( + 'accounts.edit', compact('account', 'currency', 'subTitle', 'subTitleIcon', 'what', 'roles', 'preFilled', 'liabilityTypes', 'interestPeriods') + ); } diff --git a/app/Http/Controllers/Account/IndexController.php b/app/Http/Controllers/Account/IndexController.php index 363a818cdc..6c9e8a19eb 100644 --- a/app/Http/Controllers/Account/IndexController.php +++ b/app/Http/Controllers/Account/IndexController.php @@ -27,6 +27,7 @@ use Carbon\Carbon; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Models\Account; use FireflyIII\Repositories\Account\AccountRepositoryInterface; +use FireflyIII\Support\Http\Controllers\BasicDataSupport; use Illuminate\Http\Request; use Illuminate\Pagination\LengthAwarePaginator; @@ -36,6 +37,7 @@ use Illuminate\Pagination\LengthAwarePaginator; */ class IndexController extends Controller { + use BasicDataSupport; /** @var AccountRepositoryInterface The account repository */ private $repository; @@ -75,9 +77,18 @@ class IndexController extends Controller $types = config('firefly.accountTypesByIdentifier.' . $what); $collection = $this->repository->getAccountsByType($types); $total = $collection->count(); - $page = 0 === (int)$request->get('page') ? 1 : (int)$request->get('page'); - $pageSize = (int)app('preferences')->get('listPageSize', 50)->data; - $accounts = $collection->slice(($page - 1) * $pageSize, $pageSize); + + // sort collection: + $collection = $collection->sortBy( + function (Account $account) { + return ($account->active ? '0' : '1') . $account->name; + } + ); + + + $page = 0 === (int)$request->get('page') ? 1 : (int)$request->get('page'); + $pageSize = (int)app('preferences')->get('listPageSize', 50)->data; + $accounts = $collection->slice(($page - 1) * $pageSize, $pageSize); unset($collection); /** @var Carbon $start */ $start = clone session('start', Carbon::now()->startOfMonth()); @@ -92,10 +103,13 @@ class IndexController extends Controller $accounts->each( function (Account $account) use ($activities, $startBalances, $endBalances) { - $account->lastActivityDate = $this->isInArray($activities, $account->id); - $account->startBalance = $this->isInArray($startBalances, $account->id); - $account->endBalance = $this->isInArray($endBalances, $account->id); - $account->difference = bcsub($account->endBalance, $account->startBalance); + $account->lastActivityDate = $this->isInArray($activities, $account->id); + $account->startBalance = $this->isInArray($startBalances, $account->id); + $account->endBalance = $this->isInArray($endBalances, $account->id); + $account->difference = bcsub($account->endBalance, $account->startBalance); + $account->interest = round($this->repository->getMetaValue($account, 'interest'), 6); + $account->interestPeriod = (string)trans('firefly.interest_calc_' . $this->repository->getMetaValue($account, 'interest_period')); + $account->accountTypeString = (string)trans('firefly.account_type_' . $account->accountType->type); } ); @@ -107,23 +121,4 @@ class IndexController extends Controller } - /** - * Find the ID in a given array. Return '0' of not there (amount). - * - * @param array $array - * @param int $entryId - * - * @return null|mixed - */ - protected function isInArray(array $array, int $entryId) - { - $result = '0'; - if (isset($array[$entryId])) { - $result = $array[$entryId]; - } - - return $result; - } - - } diff --git a/app/Http/Controllers/Account/ReconcileController.php b/app/Http/Controllers/Account/ReconcileController.php index 99c3ab623c..1283863a73 100644 --- a/app/Http/Controllers/Account/ReconcileController.php +++ b/app/Http/Controllers/Account/ReconcileController.php @@ -36,6 +36,7 @@ use FireflyIII\Models\TransactionType; use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface; use FireflyIII\Repositories\Journal\JournalRepositoryInterface; +use FireflyIII\Support\Http\Controllers\UserNavigation; use Log; /** @@ -45,6 +46,7 @@ use Log; */ class ReconcileController extends Controller { + use UserNavigation; /** @var AccountRepositoryInterface The account repository */ private $accountRepos; /** @var CurrencyRepositoryInterface The currency repository */ @@ -147,7 +149,7 @@ class ReconcileController extends Controller /** @var Carbon $start */ $start = clone session('start', app('navigation')->startOfPeriod(new Carbon, $range)); /** @var Carbon $end */ - $end = clone session('end', app('navigation')->endOfPeriod(new Carbon, $range)); + $end = clone session('end', app('navigation')->endOfPeriod(new Carbon, $range)); } if (null === $end) { /** @var Carbon $end */ @@ -370,32 +372,4 @@ class ReconcileController extends Controller // redirect to previous URL. return redirect($this->getPreviousUri('reconcile.edit.uri')); } - - /** - * Redirect user to the original asset account. - * - * @param Account $account - * - * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector - * - * @throws FireflyException - */ - private function redirectToOriginalAccount(Account $account) - { - /** @var Transaction $transaction */ - $transaction = $account->transactions()->first(); - if (null === $transaction) { - throw new FireflyException(sprintf('Expected a transaction. Account #%d has none. BEEP, error.', $account->id)); // @codeCoverageIgnore - } - - $journal = $transaction->transactionJournal; - /** @var Transaction $opposingTransaction */ - $opposingTransaction = $journal->transactions()->where('transactions.id', '!=', $transaction->id)->first(); - - if (null === $opposingTransaction) { - throw new FireflyException('Expected an opposing transaction. This account has none. BEEP, error.'); // @codeCoverageIgnore - } - - return redirect(route('accounts.show', [$opposingTransaction->account_id])); - } } diff --git a/app/Http/Controllers/Account/ShowController.php b/app/Http/Controllers/Account/ShowController.php index a5273323c4..6032d1fb78 100644 --- a/app/Http/Controllers/Account/ShowController.php +++ b/app/Http/Controllers/Account/ShowController.php @@ -25,15 +25,14 @@ namespace FireflyIII\Http\Controllers\Account; use Carbon\Carbon; use FireflyIII\Exceptions\FireflyException; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Models\Account; use FireflyIII\Models\AccountType; -use FireflyIII\Models\Transaction; -use FireflyIII\Models\TransactionType; use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface; -use FireflyIII\Support\CacheProperties; +use FireflyIII\Support\Http\Controllers\PeriodOverview; +use FireflyIII\Support\Http\Controllers\UserNavigation; use Illuminate\Http\Request; use Illuminate\Support\Collection; use View; @@ -45,6 +44,8 @@ use View; */ class ShowController extends Controller { + use UserNavigation, PeriodOverview; + /** @var CurrencyRepositoryInterface The currency repository */ private $currencyRepos; /** @var AccountRepositoryInterface The account repository */ @@ -90,10 +91,15 @@ class ShowController extends Controller if (AccountType::INITIAL_BALANCE === $account->accountType->type) { return $this->redirectToOriginalAccount($account); } + // a basic thing to determin if this account is a liability: + if ($this->repository->isLiability($account)) { + return redirect(route('accounts.show.all', [$account->id])); + } + /** @var Carbon $start */ $start = $start ?? session('start'); /** @var Carbon $end */ - $end = $end ?? session('end'); + $end = $end ?? session('end'); if ($end < $start) { throw new FireflyException('End is after start!'); // @codeCoverageIgnore } @@ -112,18 +118,22 @@ class ShowController extends Controller $fEnd = $end->formatLocalized($this->monthAndDayFormat); $subTitle = (string)trans('firefly.journals_in_period_for_account', ['name' => $account->name, 'start' => $fStart, 'end' => $fEnd]); $chartUri = route('chart.account.period', [$account->id, $start->format('Y-m-d'), $end->format('Y-m-d')]); - $periods = $this->getPeriodOverview($account, $end); - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + $periods = $this->getAccountPeriodOverview($account, $end); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts(new Collection([$account]))->setLimit($pageSize)->setPage($page); $collector->setRange($start, $end); - $transactions = $collector->getPaginatedJournals(); + $transactions = $collector->getPaginatedTransactions(); $transactions->setPath(route('accounts.show', [$account->id, $start->format('Y-m-d'), $end->format('Y-m-d')])); $showAll = false; + return view( 'accounts.show', - compact('account', 'showAll', 'what', 'currency', 'today', 'periods', 'subTitleIcon', 'transactions', 'subTitle', 'start', 'end', 'chartUri') + compact( + 'account', 'showAll', 'what', 'currency', 'today', 'periods', 'subTitleIcon', 'transactions', 'subTitle', 'start', 'end', + 'chartUri' + ) ); } @@ -143,9 +153,10 @@ class ShowController extends Controller if (AccountType::INITIAL_BALANCE === $account->accountType->type) { return $this->redirectToOriginalAccount($account); // @codeCoverageIgnore } + $isLiability = $this->repository->isLiability($account); $end = new Carbon; $today = new Carbon; - $start = $this->repository->oldestJournalDate($account); + $start = $this->repository->oldestJournalDate($account) ?? Carbon::now()->startOfMonth(); $subTitleIcon = config('firefly.subIconsByIdentifier.' . $account->accountType->type); $page = (int)$request->get('page'); $pageSize = (int)app('preferences')->get('listPageSize', 50)->data; @@ -156,118 +167,18 @@ class ShowController extends Controller } $subTitle = (string)trans('firefly.all_journals_for_account', ['name' => $account->name]); $periods = new Collection; - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts(new Collection([$account]))->setLimit($pageSize)->setPage($page); - $transactions = $collector->getPaginatedJournals(); + $transactions = $collector->getPaginatedTransactions(); $transactions->setPath(route('accounts.show.all', [$account->id])); $chartUri = route('chart.account.period', [$account->id, $start->format('Y-m-d'), $end->format('Y-m-d')]); $showAll = true; return view( 'accounts.show', - compact('account', 'showAll', 'currency', 'today', 'chartUri', 'periods', 'subTitleIcon', 'transactions', 'subTitle', 'start', 'end') + compact('account', 'showAll','isLiability', 'currency', 'today', 'chartUri', 'periods', 'subTitleIcon', 'transactions', 'subTitle', 'start', 'end') ); } - - - /** @noinspection MoreThanThreeArgumentsInspection */ - - /** - * This method returns "period entries", so nov-2015, dec-2015, etc etc (this depends on the users session range) - * and for each period, the amount of money spent and earned. This is a complex operation which is cached for - * performance reasons. - * - * @param Account $account the account involved - * - * @param Carbon|null $date - * - * @return Collection - * - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) - */ - private function getPeriodOverview(Account $account, ?Carbon $date): Collection - { - $range = app('preferences')->get('viewRange', '1M')->data; - $start = $this->repository->oldestJournalDate($account); - $end = $date ?? new Carbon; - if ($end < $start) { - [$start, $end] = [$end, $start]; // @codeCoverageIgnore - } - - // properties for cache - $cache = new CacheProperties; - $cache->addProperty($start); - $cache->addProperty($end); - $cache->addProperty('account-show-period-entries'); - $cache->addProperty($account->id); - if ($cache->has()) { - return $cache->get(); // @codeCoverageIgnore - } - /** @var array $dates */ - $dates = app('navigation')->blockPeriods($start, $end, $range); - $entries = new Collection; - // loop dates - foreach ($dates as $currentDate) { - - // try a collector for income: - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); - $collector->setAccounts(new Collection([$account]))->setRange($currentDate['start'], $currentDate['end'])->setTypes([TransactionType::DEPOSIT]) - ->withOpposingAccount(); - $earned = (string)$collector->getJournals()->sum('transaction_amount'); - - // try a collector for expenses: - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); - $collector->setAccounts(new Collection([$account]))->setRange($currentDate['start'], $currentDate['end'])->setTypes([TransactionType::WITHDRAWAL]) - ->withOpposingAccount(); - $spent = (string)$collector->getJournals()->sum('transaction_amount'); - - $dateName = app('navigation')->periodShow($currentDate['start'], $currentDate['period']); - /** @noinspection PhpUndefinedMethodInspection */ - $entries->push( - [ - 'name' => $dateName, - 'spent' => $spent, - 'earned' => $earned, - 'start' => $currentDate['start']->format('Y-m-d'), - 'end' => $currentDate['end']->format('Y-m-d'), - ] - ); - } - - $cache->store($entries); - - return $entries; - } - - /** - * Redirect to the original account. - * - * @param Account $account - * - * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector - * - * @throws FireflyException - */ - private function redirectToOriginalAccount(Account $account) - { - /** @var Transaction $transaction */ - $transaction = $account->transactions()->first(); - if (null === $transaction) { - throw new FireflyException('Expected a transaction. This account has none. BEEP, error.'); - } - - $journal = $transaction->transactionJournal; - /** @var Transaction $opposingTransaction */ - $opposingTransaction = $journal->transactions()->where('transactions.id', '!=', $transaction->id)->first(); - - if (null === $opposingTransaction) { - throw new FireflyException('Expected an opposing transaction. This account has none. BEEP, error.'); // @codeCoverageIgnore - } - - return redirect(route('accounts.show', [$opposingTransaction->account_id])); - } } diff --git a/app/Http/Controllers/Admin/LinkController.php b/app/Http/Controllers/Admin/LinkController.php index fd877c1e5d..b16db6a4de 100644 --- a/app/Http/Controllers/Admin/LinkController.php +++ b/app/Http/Controllers/Admin/LinkController.php @@ -55,6 +55,7 @@ class LinkController extends Controller /** * Make a new link form. + * * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View */ public function create() diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php index 273cc4c18e..2ece879132 100644 --- a/app/Http/Controllers/Auth/RegisterController.php +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -25,12 +25,12 @@ namespace FireflyIII\Http\Controllers\Auth; use FireflyConfig; use FireflyIII\Http\Controllers\Controller; +use FireflyIII\Support\Http\Controllers\CreateStuff; +use FireflyIII\Support\Http\Controllers\RequestInformation; use FireflyIII\User; use Illuminate\Auth\Events\Registered; -use Illuminate\Contracts\Validation\Validator as ValidatorContract; use Illuminate\Foundation\Auth\RegistersUsers; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Validator; /** * Class RegisterController @@ -43,7 +43,7 @@ use Illuminate\Support\Facades\Validator; */ class RegisterController extends Controller { - use RegistersUsers; + use RegistersUsers, RequestInformation, CreateStuff; /** * Where to redirect users after registration. @@ -82,7 +82,7 @@ class RegisterController extends Controller /** @noinspection PhpUndefinedMethodInspection */ $this->validator($request->all())->validate(); - event(new Registered($user = $this->create($request->all()))); + event(new Registered($user = $this->createUser($request->all()))); $this->guard()->login($user); @@ -119,38 +119,4 @@ class RegisterController extends Controller return view('auth.register', compact('isDemoSite', 'email')); } - /** - * Create a new user instance after a valid registration. - * - * @param array $data - * - * @return \FireflyIII\User - */ - protected function create(array $data): User - { - return User::create( - [ - 'email' => $data['email'], - 'password' => bcrypt($data['password']), - ] - ); - } - - /** - * Get a validator for an incoming registration request. - * - * @param array $data - * - * @return ValidatorContract - */ - protected function validator(array $data): ValidatorContract - { - return Validator::make( - $data, - [ - 'email' => 'required|string|email|max:255|unique:users', - 'password' => 'required|string|secure_password|confirmed', - ] - ); - } } diff --git a/app/Http/Controllers/BillController.php b/app/Http/Controllers/BillController.php index 5e3e12f362..2990b32e35 100644 --- a/app/Http/Controllers/BillController.php +++ b/app/Http/Controllers/BillController.php @@ -24,11 +24,10 @@ namespace FireflyIII\Http\Controllers; use Carbon\Carbon; use FireflyIII\Helpers\Attachments\AttachmentHelperInterface; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Http\Requests\BillFormRequest; use FireflyIII\Models\Bill; use FireflyIII\Repositories\Bill\BillRepositoryInterface; -use FireflyIII\Repositories\RuleGroup\RuleGroupRepositoryInterface; use FireflyIII\TransactionRules\TransactionMatcher; use FireflyIII\Transformers\BillTransformer; use Illuminate\Http\RedirectResponse; @@ -51,8 +50,6 @@ class BillController extends Controller private $attachments; /** @var BillRepositoryInterface Bill repository */ private $billRepository; - /** @var RuleGroupRepositoryInterface Rule group repository */ - private $ruleGroupRepos; /** * BillController constructor. @@ -72,7 +69,6 @@ class BillController extends Controller app('view')->share('mainTitleIcon', 'fa-calendar-o'); $this->attachments = app(AttachmentHelperInterface::class); $this->billRepository = app(BillRepositoryInterface::class); - $this->ruleGroupRepos = app(RuleGroupRepositoryInterface::class); return $next($request); } @@ -204,7 +200,10 @@ class BillController extends Controller /** @var Collection $bills */ $bills = $paginator->getCollection()->map( function (Bill $bill) use ($transformer) { - return $transformer->transform($bill); + $return = $transformer->transform($bill); + $return['currency'] = $bill->transactionCurrency; + + return $return; } ); $bills = $bills->sortBy( @@ -276,10 +275,10 @@ class BillController extends Controller public function show(Request $request, Bill $bill) { // add info about rules: - $rules = $this->billRepository->getRulesForBill($bill); - $subTitle = $bill->name; + $rules = $this->billRepository->getRulesForBill($bill); + $subTitle = $bill->name; /** @var Carbon $start */ - $start = session('start'); + $start = session('start'); /** @var Carbon $end */ $end = session('end'); $year = $start->year; @@ -295,15 +294,16 @@ class BillController extends Controller $parameters = new ParameterBag(); $parameters->set('start', $start); $parameters->set('end', $end); - $resource = new Item($bill, new BillTransformer($parameters), 'bill'); - $object = $manager->createData($resource)->toArray(); + $resource = new Item($bill, new BillTransformer($parameters), 'bill'); + $object = $manager->createData($resource)->toArray(); + $object['data']['currency'] = $bill->transactionCurrency; // use collector: - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setBills(new Collection([$bill]))->setLimit($pageSize)->setPage($page)->withBudgetInformation() ->withCategoryInformation(); - $transactions = $collector->getPaginatedJournals(); + $transactions = $collector->getPaginatedTransactions(); $transactions->setPath(route('bills.show', [$bill->id])); @@ -342,30 +342,7 @@ class BillController extends Controller $request->session()->flash('info', $this->attachments->getMessages()->get('attachments')); // @codeCoverageIgnore } - // do return to original bill form? - $return = 'false'; - if (1 === (int)$request->get('create_another')) { - $return = 'true'; - } - - $group = null; - // find first rule group, or create one: - $count = $this->ruleGroupRepos->count(); - if (0 === $count) { - $data = [ - 'title' => (string)trans('firefly.rulegroup_for_bills_title'), - 'description' => (string)trans('firefly.rulegroup_for_bills_description'), - ]; - $group = $this->ruleGroupRepos->store($data); - } - if ($count > 0) { - $group = $this->ruleGroupRepos->getActiveGroups($bill->user)->first(); - } - - // redirect to page that will create a new rule. - $params = http_build_query(['fromBill' => $bill->id, 'return' => $return]); - - return redirect(route('rules.create', [$group->id]) . '?' . $params); + return redirect(route('rules.create-from-bill', [$bill->id])); } /** diff --git a/app/Http/Controllers/Budget/AmountController.php b/app/Http/Controllers/Budget/AmountController.php index 3753265b1d..f6d7a52ba0 100644 --- a/app/Http/Controllers/Budget/AmountController.php +++ b/app/Http/Controllers/Budget/AmountController.php @@ -25,7 +25,7 @@ namespace FireflyIII\Http\Controllers\Budget; use Carbon\Carbon; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Http\Requests\BudgetIncomeRequest; use FireflyIII\Models\Budget; @@ -36,6 +36,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Collection; +use Log; /** * Class AmountController @@ -80,24 +81,34 @@ class AmountController extends Controller */ public function amount(Request $request, BudgetRepositoryInterface $repository, Budget $budget): JsonResponse { - $amount = (string)$request->get('amount'); - $start = Carbon::createFromFormat('Y-m-d', $request->get('start')); - $end = Carbon::createFromFormat('Y-m-d', $request->get('end')); - $budgetLimit = $this->repository->updateLimitAmount($budget, $start, $end, $amount); - $spent = $repository->spentInPeriod(new Collection([$budget]), new Collection, $start, $end); - $currency = app('amount')->getDefaultCurrency(); - $left = app('amount')->formatAnything($currency, bcadd($amount, $spent), true); - $largeDiff = false; - $warnText = ''; - $leftPerDay = null; - $periodLength = $start->diffInDays($end); - $dayDifference = $this->getDayDifference($start, $end); + // grab vars from URI + $amount = (string)$request->get('amount'); + $start = Carbon::createFromFormat('Y-m-d', $request->get('start')); + $end = Carbon::createFromFormat('Y-m-d', $request->get('end')); + + // grab other useful vars + $currency = app('amount')->getDefaultCurrency(); + $activeDaysLeft = $this->activeDaysLeft($start, $end); + $periodLength = $start->diffInDays($end) + 1; // absolute period length. + + // update limit amount: + $budgetLimit = $this->repository->updateLimitAmount($budget, $start, $end, $amount); + + // calculate what the user has spent in current period. + $spent = $repository->spentInPeriod(new Collection([$budget]), new Collection, $start, $end); + + // given the new budget, this is what they have left (and left per day?) + $left = app('amount')->formatAnything($currency, bcadd($amount, $spent), true); + $leftPerDay = null; // // If the user budgets ANY amount per day for this budget (anything but zero) Firefly III calculates how much he could spend per day. if (1 === bccomp(bcadd($amount, $spent), '0')) { - $leftPerDay = app('amount')->formatAnything($currency, bcdiv(bcadd($amount, $spent), (string)$dayDifference), true); + $leftPerDay = app('amount')->formatAnything($currency, bcdiv(bcadd($amount, $spent), (string)$activeDaysLeft), true); } + $largeDiff = false; + $warnText = ''; + // Get the average amount of money the user budgets for this budget. And calculate the same for the current amount. // If the difference is very large, give the user a notification. $average = $this->repository->budgetedPerDay($budget); @@ -116,8 +127,17 @@ class AmountController extends Controller app('preferences')->mark(); return response()->json( - ['left' => $left, 'name' => $budget->name, 'limit' => $budgetLimit ? $budgetLimit->id : 0, 'amount' => $amount, 'current' => $current, - 'average' => $average, 'large_diff' => $largeDiff, 'left_per_day' => $leftPerDay, 'warn_text' => $warnText,] + [ + 'left' => $left, + 'name' => $budget->name, + 'limit' => $budgetLimit ? $budgetLimit->id : 0, + 'amount' => $amount, + 'current' => $current, + 'average' => $average, + 'large_diff' => $largeDiff, + 'left_per_day' => $leftPerDay, + 'warn_text' => $warnText, + ] ); } @@ -141,27 +161,36 @@ class AmountController extends Controller $average = $this->repository->getAverageAvailable($start, $end); $available = bcmul($average, (string)$daysInPeriod); + Log::debug(sprintf('Average is %s, so total available is %s because days is %d.', $average, $available, $daysInPeriod)); + // amount earned in this period: - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setRange($searchBegin, $searchEnd)->setTypes([TransactionType::DEPOSIT])->withOpposingAccount(); - $earned = (string)$collector->getJournals()->sum('transaction_amount'); + $earned = (string)$collector->getTransactions()->sum('transaction_amount'); // Total amount earned divided by the number of days in the whole search period is the average amount earned per day. // This is multiplied by the number of days in the current period, showing you the average. $earnedAverage = bcmul(bcdiv($earned, (string)$daysInSearchPeriod), (string)$daysInPeriod); + Log::debug(sprintf('Earned is %s, earned average is %s', $earned, $earnedAverage)); + // amount spent in period - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setRange($searchBegin, $searchEnd)->setTypes([TransactionType::WITHDRAWAL])->withOpposingAccount(); - $spent = (string)$collector->getJournals()->sum('transaction_amount'); + $spent = (string)$collector->getTransactions()->sum('transaction_amount'); $spentAverage = app('steam')->positive(bcmul(bcdiv($spent, (string)$daysInSearchPeriod), (string)$daysInPeriod)); + Log::debug(sprintf('Spent is %s, spent average is %s', $earned, $earnedAverage)); + // the default suggestion is the money the user has spent, on average, over this period. $suggested = $spentAverage; + Log::debug(sprintf('Suggested is now %s (spent average)',$suggested)); + // if the user makes less per period, suggest that amount instead. if (1 === bccomp($spentAverage, $earnedAverage)) { + Log::debug(sprintf('Because earned average (%s) is less than spent average (%s) will suggest earned average instead.', $earnedAverage, $spentAverage)); $suggested = $earnedAverage; } diff --git a/app/Http/Controllers/Budget/IndexController.php b/app/Http/Controllers/Budget/IndexController.php index f23cd4f159..78bd66d719 100644 --- a/app/Http/Controllers/Budget/IndexController.php +++ b/app/Http/Controllers/Budget/IndexController.php @@ -25,13 +25,11 @@ namespace FireflyIII\Http\Controllers\Budget; use Carbon\Carbon; -use Exception; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; use FireflyIII\Support\Http\Controllers\DateCalculation; use Illuminate\Http\Request; use Illuminate\Pagination\LengthAwarePaginator; -use Log; /** * @@ -58,6 +56,7 @@ class IndexController extends Controller app('view')->share('title', (string)trans('firefly.budgets')); app('view')->share('mainTitleIcon', 'fa-tasks'); $this->repository = app(BudgetRepositoryInterface::class); + $this->repository->cleanupBudgets(); return $next($request); } @@ -69,74 +68,67 @@ class IndexController extends Controller * Show all budgets. * * @param Request $request - * @param string|null $moment + * + * @param Carbon|null $start + * @param Carbon|null $end * * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View * * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - public function index(Request $request, string $moment = null) + public function index(Request $request, Carbon $start = null, Carbon $end = null) { - /** @var string $range */ - $range = app('preferences')->get('viewRange', '1M')->data; - /** @var Carbon $start */ - $start = session('start', new Carbon); - /** @var Carbon $end */ - $end = session('end', new Carbon); - $page = 0 === (int)$request->get('page') ? 1 : (int)$request->get('page'); - $pageSize = (int)app('preferences')->get('listPageSize', 50)->data; - $moment = $moment ?? ''; - - // make date if the data is given. - if ('' !== (string)$moment) { - try { - $start = new Carbon($moment); - /** @var Carbon $end */ - $end = app('navigation')->endOfPeriod($start, $range); - } catch (Exception $e) { - // start and end are already defined. - Log::debug(sprintf('start and end are already defined: %s', $e->getMessage())); - } - } - - // if today is between start and end, use the diff in days between end and today (days left) - // otherwise, use diff between start and end. - $dayDifference = $this->getDayDifference($start, $end); + // collect some basic vars: + $range = app('preferences')->get('viewRange', '1M')->data; + $start = $start ?? session('start', Carbon::now()->startOfMonth()); + $end = $end ?? app('navigation')->endOfPeriod($start, $range); + $page = 0 === (int)$request->get('page') ? 1 : (int)$request->get('page'); + $pageSize = (int)app('preferences')->get('listPageSize', 50)->data; + $defaultCurrency = app('amount')->getDefaultCurrency(); + // make the next and previous period, and calculate the periods used for period navigation $next = clone $end; $next->addDay(); $prev = clone $start; $prev->subDay(); - $prev = app('navigation')->startOfPeriod($prev, $range); - $this->repository->cleanupBudgets(); - $daysPassed = $this->getDaysPassedInPeriod($start, $end); - $allBudgets = $this->repository->getActiveBudgets(); - $total = $allBudgets->count(); - $budgets = $allBudgets->slice(($page - 1) * $pageSize, $pageSize); - $inactive = $this->repository->getInactiveBudgets(); - $periodStart = $start->formatLocalized($this->monthAndDayFormat); - $periodEnd = $end->formatLocalized($this->monthAndDayFormat); - $budgetInformation = $this->repository->collectBudgetInformation($allBudgets, $start, $end); - $defaultCurrency = app('amount')->getDefaultCurrency(); - $available = $this->repository->getAvailableBudget($defaultCurrency, $start, $end); - $spent = array_sum(array_column($budgetInformation, 'spent')); - $budgeted = array_sum(array_column($budgetInformation, 'budgeted')); - $previousLoop = $this->getPreviousPeriods($start, $range); - $nextLoop = $this->getNextPeriods($end, $range); - - // paginate budgets - $budgets = new LengthAwarePaginator($budgets, $total, $pageSize, $page); - $budgets->setPath(route('budgets.index')); - // display info + $prev = app('navigation')->startOfPeriod($prev, $range); + $previousLoop = $this->getPreviousPeriods($start, $range); + $nextLoop = $this->getNextPeriods($start, $range); $currentMonth = app('navigation')->periodShow($start, $range); $nextText = app('navigation')->periodShow($next, $range); $prevText = app('navigation')->periodShow($prev, $range); + // number of days for consistent budgeting. + $activeDaysPassed = $this->activeDaysPassed($start, $end); // see method description. + $activeDaysLeft = $this->activeDaysLeft($start, $end); // see method description. + + // get all budgets, and paginate them into $budgets. + $collection = $this->repository->getActiveBudgets(); + $total = $collection->count(); + $budgets = $collection->slice(($page - 1) * $pageSize, $pageSize); + + // get all inactive budgets, and simply list them: + $inactive = $this->repository->getInactiveBudgets(); + + // collect budget info to fill bars and so on. + $budgetInformation = $this->repository->collectBudgetInformation($collection, $start, $end); + + // to display available budget: + $available = $this->repository->getAvailableBudget($defaultCurrency, $start, $end); + $spent = array_sum(array_column($budgetInformation, 'spent')); + $budgeted = array_sum(array_column($budgetInformation, 'budgeted')); + + + // paginate budgets + $paginator = new LengthAwarePaginator($budgets, $total, $pageSize, $page); + $paginator->setPath(route('budgets.index')); + return view( 'budgets.index', compact( - 'available', 'currentMonth', 'next', 'nextText', 'prev', 'allBudgets', 'prevText', 'periodStart', 'periodEnd', 'dayDifference', - 'page', - 'budgetInformation', 'daysPassed', + 'available', 'currentMonth', 'next', 'nextText', 'prev', 'paginator', + 'prevText', + 'page', 'activeDaysPassed', 'activeDaysLeft', + 'budgetInformation', 'inactive', 'budgets', 'spent', 'budgeted', 'previousLoop', 'nextLoop', 'start', 'end' ) ); diff --git a/app/Http/Controllers/Budget/ShowController.php b/app/Http/Controllers/Budget/ShowController.php index 0b48280422..7b0e62bffd 100644 --- a/app/Http/Controllers/Budget/ShowController.php +++ b/app/Http/Controllers/Budget/ShowController.php @@ -26,7 +26,7 @@ namespace FireflyIII\Http\Controllers\Budget; use Carbon\Carbon; use FireflyIII\Exceptions\FireflyException; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Models\Budget; use FireflyIII\Models\BudgetLimit; @@ -34,6 +34,7 @@ use FireflyIII\Models\TransactionType; use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; use FireflyIII\Repositories\Journal\JournalRepositoryInterface; use FireflyIII\Support\CacheProperties; +use FireflyIII\Support\Http\Controllers\PeriodOverview; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -43,6 +44,7 @@ use Illuminate\Support\Collection; */ class ShowController extends Controller { + use PeriodOverview; /** @var BudgetRepositoryInterface The budget repository */ private $repository; @@ -86,15 +88,15 @@ class ShowController extends Controller 'firefly.without_budget_between', ['start' => $start->formatLocalized($this->monthAndDayFormat), 'end' => $end->formatLocalized($this->monthAndDayFormat)] ); - $periods = $this->getPeriodOverview(); + $periods = $this->getBudgetPeriodOverview(); $page = (int)$request->get('page'); $pageSize = (int)app('preferences')->get('listPageSize', 50)->data; - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setRange($start, $end)->setTypes([TransactionType::WITHDRAWAL])->setLimit($pageSize)->setPage($page) ->withoutBudget()->withOpposingAccount(); - $transactions = $collector->getPaginatedJournals(); + $transactions = $collector->getPaginatedTransactions(); $transactions->setPath(route('budgets.no-budget')); return view('budgets.no-budget', compact('transactions', 'subTitle', 'periods', 'start', 'end')); @@ -118,16 +120,15 @@ class ShowController extends Controller $end = new Carbon; $page = (int)$request->get('page'); $pageSize = (int)app('preferences')->get('listPageSize', 50)->data; - $moment = 'all'; - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setRange($start, $end)->setTypes([TransactionType::WITHDRAWAL])->setLimit($pageSize)->setPage($page) ->withoutBudget()->withOpposingAccount(); - $transactions = $collector->getPaginatedJournals(); + $transactions = $collector->getPaginatedTransactions(); $transactions->setPath(route('budgets.no-budget')); - return view('budgets.no-budget', compact('transactions', 'subTitle', 'moment', 'start', 'end')); + return view('budgets.no-budget', compact('transactions', 'subTitle', 'start', 'end')); } @@ -142,7 +143,7 @@ class ShowController extends Controller public function show(Request $request, Budget $budget) { /** @var Carbon $start */ - $start = session('first', Carbon::create()->startOfYear()); + $start = session('first', Carbon::now()->startOfYear()); $end = new Carbon; $page = (int)$request->get('page'); $pageSize = (int)app('preferences')->get('listPageSize', 50)->data; @@ -150,10 +151,10 @@ class ShowController extends Controller $repetition = null; // collector: - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setRange($start, $end)->setBudget($budget)->setLimit($pageSize)->setPage($page)->withBudgetInformation(); - $transactions = $collector->getPaginatedJournals(); + $transactions = $collector->getPaginatedTransactions(); $transactions->setPath(route('budgets.show', [$budget->id])); $subTitle = (string)trans('firefly.all_journals_for_budget', ['name' => $budget->name]); @@ -189,14 +190,14 @@ class ShowController extends Controller ); // collector: - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setRange($budgetLimit->start_date, $budgetLimit->end_date) ->setBudget($budget)->setLimit($pageSize)->setPage($page)->withBudgetInformation(); - $transactions = $collector->getPaginatedJournals(); + $transactions = $collector->getPaginatedTransactions(); $transactions->setPath(route('budgets.show', [$budget->id, $budgetLimit->id])); /** @var Carbon $start */ - $start = session('first', Carbon::create()->startOfYear()); + $start = session('first', Carbon::now()->startOfYear()); $end = new Carbon; $limits = $this->getLimits($budget, $start, $end); @@ -212,7 +213,7 @@ class ShowController extends Controller * * @return Collection */ - private function getLimits(Budget $budget, Carbon $start, Carbon $end): Collection + protected function getLimits(Budget $budget, Carbon $start, Carbon $end): Collection // get data + augment with info { // properties for cache $cache = new CacheProperties; @@ -237,57 +238,4 @@ class ShowController extends Controller return $set; } - - - /** - * Gets period overview used for budgets. - * - * @return Collection - * - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) - */ - private function getPeriodOverview(): Collection - { - /** @var JournalRepositoryInterface $repository */ - $repository = app(JournalRepositoryInterface::class); - $first = $repository->firstNull(); - $start = null === $first ? new Carbon : $first->date; - $range = app('preferences')->get('viewRange', '1M')->data; - $start = app('navigation')->startOfPeriod($start, $range); - $end = app('navigation')->endOfX(new Carbon, $range, null); - $entries = new Collection; - $cache = new CacheProperties; - $cache->addProperty($start); - $cache->addProperty($end); - $cache->addProperty('no-budget-period-entries'); - - if ($cache->has()) { - return $cache->get(); // @codeCoverageIgnore - } - $dates = app('navigation')->blockPeriods($start, $end, $range); - foreach ($dates as $date) { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); - $collector->setAllAssetAccounts()->setRange($date['start'], $date['end'])->withoutBudget()->withOpposingAccount()->setTypes( - [TransactionType::WITHDRAWAL] - ); - $set = $collector->getJournals(); - $sum = (string)($set->sum('transaction_amount') ?? '0'); - $journals = $set->count(); - /** @noinspection PhpUndefinedMethodInspection */ - $dateStr = $date['end']->format('Y-m-d'); - $dateName = app('navigation')->periodShow($date['end'], $date['period']); - $entries->push( - ['string' => $dateStr, 'name' => $dateName, 'count' => $journals, 'sum' => $sum, 'date' => clone $date['end'], - 'start' => $date['start'], - 'end' => $date['end'], - - ] - ); - } - $cache->store($entries); - - return $entries; - } - } diff --git a/app/Http/Controllers/Category/NoCategoryController.php b/app/Http/Controllers/Category/NoCategoryController.php index 59b03f4a1f..1f438869e2 100644 --- a/app/Http/Controllers/Category/NoCategoryController.php +++ b/app/Http/Controllers/Category/NoCategoryController.php @@ -25,11 +25,10 @@ namespace FireflyIII\Http\Controllers\Category; use Carbon\Carbon; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Helpers\Filter\InternalTransferFilter; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Models\TransactionType; - use FireflyIII\Repositories\Journal\JournalRepositoryInterface; use FireflyIII\Support\CacheProperties; use Illuminate\Http\Request; @@ -80,7 +79,6 @@ class NoCategoryController extends Controller $start = $start ?? session('start'); /** @var Carbon $end */ $end = $end ?? session('end'); - $moment = ''; $page = (int)$request->get('page'); $pageSize = (int)app('preferences')->get('listPageSize', 50)->data; $subTitle = trans( @@ -92,30 +90,28 @@ class NoCategoryController extends Controller Log::debug(sprintf('Start for noCategory() is %s', $start->format('Y-m-d'))); Log::debug(sprintf('End for noCategory() is %s', $end->format('Y-m-d'))); - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setRange($start, $end)->setLimit($pageSize)->setPage($page)->withoutCategory()->withOpposingAccount() ->setTypes([TransactionType::WITHDRAWAL, TransactionType::DEPOSIT, TransactionType::TRANSFER]); $collector->removeFilter(InternalTransferFilter::class); - $transactions = $collector->getPaginatedJournals(); + $transactions = $collector->getPaginatedTransactions(); $transactions->setPath(route('categories.no-category')); - return view('categories.no-category', compact('transactions', 'subTitle', 'moment', 'periods', 'start', 'end')); + return view('categories.no-category', compact('transactions', 'subTitle', 'periods', 'start', 'end')); } /** * Show all transactions without a category. * - * @param Request $request - * @param string|null $moment + * @param Request $request * * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View */ - public function showAll(Request $request, string $moment = null) + public function showAll(Request $request) { // default values: - $moment = $moment ?? ''; $start = null; $end = null; $periods = new Collection; @@ -129,15 +125,15 @@ class NoCategoryController extends Controller Log::debug(sprintf('Start for noCategory() is %s', $start->format('Y-m-d'))); Log::debug(sprintf('End for noCategory() is %s', $end->format('Y-m-d'))); - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setRange($start, $end)->setLimit($pageSize)->setPage($page)->withoutCategory()->withOpposingAccount() ->setTypes([TransactionType::WITHDRAWAL, TransactionType::DEPOSIT, TransactionType::TRANSFER]); $collector->removeFilter(InternalTransferFilter::class); - $transactions = $collector->getPaginatedJournals(); + $transactions = $collector->getPaginatedTransactions(); $transactions->setPath(route('categories.no-category')); - return view('categories.no-category', compact('transactions', 'subTitle', 'moment', 'periods', 'start', 'end')); + return view('categories.no-category', compact('transactions', 'subTitle', 'periods', 'start', 'end')); } @@ -149,7 +145,7 @@ class NoCategoryController extends Controller * @return Collection * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - private function getNoCategoryPeriodOverview(Carbon $theDate): Collection + protected function getNoCategoryPeriodOverview(Carbon $theDate): Collection // period overview method. { Log::debug(sprintf('Now in getNoCategoryPeriodOverview(%s)', $theDate->format('Y-m-d'))); $range = app('preferences')->get('viewRange', '1M')->data; @@ -176,36 +172,36 @@ class NoCategoryController extends Controller foreach ($dates as $date) { // count journals without category in this period: - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setRange($date['start'], $date['end'])->withoutCategory() ->withOpposingAccount()->setTypes([TransactionType::WITHDRAWAL, TransactionType::DEPOSIT, TransactionType::TRANSFER]); $collector->removeFilter(InternalTransferFilter::class); - $count = $collector->getJournals()->count(); + $count = $collector->getTransactions()->count(); // amount transferred - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setRange($date['start'], $date['end'])->withoutCategory() ->withOpposingAccount()->setTypes([TransactionType::TRANSFER]); $collector->removeFilter(InternalTransferFilter::class); - $transferred = app('steam')->positive((string)$collector->getJournals()->sum('transaction_amount')); + $transferred = app('steam')->positive((string)$collector->getTransactions()->sum('transaction_amount')); // amount spent - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setRange($date['start'], $date['end'])->withoutCategory()->withOpposingAccount()->setTypes( [TransactionType::WITHDRAWAL] ); - $spent = $collector->getJournals()->sum('transaction_amount'); + $spent = $collector->getTransactions()->sum('transaction_amount'); // amount earned - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setRange($date['start'], $date['end'])->withoutCategory()->withOpposingAccount()->setTypes( [TransactionType::DEPOSIT] ); - $earned = $collector->getJournals()->sum('transaction_amount'); + $earned = $collector->getTransactions()->sum('transaction_amount'); /** @noinspection PhpUndefinedMethodInspection */ $dateStr = $date['end']->format('Y-m-d'); $dateName = app('navigation')->periodShow($date['end'], $date['period']); diff --git a/app/Http/Controllers/Category/ShowController.php b/app/Http/Controllers/Category/ShowController.php index eb0e54edfd..42826046e4 100644 --- a/app/Http/Controllers/Category/ShowController.php +++ b/app/Http/Controllers/Category/ShowController.php @@ -24,16 +24,14 @@ declare(strict_types=1); namespace FireflyIII\Http\Controllers\Category; use Carbon\Carbon; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Helpers\Filter\InternalTransferFilter; use FireflyIII\Http\Controllers\Controller; -use FireflyIII\Models\AccountType; use FireflyIII\Models\Category; -use FireflyIII\Models\TransactionType; use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Repositories\Category\CategoryRepositoryInterface; use FireflyIII\Repositories\Journal\JournalRepositoryInterface; -use FireflyIII\Support\CacheProperties; +use FireflyIII\Support\Http\Controllers\PeriodOverview; use Illuminate\Http\Request; use Illuminate\Support\Collection; use Log; @@ -46,7 +44,7 @@ use Log; */ class ShowController extends Controller { - + use PeriodOverview; /** @var AccountRepositoryInterface The account repository */ private $accountRepos; /** @var JournalRepositoryInterface Journals and transactions overview */ @@ -90,14 +88,13 @@ class ShowController extends Controller { Log::debug('Now in show()'); /** @var Carbon $start */ - $start = $start ?? session('start', Carbon::create()->startOfMonth()); + $start = $start ?? session('start', Carbon::now()->startOfMonth()); /** @var Carbon $end */ - $end = $end ?? session('end', Carbon::create()->startOfMonth()); + $end = $end ?? session('end', Carbon::now()->endOfMonth()); $subTitleIcon = 'fa-bar-chart'; - $moment = ''; $page = (int)$request->get('page'); $pageSize = (int)app('preferences')->get('listPageSize', 50)->data; - $periods = $this->getPeriodOverview($category, $start); + $periods = $this->getCategoryPeriodOverview($category, $start); $path = route('categories.show', [$category->id, $start->format('Y-m-d'), $end->format('Y-m-d')]); $subTitle = trans( 'firefly.journals_in_period_for_category', @@ -105,17 +102,17 @@ class ShowController extends Controller 'end' => $end->formatLocalized($this->monthAndDayFormat),] ); - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setRange($start, $end)->setLimit($pageSize)->setPage($page)->withOpposingAccount() ->setCategory($category)->withBudgetInformation()->withCategoryInformation(); $collector->removeFilter(InternalTransferFilter::class); - $transactions = $collector->getPaginatedJournals(); + $transactions = $collector->getPaginatedTransactions(); $transactions->setPath($path); Log::debug('End of show()'); - return view('categories.show', compact('category', 'transactions', 'moment', 'periods', 'subTitle', 'subTitleIcon', 'start', 'end')); + return view('categories.show', compact('category', 'transactions', 'periods', 'subTitle', 'subTitleIcon', 'start', 'end')); } /** @@ -135,92 +132,23 @@ class ShowController extends Controller $start = null; $end = null; $periods = new Collection; - $moment = 'all'; $subTitle = (string)trans('firefly.all_journals_for_category', ['name' => $category->name]); $first = $this->repository->firstUseDate($category); /** @var Carbon $start */ $start = $first ?? new Carbon; $end = new Carbon; - $path = route('categories.show-all', [$category->id]); + $path = route('categories.show.all', [$category->id]); - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setRange($start, $end)->setLimit($pageSize)->setPage($page)->withOpposingAccount() ->setCategory($category)->withBudgetInformation()->withCategoryInformation(); $collector->removeFilter(InternalTransferFilter::class); - $transactions = $collector->getPaginatedJournals(); + $transactions = $collector->getPaginatedTransactions(); $transactions->setPath($path); - return view('categories.show', compact('category', 'moment', 'transactions', 'periods', 'subTitle', 'subTitleIcon', 'start', 'end')); + return view('categories.show', compact('category', 'transactions', 'periods', 'subTitle', 'subTitleIcon', 'start', 'end')); } - - /** - * Get a period overview for category. - * - * @param Category $category - * - * @param Carbon $date - * - * @return Collection - * - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) - */ - private function getPeriodOverview(Category $category, Carbon $date): Collection - { - $range = app('preferences')->get('viewRange', '1M')->data; - $first = $this->journalRepos->firstNull(); - $start = null === $first ? new Carbon : $first->date; - $end = $date ?? new Carbon; - $accounts = $this->accountRepos->getAccountsByType([AccountType::DEFAULT, AccountType::ASSET]); - - // properties for entries with their amounts. - $cache = new CacheProperties(); - $cache->addProperty($start); - $cache->addProperty($end); - $cache->addProperty($range); - $cache->addProperty('categories.entries'); - $cache->addProperty($category->id); - - if ($cache->has()) { - return $cache->get(); // @codeCoverageIgnore - } - /** @var array $dates */ - $dates = app('navigation')->blockPeriods($start, $end, $range); - $entries = new Collection; - - foreach ($dates as $currentDate) { - $spent = $this->repository->spentInPeriod(new Collection([$category]), $accounts, $currentDate['start'], $currentDate['end']); - $earned = $this->repository->earnedInPeriod(new Collection([$category]), $accounts, $currentDate['start'], $currentDate['end']); - /** @noinspection PhpUndefinedMethodInspection */ - $dateStr = $currentDate['end']->format('Y-m-d'); - $dateName = app('navigation')->periodShow($currentDate['end'], $currentDate['period']); - - // amount transferred - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); - $collector->setAllAssetAccounts()->setRange($currentDate['start'], $currentDate['end'])->setCategory($category) - ->withOpposingAccount()->setTypes([TransactionType::TRANSFER]); - $collector->removeFilter(InternalTransferFilter::class); - $transferred = app('steam')->positive((string)$collector->getJournals()->sum('transaction_amount')); - - $entries->push( - [ - 'string' => $dateStr, - 'name' => $dateName, - 'spent' => $spent, - 'earned' => $earned, - 'sum' => bcadd($earned, $spent), - 'transferred' => $transferred, - 'start' => clone $currentDate['start'], - 'end' => clone $currentDate['end'], - ] - ); - } - $cache->store($entries); - - return $entries; - } - } diff --git a/app/Http/Controllers/Chart/AccountController.php b/app/Http/Controllers/Chart/AccountController.php index 124cc00da6..ff75bc676c 100644 --- a/app/Http/Controllers/Chart/AccountController.php +++ b/app/Http/Controllers/Chart/AccountController.php @@ -18,24 +18,23 @@ * You should have received a copy of the GNU General Public License * along with Firefly III. If not, see . */ -/** @noinspection NullPointerExceptionInspection */ declare(strict_types=1); namespace FireflyIII\Http\Controllers\Chart; use Carbon\Carbon; use FireflyIII\Generator\Chart\Basic\GeneratorInterface; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Models\Account; use FireflyIII\Models\AccountType; use FireflyIII\Models\Transaction; +use FireflyIII\Models\TransactionCurrency; use FireflyIII\Models\TransactionType; use FireflyIII\Repositories\Account\AccountRepositoryInterface; -use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; -use FireflyIII\Repositories\Category\CategoryRepositoryInterface; use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface; use FireflyIII\Support\CacheProperties; +use FireflyIII\Support\Http\Controllers\AugumentData; use FireflyIII\Support\Http\Controllers\DateCalculation; use Illuminate\Http\JsonResponse; use Illuminate\Support\Collection; @@ -50,29 +49,46 @@ use Log; */ class AccountController extends Controller { - use DateCalculation; + use DateCalculation, AugumentData; /** @var GeneratorInterface Chart generation methods. */ protected $generator; + /** @var AccountRepositoryInterface Account repository. */ + private $accountRepository; + + /** @var CurrencyRepositoryInterface */ + private $currencyRepository; + /** * AccountController constructor. */ public function __construct() { parent::__construct(); - $this->generator = app(GeneratorInterface::class); + + $this->middleware( + function ($request, $next) { + $this->generator = app(GeneratorInterface::class); + $this->accountRepository = app(AccountRepositoryInterface::class); + $this->currencyRepository = app(CurrencyRepositoryInterface::class); + + return $next($request); + } + ); + + } /** - * Shows the balances for all the user's expense accounts. + * Shows the balances for all the user's expense accounts (on the front page). * - * @param AccountRepositoryInterface $repository + * This chart is (multi) currency aware. * * @return JsonResponse */ - public function expenseAccounts(AccountRepositoryInterface $repository): JsonResponse + public function expenseAccounts(): JsonResponse { /** @var Carbon $start */ $start = clone session('start', Carbon::now()->startOfMonth()); @@ -87,23 +103,71 @@ class AccountController extends Controller } $start->subDay(); - $accounts = $repository->getAccountsByType([AccountType::EXPENSE, AccountType::BENEFICIARY]); - $startBalances = app('steam')->balancesByAccounts($accounts, $start); - $endBalances = app('steam')->balancesByAccounts($accounts, $end); - $chartData = []; + // prep some vars: + $currencies = []; + $chartData = []; + $tempData = []; - foreach ($accounts as $account) { - $id = $account->id; - $startBalance = $startBalances[$id] ?? '0'; - $endBalance = $endBalances[$id] ?? '0'; - $diff = bcsub($endBalance, $startBalance); - if (0 !== bccomp($diff, '0')) { - $chartData[$account->name] = $diff; + // grab all accounts and names + $accounts = $this->accountRepository->getAccountsByType([AccountType::EXPENSE, AccountType::BENEFICIARY]); + $accountNames = $this->extractNames($accounts); + + // grab all balances + $startBalances = app('steam')->balancesPerCurrencyByAccounts($accounts, $start); + $endBalances = app('steam')->balancesPerCurrencyByAccounts($accounts, $end); + + // loop the end balances. This is an array for each account ($expenses) + foreach ($endBalances as $accountId => $expenses) { + $accountId = (int)$accountId; + // loop each expense entry (each entry can be a different currency). + foreach ($expenses as $currencyId => $endAmount) { + $currencyId = (int)$currencyId; + + // see if there is an accompanying start amount. + // grab the difference and find the currency. + $startAmount = $startBalances[$accountId][$currencyId] ?? '0'; + $diff = bcsub($endAmount, $startAmount); + $currencies[$currencyId] = $currencies[$currencyId] ?? $this->currencyRepository->findNull($currencyId); + if (0 !== bccomp($diff, '0')) { + // store the values in a temporary array. + $tempData[] = [ + 'name' => $accountNames[$accountId], + 'difference' => $diff, + 'diff_float' => (float)$diff, + 'currency_id' => $currencyId, + ]; + } } } - arsort($chartData); - $data = $this->generator->singleSet((string)trans('firefly.spent'), $chartData); + // sort temp array by amount. + $amounts = array_column($tempData, 'diff_float'); + array_multisort($amounts, SORT_DESC, $tempData); + + // loop all found currencies and build the data array for the chart. + /** + * @var int $currencyId + * @var TransactionCurrency $currency + */ + foreach ($currencies as $currencyId => $currency) { + $dataSet + = [ + 'label' => (string)trans('firefly.spent'), + 'type' => 'bar', + 'currency_symbol' => $currency->symbol, + 'entries' => $this->expandNames($tempData), + ]; + $chartData[$currencyId] = $dataSet; + } + + // loop temp data and place data in correct array: + foreach ($tempData as $entry) { + $currencyId = $entry['currency_id']; + $name = $entry['name']; + $chartData[$currencyId]['entries'][$name] = $entry['difference']; + } + + $data = $this->generator->multiSet($chartData); $cache->store($data); return response()->json($data); @@ -129,24 +193,37 @@ class AccountController extends Controller if ($cache->has()) { return response()->json($cache->get()); // @codeCoverageIgnore } - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts(new Collection([$account]))->setRange($start, $end)->withBudgetInformation()->setTypes([TransactionType::WITHDRAWAL]); - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); $chartData = []; $result = []; - + $budgetIds = []; /** @var Transaction $transaction */ foreach ($transactions as $transaction) { - $jrnlBudgetId = (int)$transaction->transaction_journal_budget_id; - $transBudgetId = (int)$transaction->transaction_budget_id; - $budgetId = max($jrnlBudgetId, $transBudgetId); - $result[$budgetId] = $result[$budgetId] ?? '0'; - $result[$budgetId] = bcadd($transaction->transaction_amount, $result[$budgetId]); + $jrnlBudgetId = (int)$transaction->transaction_journal_budget_id; + $transBudgetId = (int)$transaction->transaction_budget_id; + $currencyName = $transaction->transaction_currency_name; + $budgetId = max($jrnlBudgetId, $transBudgetId); + $combi = $budgetId . $currencyName; + $budgetIds[] = $budgetId; + if (!isset($result[$combi])) { + $result[$combi] = [ + 'total' => '0', + 'budget_id' => $budgetId, + 'currency' => $currencyName, + ]; + } + $result[$combi]['total'] = bcadd($transaction->transaction_amount, $result[$combi]['total']); } - $names = $this->getBudgetNames(array_keys($result)); - foreach ($result as $budgetId => $amount) { - $chartData[$names[$budgetId]] = $amount; + $names = $this->getBudgetNames($budgetIds); + foreach ($result as $row) { + $budgetId = $row['budget_id']; + $name = $names[$budgetId]; + $label = (string)trans('firefly.name_in_currency', ['name' => $name, 'currency' => $row['currency']]); + $chartData[$label] = $row['total']; } $data = $this->generator->pieChart($chartData); @@ -165,7 +242,7 @@ class AccountController extends Controller */ public function expenseBudgetAll(AccountRepositoryInterface $repository, Account $account): JsonResponse { - $start = $repository->oldestJournalDate($account); + $start = $repository->oldestJournalDate($account) ?? Carbon::now()->startOfMonth(); $end = Carbon::now(); return $this->expenseBudget($account, $start, $end); @@ -192,23 +269,38 @@ class AccountController extends Controller return response()->json($cache->get()); // @codeCoverageIgnore } - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts(new Collection([$account]))->setRange($start, $end)->withCategoryInformation()->setTypes([TransactionType::WITHDRAWAL]); - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); $result = []; $chartData = []; + $categoryIds = []; /** @var Transaction $transaction */ foreach ($transactions as $transaction) { - $jrnlCatId = (int)$transaction->transaction_journal_category_id; - $transCatId = (int)$transaction->transaction_category_id; - $categoryId = max($jrnlCatId, $transCatId); - $result[$categoryId] = $result[$categoryId] ?? '0'; - $result[$categoryId] = bcadd($transaction->transaction_amount, $result[$categoryId]); + $jrnlCatId = (int)$transaction->transaction_journal_category_id; + $transCatId = (int)$transaction->transaction_category_id; + $currencyName = $transaction->transaction_currency_name; + $categoryId = max($jrnlCatId, $transCatId); + $combi = $categoryId . $currencyName; + $categoryIds[] = $categoryId; + if (!isset($result[$combi])) { + $result[$combi] = [ + 'total' => '0', + 'category_id' => $categoryId, + 'currency' => $currencyName, + ]; + } + $result[$combi]['total'] = bcadd($transaction->transaction_amount, $result[$combi]['total']); } $names = $this->getCategoryNames(array_keys($result)); - foreach ($result as $categoryId => $amount) { - $chartData[$names[$categoryId]] = $amount; + + foreach ($result as $row) { + $categoryId = $row['category_id']; + $name = $names[$categoryId] ?? '(unknown)'; + $label = (string)trans('firefly.name_in_currency', ['name' => $name, 'currency' => $row['currency']]); + $chartData[$label] = $row['total']; } $data = $this->generator->pieChart($chartData); @@ -227,7 +319,7 @@ class AccountController extends Controller */ public function expenseCategoryAll(AccountRepositoryInterface $repository, Account $account): JsonResponse { - $start = $repository->oldestJournalDate($account); + $start = $repository->oldestJournalDate($account) ?? Carbon::now()->startOfMonth(); $end = Carbon::now(); return $this->expenseCategory($account, $start, $end); @@ -283,23 +375,36 @@ class AccountController extends Controller } // grab all journals: - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts(new Collection([$account]))->setRange($start, $end)->withCategoryInformation()->setTypes([TransactionType::DEPOSIT]); - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); $result = []; $chartData = []; /** @var Transaction $transaction */ foreach ($transactions as $transaction) { - $jrnlCatId = (int)$transaction->transaction_journal_category_id; - $transCatId = (int)$transaction->transaction_category_id; - $categoryId = max($jrnlCatId, $transCatId); - $result[$categoryId] = $result[$categoryId] ?? '0'; - $result[$categoryId] = bcadd($transaction->transaction_amount, $result[$categoryId]); + $jrnlCatId = (int)$transaction->transaction_journal_category_id; + $transCatId = (int)$transaction->transaction_category_id; + $categoryId = max($jrnlCatId, $transCatId); + $currencyName = $transaction->transaction_currency_name; + $combi = $categoryId . $currencyName; + $categoryIds[] = $categoryId; + if (!isset($result[$combi])) { + $result[$combi] = [ + 'total' => '0', + 'category_id' => $categoryId, + 'currency' => $currencyName, + ]; + } + $result[$combi]['total'] = bcadd($transaction->transaction_amount, $result[$combi]['total']); } $names = $this->getCategoryNames(array_keys($result)); - foreach ($result as $categoryId => $amount) { - $chartData[$names[$categoryId]] = $amount; + foreach ($result as $row) { + $categoryId = $row['category_id']; + $name = $names[$categoryId] ?? '(unknown)'; + $label = (string)trans('firefly.name_in_currency', ['name' => $name, 'currency' => $row['currency']]); + $chartData[$label] = $row['total']; } $data = $this->generator->pieChart($chartData); $cache->store($data); @@ -317,7 +422,7 @@ class AccountController extends Controller */ public function incomeCategoryAll(AccountRepositoryInterface $repository, Account $account): JsonResponse { - $start = $repository->oldestJournalDate($account); + $start = $repository->oldestJournalDate($account) ?? Carbon::now()->startOfMonth(); $end = Carbon::now(); return $this->incomeCategory($account, $start, $end); @@ -327,6 +432,8 @@ class AccountController extends Controller /** * Shows overview of account during a single period. * + * TODO this chart is not multi-currency aware. + * * @param Account $account * @param Carbon $start * @@ -386,6 +493,8 @@ class AccountController extends Controller /** * Shows the balances for a given set of dates and accounts. * + * TODO this chart is not multi-currency aware. + * * @param Carbon $start * @param Carbon $end * @param Collection $accounts @@ -401,41 +510,90 @@ class AccountController extends Controller /** * Shows the balances for all the user's revenue accounts. * - * @param AccountRepositoryInterface $repository + * This chart is multi-currency aware. * * @return JsonResponse */ - public function revenueAccounts(AccountRepositoryInterface $repository): JsonResponse + public function revenueAccounts(): JsonResponse { - $start = clone session('start', Carbon::now()->startOfMonth()); - $end = clone session('end', Carbon::now()->endOfMonth()); - $chartData = []; - $cache = new CacheProperties; + /** @var Carbon $start */ + $start = clone session('start', Carbon::now()->startOfMonth()); + /** @var Carbon $end */ + $end = clone session('end', Carbon::now()->endOfMonth()); + $cache = new CacheProperties; $cache->addProperty($start); $cache->addProperty($end); $cache->addProperty('chart.account.revenue-accounts'); if ($cache->has()) { - return response()->json($cache->get()); // @codeCoverageIgnore + //return response()->json($cache->get()); // @codeCoverageIgnore } - $accounts = $repository->getAccountsByType([AccountType::REVENUE]); - $start->subDay(); - $startBalances = app('steam')->balancesByAccounts($accounts, $start); - $endBalances = app('steam')->balancesByAccounts($accounts, $end); - foreach ($accounts as $account) { - $id = $account->id; - $startBalance = $startBalances[$id] ?? '0'; - $endBalance = $endBalances[$id] ?? '0'; - $diff = bcsub($endBalance, $startBalance); - $diff = bcmul($diff, '-1'); - if (0 !== bccomp($diff, '0')) { - $chartData[$account->name] = $diff; + // prep some vars: + $currencies = []; + $chartData = []; + $tempData = []; + + // grab all accounts and names + $accounts = $this->accountRepository->getAccountsByType([AccountType::REVENUE]); + $accountNames = $this->extractNames($accounts); + + // grab all balances + $startBalances = app('steam')->balancesPerCurrencyByAccounts($accounts, $start); + $endBalances = app('steam')->balancesPerCurrencyByAccounts($accounts, $end); + + // loop the end balances. This is an array for each account ($expenses) + foreach ($endBalances as $accountId => $expenses) { + $accountId = (int)$accountId; + // loop each expense entry (each entry can be a different currency). + foreach ($expenses as $currencyId => $endAmount) { + $currencyId = (int)$currencyId; + + // see if there is an accompanying start amount. + // grab the difference and find the currency. + $startAmount = $startBalances[$accountId][$currencyId] ?? '0'; + $diff = bcsub($endAmount, $startAmount); + $currencies[$currencyId] = $currencies[$currencyId] ?? $this->currencyRepository->findNull($currencyId); + if (0 !== bccomp($diff, '0')) { + // store the values in a temporary array. + $tempData[] = [ + 'name' => $accountNames[$accountId], + 'difference' => $diff, + 'diff_float' => (float)$diff, + 'currency_id' => $currencyId, + ]; + } } } - arsort($chartData); - $data = $this->generator->singleSet((string)trans('firefly.earned'), $chartData); + // sort temp array by amount. + $amounts = array_column($tempData, 'diff_float'); + array_multisort($amounts, SORT_DESC, $tempData); + + // loop all found currencies and build the data array for the chart. + /** + * @var int $currencyId + * @var TransactionCurrency $currency + */ + foreach ($currencies as $currencyId => $currency) { + $dataSet + = [ + 'label' => (string)trans('firefly.earned'), + 'type' => 'bar', + 'currency_symbol' => $currency->symbol, + 'entries' => $this->expandNames($tempData), + ]; + $chartData[$currencyId] = $dataSet; + } + + // loop temp data and place data in correct array: + foreach ($tempData as $entry) { + $currencyId = $entry['currency_id']; + $name = $entry['name']; + $chartData[$currencyId]['entries'][$name] = bcmul($entry['difference'], '-1'); + } + + $data = $this->generator->multiSet($chartData); $cache->store($data); return response()->json($data); @@ -454,7 +612,7 @@ class AccountController extends Controller * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - private function accountBalanceChart(Collection $accounts, Carbon $start, Carbon $end): array + protected function accountBalanceChart(Collection $accounts, Carbon $start, Carbon $end): array // chart helper method. { // chart properties for cache: $cache = new CacheProperties(); @@ -506,49 +664,36 @@ class AccountController extends Controller } /** - * Get the budget names from a set of budget ID's. + * Small helper function for the revenue and expense account charts. * - * @param array $budgetIds + * @param array $names * * @return array */ - private function getBudgetNames(array $budgetIds): array + private function expandNames(array $names): array { - /** @var BudgetRepositoryInterface $repository */ - $repository = app(BudgetRepositoryInterface::class); - $budgets = $repository->getBudgets(); - $grouped = $budgets->groupBy('id')->toArray(); - $return = []; - foreach ($budgetIds as $budgetId) { - if (isset($grouped[$budgetId])) { - $return[$budgetId] = $grouped[$budgetId][0]['name']; - } + $result = []; + foreach ($names as $entry) { + $result[$entry['name']] = 0; } - $return[0] = (string)trans('firefly.no_budget'); - return $return; + return $result; } /** - * Get the category names from a set of category ID's. Small helper function for some of the charts. + * Small helper function for the revenue and expense account charts. * - * @param array $categoryIds + * @param Collection $accounts * * @return array */ - private function getCategoryNames(array $categoryIds): array + private function extractNames(Collection $accounts): array { - /** @var CategoryRepositoryInterface $repository */ - $repository = app(CategoryRepositoryInterface::class); - $categories = $repository->getCategories(); - $grouped = $categories->groupBy('id')->toArray(); - $return = []; - foreach ($categoryIds as $categoryId) { - if (isset($grouped[$categoryId])) { - $return[$categoryId] = $grouped[$categoryId][0]['name']; - } + $return = []; + /** @var Account $account */ + foreach ($accounts as $account) { + $return[$account->id] = $account->name; } - $return[0] = (string)trans('firefly.noCategory'); return $return; } diff --git a/app/Http/Controllers/Chart/BillController.php b/app/Http/Controllers/Chart/BillController.php index ab4fb05f79..3472335bec 100644 --- a/app/Http/Controllers/Chart/BillController.php +++ b/app/Http/Controllers/Chart/BillController.php @@ -24,11 +24,12 @@ namespace FireflyIII\Http\Controllers\Chart; use Carbon\Carbon; use FireflyIII\Generator\Chart\Basic\GeneratorInterface; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Models\Bill; use FireflyIII\Models\Transaction; use FireflyIII\Repositories\Bill\BillRepositoryInterface; +use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface; use FireflyIII\Support\CacheProperties; use Illuminate\Http\JsonResponse; use Illuminate\Support\Collection; @@ -67,17 +68,28 @@ class BillController extends Controller $cache->addProperty($end); $cache->addProperty('chart.bill.frontpage'); if ($cache->has()) { - return response()->json($cache->get()); // @codeCoverageIgnore + //return response()->json($cache->get()); // @codeCoverageIgnore + } + /** @var CurrencyRepositoryInterface $currencyRepository */ + $currencyRepository = app(CurrencyRepositoryInterface::class); + + $chartData = []; + $currencies = []; + $paid = $repository->getBillsPaidInRangePerCurrency($start, $end); // will be a negative amount. + $unpaid = $repository->getBillsUnpaidInRangePerCurrency($start, $end); // will be a positive amount. + + foreach ($paid as $currencyId => $amount) { + $currencies[$currencyId] = $currencies[$currencyId] ?? $currencyRepository->findNull($currencyId); + $label = (string)trans('firefly.paid_in_currency', ['currency' => $currencies[$currencyId]->name]); + $chartData[$label] = ['amount' => $amount, 'currency_symbol' => $currencies[$currencyId]->symbol]; + } + foreach ($unpaid as $currencyId => $amount) { + $currencies[$currencyId] = $currencies[$currencyId] ?? $currencyRepository->findNull($currencyId); + $label = (string)trans('firefly.unpaid_in_currency', ['currency' => $currencies[$currencyId]->name]); + $chartData[$label] = ['amount' => $amount, 'currency_symbol' => $currencies[$currencyId]->symbol]; } - $paid = $repository->getBillsPaidInRange($start, $end); // will be a negative amount. - $unpaid = $repository->getBillsUnpaidInRange($start, $end); // will be a positive amount. - $chartData = [ - (string)trans('firefly.unpaid') => $unpaid, - (string)trans('firefly.paid') => $paid, - ]; - - $data = $this->generator->pieChart($chartData); + $data = $this->generator->multiCurrencyPieChart($chartData); $cache->store($data); return response()->json($data); @@ -87,12 +99,12 @@ class BillController extends Controller /** * Shows overview for a single bill. * - * @param JournalCollectorInterface $collector - * @param Bill $bill + * @param TransactionCollectorInterface $collector + * @param Bill $bill * * @return JsonResponse */ - public function single(JournalCollectorInterface $collector, Bill $bill): JsonResponse + public function single(TransactionCollectorInterface $collector, Bill $bill): JsonResponse { $cache = new CacheProperties; $cache->addProperty('chart.bill.single'); @@ -101,16 +113,17 @@ class BillController extends Controller return response()->json($cache->get()); // @codeCoverageIgnore } - $results = $collector->setAllAssetAccounts()->setBills(new Collection([$bill]))->getJournals(); - $results = $results->sortBy( + $results = $collector->setAllAssetAccounts()->setBills(new Collection([$bill]))->getTransactions(); + /** @var Collection $results */ + $results = $results->sortBy( function (Transaction $transaction) { return $transaction->date->format('U'); } ); $chartData = [ - ['type' => 'bar', 'label' => (string)trans('firefly.min-amount'), 'entries' => []], - ['type' => 'bar', 'label' => (string)trans('firefly.max-amount'), 'entries' => []], - ['type' => 'line', 'label' => (string)trans('firefly.journal-amount'), 'entries' => []], + ['type' => 'bar', 'label' => (string)trans('firefly.min-amount'), 'currency_symbol' => $bill->transactionCurrency->symbol, 'entries' => []], + ['type' => 'bar', 'label' => (string)trans('firefly.max-amount'), 'currency_symbol' => $bill->transactionCurrency->symbol, 'entries' => []], + ['type' => 'line', 'label' => (string)trans('firefly.journal-amount'), 'currency_symbol' => $bill->transactionCurrency->symbol, 'entries' => []], ]; /** @var Transaction $entry */ diff --git a/app/Http/Controllers/Chart/BudgetController.php b/app/Http/Controllers/Chart/BudgetController.php index 801be0814c..639e56f1e8 100644 --- a/app/Http/Controllers/Chart/BudgetController.php +++ b/app/Http/Controllers/Chart/BudgetController.php @@ -25,17 +25,15 @@ namespace FireflyIII\Http\Controllers\Chart; use Carbon\Carbon; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Generator\Chart\Basic\GeneratorInterface; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Http\Controllers\Controller; -use FireflyIII\Models\AccountType; use FireflyIII\Models\Budget; use FireflyIII\Models\BudgetLimit; use FireflyIII\Models\Transaction; use FireflyIII\Models\TransactionType; -use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; -use FireflyIII\Repositories\Category\CategoryRepositoryInterface; use FireflyIII\Support\CacheProperties; +use FireflyIII\Support\Http\Controllers\AugumentData; use FireflyIII\Support\Http\Controllers\DateCalculation; use Illuminate\Http\JsonResponse; use Illuminate\Support\Collection; @@ -49,7 +47,7 @@ use Illuminate\Support\Collection; */ class BudgetController extends Controller { - use DateCalculation; + use DateCalculation, AugumentData; /** @var GeneratorInterface Chart generation methods. */ protected $generator; @@ -77,6 +75,8 @@ class BudgetController extends Controller /** * Shows overview of a single budget. * + * TODO this chart is not multi-currency aware. + * * @param Budget $budget * * @return JsonResponse @@ -126,6 +126,8 @@ class BudgetController extends Controller /** * Shows the amount left in a specific budget limit. * + * TODO this chart is not multi-currency aware. + * * @param Budget $budget * @param BudgetLimit $budgetLimit * @@ -173,6 +175,8 @@ class BudgetController extends Controller /** * Shows how much is spent per asset account. * + * TODO this chart is not multi-currency aware. + * * @param Budget $budget * @param BudgetLimit|null $budgetLimit * @@ -191,14 +195,14 @@ class BudgetController extends Controller return response()->json($cache->get()); // @codeCoverageIgnore } - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setBudget($budget); if (null !== $budgetLimit) { $collector->setRange($budgetLimit->start_date, $budgetLimit->end_date); } - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); $result = []; $chartData = []; /** @var Transaction $transaction */ @@ -223,6 +227,8 @@ class BudgetController extends Controller /** * Shows how much is spent per category. * + * TODO this chart is not multi-currency aware. + * * @param Budget $budget * @param BudgetLimit|null $budgetLimit * @@ -241,14 +247,14 @@ class BudgetController extends Controller return response()->json($cache->get()); // @codeCoverageIgnore } - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setBudget($budget)->withCategoryInformation(); if (null !== $budgetLimit) { $collector->setRange($budgetLimit->start_date, $budgetLimit->end_date); } - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); $result = []; $chartData = []; /** @var Transaction $transaction */ @@ -274,6 +280,8 @@ class BudgetController extends Controller /** * Shows how much is spent per expense account. * + * TODO this chart is not multi-currency aware. + * * @param Budget $budget * @param BudgetLimit|null $budgetLimit * @@ -292,14 +300,14 @@ class BudgetController extends Controller return response()->json($cache->get()); // @codeCoverageIgnore } - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setTypes([TransactionType::WITHDRAWAL])->setBudget($budget)->withOpposingAccount(); if (null !== $budgetLimit) { $collector->setRange($budgetLimit->start_date, $budgetLimit->end_date); } - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); $result = []; $chartData = []; /** @var Transaction $transaction */ @@ -325,12 +333,14 @@ class BudgetController extends Controller /** * Shows a budget list with spent/left/overspent. * - * @return \Symfony\Component\HttpFoundation\Response + * TODO this chart is not multi-currency aware. + * + * @return JsonResponse * * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - public function frontpage(): \Symfony\Component\HttpFoundation\Response + public function frontpage(): JsonResponse { $start = session('start', Carbon::now()->startOfMonth()); $end = session('end', Carbon::now()->endOfMonth()); @@ -380,6 +390,8 @@ class BudgetController extends Controller /** * Shows a budget overview chart (spent and budgeted). * + * TODO this chart is not multi-currency aware. + * * @param Budget $budget * @param Carbon $start * @param Carbon $end @@ -426,6 +438,8 @@ class BudgetController extends Controller /** * Shows a chart for transactions without a budget. * + * TODO this chart is not multi-currency aware. + * * @param Collection $accounts * @param Carbon $start * @param Carbon $end @@ -461,29 +475,6 @@ class BudgetController extends Controller return response()->json($data); } - /** - * Get the account names belonging to a bunch of account ID's. - * - * @param array $accountIds - * - * @return array - */ - private function getAccountNames(array $accountIds): array - { - /** @var AccountRepositoryInterface $repository */ - $repository = app(AccountRepositoryInterface::class); - $accounts = $repository->getAccountsByType([AccountType::ASSET, AccountType::DEFAULT, AccountType::EXPENSE, AccountType::CASH]); - $grouped = $accounts->groupBy('id')->toArray(); - $return = []; - foreach ($accountIds as $accountId) { - if (isset($grouped[$accountId])) { - $return[$accountId] = $grouped[$accountId][0]['name']; - } - } - $return[0] = '(no name)'; - - return $return; - } /** * Get the amount of money budgeted in a period. @@ -494,7 +485,7 @@ class BudgetController extends Controller * * @return array */ - private function getBudgetedInPeriod(Budget $budget, Carbon $start, Carbon $end): array + protected function getBudgetedInPeriod(Budget $budget, Carbon $start, Carbon $end): array // get data + augment with info { $key = app('navigation')->preferredCarbonFormat($start, $end); $range = app('navigation')->preferredRangeFormat($start, $end); @@ -515,31 +506,7 @@ class BudgetController extends Controller return $budgeted; } - /** - * Small helper function for some of the charts. Extracts category names from a bunch of ID's. - * - * @param array $categoryIds - * - * @return array - */ - private function getCategoryNames(array $categoryIds): array - { - /** @var CategoryRepositoryInterface $repository */ - $repository = app(CategoryRepositoryInterface::class); - $categories = $repository->getCategories(); - $grouped = $categories->groupBy('id')->toArray(); - $return = []; - foreach ($categoryIds as $categoryId) { - if (isset($grouped[$categoryId])) { - $return[$categoryId] = $grouped[$categoryId][0]['name']; - } - } - $return[0] = (string)trans('firefly.noCategory'); - return $return; - } - - /** @noinspection MoreThanThreeArgumentsInspection */ /** * Get the expenses for a budget in a date range. * @@ -552,7 +519,7 @@ class BudgetController extends Controller * * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ - private function getExpensesForBudget(Collection $limits, Budget $budget, Carbon $start, Carbon $end): array + protected function getExpensesForBudget(Collection $limits, Budget $budget, Carbon $start, Carbon $end): array // get data + augment with info { $return = []; if (0 === $limits->count()) { @@ -595,7 +562,7 @@ class BudgetController extends Controller * @SuppressWarnings(PHPMD.CyclomaticComplexity) * */ - private function spentInPeriodMulti(Budget $budget, Collection $limits): array + protected function spentInPeriodMulti(Budget $budget, Collection $limits): array // get data + augment with info { $return = []; $format = (string)trans('config.month_and_day'); @@ -643,17 +610,17 @@ class BudgetController extends Controller * * @return string */ - private function spentInPeriodWithout(Carbon $start, Carbon $end): string + protected function spentInPeriodWithout(Carbon $start, Carbon $end): string // get data + augment with info { // collector - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $types = [TransactionType::WITHDRAWAL]; $collector->setAllAssetAccounts()->setTypes($types)->setRange($start, $end)->withoutBudget(); - $journals = $collector->getJournals(); + $transactions = $collector->getTransactions(); $sum = '0'; /** @var Transaction $entry */ - foreach ($journals as $entry) { + foreach ($transactions as $entry) { $sum = bcadd($entry->transaction_amount, $sum); } diff --git a/app/Http/Controllers/Chart/BudgetReportController.php b/app/Http/Controllers/Chart/BudgetReportController.php index 7bfbcaff22..3879ff540c 100644 --- a/app/Http/Controllers/Chart/BudgetReportController.php +++ b/app/Http/Controllers/Chart/BudgetReportController.php @@ -25,17 +25,12 @@ namespace FireflyIII\Http\Controllers\Chart; use Carbon\Carbon; use FireflyIII\Generator\Chart\Basic\GeneratorInterface; use FireflyIII\Helpers\Chart\MetaPieChartInterface; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; -use FireflyIII\Helpers\Filter\OpposingAccountFilter; -use FireflyIII\Helpers\Filter\PositiveAmountFilter; -use FireflyIII\Helpers\Filter\TransferFilter; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Models\Budget; -use FireflyIII\Models\BudgetLimit; -use FireflyIII\Models\Transaction; -use FireflyIII\Models\TransactionType; use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; use FireflyIII\Support\CacheProperties; +use FireflyIII\Support\Http\Controllers\AugumentData; +use FireflyIII\Support\Http\Controllers\TransactionCalculation; use Illuminate\Http\JsonResponse; use Illuminate\Support\Collection; @@ -48,6 +43,7 @@ use Illuminate\Support\Collection; */ class BudgetReportController extends Controller { + use AugumentData, TransactionCalculation; /** @var BudgetRepositoryInterface The budget repository */ private $budgetRepository; /** @var GeneratorInterface Chart generation methods. */ @@ -73,6 +69,8 @@ class BudgetReportController extends Controller /** * Chart that groups expenses by the account. * + * TODO this chart is not multi-currency aware. + * * @param Collection $accounts * @param Collection $budgets * @param Carbon $start @@ -102,6 +100,8 @@ class BudgetReportController extends Controller /** * Chart that groups the expenses by budget. * + * TODO this chart is not multi-currency aware. + * * @param Collection $accounts * @param Collection $budgets * @param Carbon $start @@ -131,6 +131,8 @@ class BudgetReportController extends Controller /** * Main overview of a budget in the budget report. * + * TODO this chart is not multi-currency aware. + * * @param Collection $accounts * @param Collection $budgets * @param Carbon $start @@ -186,7 +188,7 @@ class BudgetReportController extends Controller while ($currentStart < $end) { $currentEnd = clone $currentStart; $currentEnd = $currentEnd->$function(); - $expenses = $this->groupByBudget($this->getExpenses($accounts, $budgets, $currentStart, $currentEnd)); + $expenses = $this->groupByBudget($this->getExpensesInBudgets($accounts, $budgets, $currentStart, $currentEnd)); $label = $currentStart->formatLocalized($format); /** @var Budget $budget */ @@ -217,80 +219,4 @@ class BudgetReportController extends Controller return response()->json($data); } - /** @noinspection MoreThanThreeArgumentsInspection */ - /** - * Returns the budget limits belonging to the given budget and valid on the given day. - * - * @param Collection $budgetLimits - * @param Budget $budget - * @param Carbon $start - * @param Carbon $end - * - * @return Collection - */ - private function filterBudgetLimits(Collection $budgetLimits, Budget $budget, Carbon $start, Carbon $end): Collection - { - $set = $budgetLimits->filter( - function (BudgetLimit $budgetLimit) use ($budget, $start, $end) { - if ($budgetLimit->budget_id === $budget->id - && $budgetLimit->start_date->lte($start) // start of budget limit is on or before start - && $budgetLimit->end_date->gte($end) // end of budget limit is on or after end - ) { - return $budgetLimit; - } - - return false; - } - ); - - return $set; - } - - /** @noinspection MoreThanThreeArgumentsInspection */ - /** - * Helper function that collects expenses for the given budgets. - * - * @param Collection $accounts - * @param Collection $budgets - * @param Carbon $start - * @param Carbon $end - * - * @return Collection - */ - private function getExpenses(Collection $accounts, Collection $budgets, Carbon $start, Carbon $end): Collection - { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); - $collector->setAccounts($accounts)->setRange($start, $end)->setTypes([TransactionType::WITHDRAWAL, TransactionType::TRANSFER]) - ->setBudgets($budgets)->withOpposingAccount(); - $collector->removeFilter(TransferFilter::class); - - $collector->addFilter(OpposingAccountFilter::class); - $collector->addFilter(PositiveAmountFilter::class); - - return $collector->getJournals(); - } - - /** - * Helper function that groups expenses. - * - * @param Collection $set - * - * @return array - */ - private function groupByBudget(Collection $set): array - { - // group by category ID: - $grouped = []; - /** @var Transaction $transaction */ - foreach ($set as $transaction) { - $jrnlBudId = (int)$transaction->transaction_journal_budget_id; - $transBudId = (int)$transaction->transaction_budget_id; - $budgetId = max($jrnlBudId, $transBudId); - $grouped[$budgetId] = $grouped[$budgetId] ?? '0'; - $grouped[$budgetId] = bcadd($transaction->transaction_amount, $grouped[$budgetId]); - } - - return $grouped; - } } diff --git a/app/Http/Controllers/Chart/CategoryController.php b/app/Http/Controllers/Chart/CategoryController.php index 458e0fb014..7e6a17eefd 100644 --- a/app/Http/Controllers/Chart/CategoryController.php +++ b/app/Http/Controllers/Chart/CategoryController.php @@ -27,8 +27,10 @@ use FireflyIII\Generator\Chart\Basic\GeneratorInterface; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Models\AccountType; use FireflyIII\Models\Category; +use FireflyIII\Models\TransactionCurrency; use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Repositories\Category\CategoryRepositoryInterface; +use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface; use FireflyIII\Support\CacheProperties; use Illuminate\Http\JsonResponse; use Illuminate\Support\Collection; @@ -55,6 +57,8 @@ class CategoryController extends Controller /** * Show an overview for a category for all time, per month/week/year. * + * TODO this chart is not multi-currency aware. + * * @param CategoryRepositoryInterface $repository * @param AccountRepositoryInterface $accountRepository * @param Category $category @@ -79,12 +83,14 @@ class CategoryController extends Controller $accounts = $accountRepository->getAccountsByType([AccountType::DEFAULT, AccountType::ASSET]); $chartData = [ [ - 'label' => (string)trans('firefly.spent'), - 'entries' => [], 'type' => 'bar', + 'label' => (string)trans('firefly.spent'), + 'entries' => [], 'type' => 'bar', + 'backgroundColor' => 'rgba(219, 68, 55, 0.5)', // red ], [ - 'label' => (string)trans('firefly.earned'), - 'entries' => [], 'type' => 'bar', + 'label' => (string)trans('firefly.earned'), + 'entries' => [], 'type' => 'bar', + 'backgroundColor' => 'rgba(0, 141, 76, 0.5)', // green ], [ 'label' => (string)trans('firefly.sum'), @@ -129,35 +135,82 @@ class CategoryController extends Controller $cache->addProperty($end); $cache->addProperty('chart.category.frontpage'); if ($cache->has()) { - return response()->json($cache->get()); // @codeCoverageIgnore + //return response()->json($cache->get()); // @codeCoverageIgnore } + + // currency repos: + /** @var CurrencyRepositoryInterface $currencyRepository */ + $currencyRepository = app(CurrencyRepositoryInterface::class); + $currencies = []; + + $chartData = []; + $tempData = []; $categories = $repository->getCategories(); $accounts = $accountRepository->getAccountsByType([AccountType::ASSET, AccountType::DEFAULT]); + /** @var Category $category */ foreach ($categories as $category) { - $spent = $repository->spentInPeriod(new Collection([$category]), $accounts, $start, $end); - if (bccomp($spent, '0') === -1) { - $chartData[$category->name] = bcmul($spent, '-1'); + $spentArray = $repository->spentInPeriodPerCurrency(new Collection([$category]), $accounts, $start, $end); + foreach ($spentArray as $currencyId => $spent) { + if (bccomp($spent, '0') === -1) { + $currencies[$currencyId] = $currencies[$currencyId] ?? $currencyRepository->findNull($currencyId); + $tempData[] = [ + 'name' => $category->name, + 'spent' => bcmul($spent, '-1'), + 'spent_float' => (float)bcmul($spent, '-1'), + 'currency_id' => $currencyId, + ]; + } } } + // no category per currency: + $noCategory = $repository->spentInPeriodPcWoCategory(new Collection, $start, $end); + foreach ($noCategory as $currencyId => $spent) { + $currencies[$currencyId] = $currencies[$currencyId] ?? $currencyRepository->findNull($currencyId); + $tempData[] = [ + 'name' => trans('firefly.no_category'), + 'spent' => bcmul($spent, '-1'), + 'spent_float' => (float)bcmul($spent, '-1'), + 'currency_id' => $currencyId, + ]; + } - $chartData[(string)trans('firefly.no_category')] = bcmul($repository->spentInPeriodWithoutCategory(new Collection, $start, $end), '-1'); + // sort temp array by amount. + $amounts = array_column($tempData, 'spent_float'); + array_multisort($amounts, SORT_DESC, $tempData); - // sort - arsort($chartData); - - $data = $this->generator->singleSet((string)trans('firefly.spent'), $chartData); + // loop all found currencies and build the data array for the chart. + /** + * @var int $currencyId + * @var TransactionCurrency $currency + */ + foreach ($currencies as $currencyId => $currency) { + $dataSet = [ + 'label' => (string)trans('firefly.spent'), + 'type' => 'bar', + 'currency_symbol' => $currency->symbol, + 'entries' => $this->expandNames($tempData), + ]; + $chartData[$currencyId] = $dataSet; + } + // loop temp data and place data in correct array: + foreach ($tempData as $entry) { + $currencyId = $entry['currency_id']; + $name = $entry['name']; + $chartData[$currencyId]['entries'][$name] = $entry['spent']; + } + $data = $this->generator->multiSet($chartData); $cache->store($data); return response()->json($data); } - - /** @noinspection MoreThanThreeArgumentsInspection */ /** * Chart report. * + * TODO this chart is not multi-currency aware. + * * @param Category $category * @param Collection $accounts * @param Carbon $start @@ -184,14 +237,16 @@ class CategoryController extends Controller $periods = app('navigation')->listOfPeriods($start, $end); $chartData = [ [ - 'label' => (string)trans('firefly.spent'), - 'entries' => [], - 'type' => 'bar', + 'label' => (string)trans('firefly.spent'), + 'entries' => [], + 'type' => 'bar', + 'backgroundColor' => 'rgba(219, 68, 55, 0.5)', // red ], [ - 'label' => (string)trans('firefly.earned'), - 'entries' => [], - 'type' => 'bar', + 'label' => (string)trans('firefly.earned'), + 'entries' => [], + 'type' => 'bar', + 'backgroundColor' => 'rgba(0, 141, 76, 0.5)', // green ], [ 'label' => (string)trans('firefly.sum'), @@ -218,9 +273,13 @@ class CategoryController extends Controller } + /** @noinspection MoreThanThreeArgumentsInspection */ + /** * Chart for period for transactions without a category. * + * TODO this chart is not multi-currency aware. + * * @param Collection $accounts * @param Carbon $start * @param Carbon $end @@ -245,14 +304,16 @@ class CategoryController extends Controller $periods = app('navigation')->listOfPeriods($start, $end); $chartData = [ [ - 'label' => (string)trans('firefly.spent'), - 'entries' => [], - 'type' => 'bar', + 'label' => (string)trans('firefly.spent'), + 'entries' => [], + 'type' => 'bar', + 'backgroundColor' => 'rgba(219, 68, 55, 0.5)', // red ], [ - 'label' => (string)trans('firefly.earned'), - 'entries' => [], - 'type' => 'bar', + 'label' => (string)trans('firefly.earned'), + 'entries' => [], + 'type' => 'bar', + 'backgroundColor' => 'rgba(0, 141, 76, 0.5)', // green ], [ 'label' => (string)trans('firefly.sum'), @@ -280,6 +341,8 @@ class CategoryController extends Controller /** * Chart for a specific period. * + * TODO this chart is not multi-currency aware. + * * @param Category $category * @param $date * @@ -295,10 +358,10 @@ class CategoryController extends Controller return response()->json($data); } - /** * Chart for a specific period (start and end). * + * * @param Category $category * @param Carbon $start * @param Carbon $end @@ -307,7 +370,7 @@ class CategoryController extends Controller * * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - private function makePeriodChart(Category $category, Carbon $start, Carbon $end): array + protected function makePeriodChart(Category $category, Carbon $start, Carbon $end): array // chart helper method. { $cache = new CacheProperties; $cache->addProperty($start); @@ -328,14 +391,16 @@ class CategoryController extends Controller // chart data $chartData = [ [ - 'label' => (string)trans('firefly.spent'), - 'entries' => [], - 'type' => 'bar', + 'label' => (string)trans('firefly.spent'), + 'entries' => [], + 'type' => 'bar', + 'backgroundColor' => 'rgba(219, 68, 55, 0.5)', // red ], [ - 'label' => (string)trans('firefly.earned'), - 'entries' => [], - 'type' => 'bar', + 'label' => (string)trans('firefly.earned'), + 'entries' => [], + 'type' => 'bar', + 'backgroundColor' => 'rgba(0, 141, 76, 0.5)', // green ], [ 'label' => (string)trans('firefly.sum'), @@ -363,4 +428,21 @@ class CategoryController extends Controller return $data; } + + /** + * Small helper function for the revenue and expense account charts. + * + * @param array $names + * + * @return array + */ + private function expandNames(array $names): array + { + $result = []; + foreach ($names as $entry) { + $result[$entry['name']] = 0; + } + + return $result; + } } diff --git a/app/Http/Controllers/Chart/CategoryReportController.php b/app/Http/Controllers/Chart/CategoryReportController.php index a5b362e320..4ff0e3b465 100644 --- a/app/Http/Controllers/Chart/CategoryReportController.php +++ b/app/Http/Controllers/Chart/CategoryReportController.php @@ -25,16 +25,11 @@ namespace FireflyIII\Http\Controllers\Chart; use Carbon\Carbon; use FireflyIII\Generator\Chart\Basic\GeneratorInterface; use FireflyIII\Helpers\Chart\MetaPieChartInterface; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; -use FireflyIII\Helpers\Filter\NegativeAmountFilter; -use FireflyIII\Helpers\Filter\OpposingAccountFilter; -use FireflyIII\Helpers\Filter\PositiveAmountFilter; -use FireflyIII\Helpers\Filter\TransferFilter; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Models\Category; -use FireflyIII\Models\Transaction; -use FireflyIII\Models\TransactionType; use FireflyIII\Support\CacheProperties; +use FireflyIII\Support\Http\Controllers\AugumentData; +use FireflyIII\Support\Http\Controllers\TransactionCalculation; use Illuminate\Http\JsonResponse; use Illuminate\Support\Collection; @@ -45,6 +40,8 @@ use Illuminate\Support\Collection; */ class CategoryReportController extends Controller { + use AugumentData, TransactionCalculation; + /** @var GeneratorInterface Chart generation methods. */ private $generator; @@ -67,6 +64,8 @@ class CategoryReportController extends Controller /** * Chart for expenses grouped by expense account. * + * TODO this chart is not multi-currency aware. + * * @param Collection $accounts * @param Collection $categories * @param Carbon $start @@ -93,6 +92,8 @@ class CategoryReportController extends Controller /** * Chart for income grouped by revenue account. * + * TODO this chart is not multi-currency aware. + * * @param Collection $accounts * @param Collection $categories * @param Carbon $start @@ -122,6 +123,8 @@ class CategoryReportController extends Controller /** * Chart for expenses grouped by expense account. * + * TODO this chart is not multi-currency aware. + * * @param Collection $accounts * @param Collection $categories * @param Carbon $start @@ -151,6 +154,8 @@ class CategoryReportController extends Controller /** * Piechart for income grouped by account. * + * TODO this chart is not multi-currency aware. + * * @param Collection $accounts * @param Collection $categories * @param Carbon $start @@ -180,6 +185,8 @@ class CategoryReportController extends Controller /** * Main report category chart. * + * TODO this chart is not multi-currency aware. + * * @param Collection $accounts * @param Collection $categories * @param Carbon $start @@ -244,8 +251,8 @@ class CategoryReportController extends Controller while ($currentStart < $end) { $currentEnd = clone $currentStart; $currentEnd = $currentEnd->$function(); - $expenses = $this->groupByCategory($this->getExpenses($accounts, $categories, $currentStart, $currentEnd)); - $income = $this->groupByCategory($this->getIncome($accounts, $categories, $currentStart, $currentEnd)); + $expenses = $this->groupByCategory($this->getExpensesInCategories($accounts, $categories, $currentStart, $currentEnd)); + $income = $this->groupByCategory($this->getIncomeForCategories($accounts, $categories, $currentStart, $currentEnd)); $label = $currentStart->formatLocalized($format); /** @var Category $category */ @@ -289,77 +296,5 @@ class CategoryReportController extends Controller return response()->json($data); } - /** @noinspection MoreThanThreeArgumentsInspection */ - /** - * Get all expenses in a period for categories. - * - * @param Collection $accounts - * @param Collection $categories - * @param Carbon $start - * @param Carbon $end - * - * @return Collection - * - * - */ - private function getExpenses(Collection $accounts, Collection $categories, Carbon $start, Carbon $end): Collection - { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); - $collector->setAccounts($accounts)->setRange($start, $end)->setTypes([TransactionType::WITHDRAWAL, TransactionType::TRANSFER]) - ->setCategories($categories)->withOpposingAccount(); - $collector->removeFilter(TransferFilter::class); - $collector->addFilter(OpposingAccountFilter::class); - $collector->addFilter(PositiveAmountFilter::class); - - return $collector->getJournals(); - } - - /** @noinspection MoreThanThreeArgumentsInspection */ - /** - * Get all income for a period and a bunch of categories. - * - * @param Collection $accounts - * @param Collection $categories - * @param Carbon $start - * @param Carbon $end - * - * @return Collection - */ - private function getIncome(Collection $accounts, Collection $categories, Carbon $start, Carbon $end): Collection - { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); - $collector->setAccounts($accounts)->setRange($start, $end)->setTypes([TransactionType::DEPOSIT, TransactionType::TRANSFER]) - ->setCategories($categories)->withOpposingAccount(); - - $collector->addFilter(OpposingAccountFilter::class); - $collector->addFilter(NegativeAmountFilter::class); - - return $collector->getJournals(); - } - - /** - * Group transactions by category. - * - * @param Collection $set - * - * @return array - */ - private function groupByCategory(Collection $set): array - { - // group by category ID: - $grouped = []; - /** @var Transaction $transaction */ - foreach ($set as $transaction) { - $jrnlCatId = (int)$transaction->transaction_journal_category_id; - $transCatId = (int)$transaction->transaction_category_id; - $categoryId = max($jrnlCatId, $transCatId); - $grouped[$categoryId] = $grouped[$categoryId] ?? '0'; - $grouped[$categoryId] = bcadd($transaction->transaction_amount, $grouped[$categoryId]); - } - - return $grouped; - } } diff --git a/app/Http/Controllers/Chart/ExpenseReportController.php b/app/Http/Controllers/Chart/ExpenseReportController.php index 31f7d28782..baa6ededed 100644 --- a/app/Http/Controllers/Chart/ExpenseReportController.php +++ b/app/Http/Controllers/Chart/ExpenseReportController.php @@ -25,14 +25,12 @@ namespace FireflyIII\Http\Controllers\Chart; use Carbon\Carbon; use FireflyIII\Generator\Chart\Basic\GeneratorInterface; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Models\Account; -use FireflyIII\Models\AccountType; -use FireflyIII\Models\Transaction; -use FireflyIII\Models\TransactionType; use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Support\CacheProperties; +use FireflyIII\Support\Http\Controllers\AugumentData; +use FireflyIII\Support\Http\Controllers\TransactionCalculation; use Illuminate\Http\JsonResponse; use Illuminate\Support\Collection; @@ -43,6 +41,7 @@ use Illuminate\Support\Collection; */ class ExpenseReportController extends Controller { + use AugumentData, TransactionCalculation; /** @var AccountRepositoryInterface The account repository */ protected $accountRepository; /** @var GeneratorInterface Chart generation methods. */ @@ -68,6 +67,8 @@ class ExpenseReportController extends Controller /** * Main chart that shows income and expense for a combination of expense/revenue accounts. * + * TODO this chart is not multi-currency aware. + * * @param Collection $accounts * @param Collection $expense * @param Carbon $start @@ -104,6 +105,10 @@ class ExpenseReportController extends Controller } // prep chart data: + /** + * @var string $name + * @var Collection $combi + */ foreach ($combined as $name => $combi) { // first is always expense account: /** @var Account $exp */ @@ -145,8 +150,8 @@ class ExpenseReportController extends Controller $currentEnd = $currentEnd->$function(); // get expenses grouped by opposing name: - $expenses = $this->groupByName($this->getExpenses($accounts, $all, $currentStart, $currentEnd)); - $income = $this->groupByName($this->getIncome($accounts, $all, $currentStart, $currentEnd)); + $expenses = $this->groupByName($this->getExpensesForOpposing($accounts, $all, $currentStart, $currentEnd)); + $income = $this->groupByName($this->getIncomeForOpposing($accounts, $all, $currentStart, $currentEnd)); $label = $currentStart->formatLocalized($format); foreach ($combined as $name => $combi) { @@ -191,89 +196,4 @@ class ExpenseReportController extends Controller return response()->json($data); } - - /** - * Searches for the opposing account. - * - * @param Collection $accounts - * - * @return array - */ - protected function combineAccounts(Collection $accounts): array - { - $combined = []; - /** @var Account $expenseAccount */ - foreach ($accounts as $expenseAccount) { - $collection = new Collection; - $collection->push($expenseAccount); - - $revenue = $this->accountRepository->findByName($expenseAccount->name, [AccountType::REVENUE]); - if (null !== $revenue) { - $collection->push($revenue); - } - $combined[$expenseAccount->name] = $collection; - } - - return $combined; - } - - /** - * Get all expenses for a set of accounts. - * - * @param Collection $accounts - * @param Collection $opposing - * @param Carbon $start - * @param Carbon $end - * - * @return Collection - */ - private function getExpenses(Collection $accounts, Collection $opposing, Carbon $start, Carbon $end): Collection - { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); - $collector->setAccounts($accounts)->setRange($start, $end)->setTypes([TransactionType::WITHDRAWAL])->setOpposingAccounts($opposing); - - return $collector->getJournals(); - } - - /** - * Get the income for a set of accounts. - * - * @param Collection $accounts - * @param Collection $opposing - * @param Carbon $start - * @param Carbon $end - * - * @return Collection - */ - private function getIncome(Collection $accounts, Collection $opposing, Carbon $start, Carbon $end): Collection - { - /** @var JournalCollectorInterface $collector */ - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); - $collector->setAccounts($accounts)->setRange($start, $end)->setTypes([TransactionType::DEPOSIT])->setOpposingAccounts($opposing); - - return $collector->getJournals(); - } - - /** - * Group set of transactions by name of opposing account. - * - * @param Collection $set - * - * @return array - */ - private function groupByName(Collection $set): array - { - // group by opposing account name. - $grouped = []; - /** @var Transaction $transaction */ - foreach ($set as $transaction) { - $name = $transaction->opposing_account_name; - $grouped[$name] = $grouped[$name] ?? '0'; - $grouped[$name] = bcadd($transaction->transaction_amount, $grouped[$name]); - } - - return $grouped; - } } diff --git a/app/Http/Controllers/Chart/PiggyBankController.php b/app/Http/Controllers/Chart/PiggyBankController.php index a22122839b..b67a057b4c 100644 --- a/app/Http/Controllers/Chart/PiggyBankController.php +++ b/app/Http/Controllers/Chart/PiggyBankController.php @@ -55,6 +55,8 @@ class PiggyBankController extends Controller /** * Shows the piggy bank history. * + * TODO this chart is not multi-currency aware. + * * @param PiggyBankRepositoryInterface $repository * @param PiggyBank $piggyBank * diff --git a/app/Http/Controllers/Chart/ReportController.php b/app/Http/Controllers/Chart/ReportController.php index bfba313d82..b13125d2da 100644 --- a/app/Http/Controllers/Chart/ReportController.php +++ b/app/Http/Controllers/Chart/ReportController.php @@ -24,9 +24,13 @@ namespace FireflyIII\Http\Controllers\Chart; use Carbon\Carbon; use FireflyIII\Generator\Chart\Basic\GeneratorInterface; +use FireflyIII\Helpers\Report\NetWorthInterface; use FireflyIII\Http\Controllers\Controller; +use FireflyIII\Models\Account; +use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Repositories\Account\AccountTaskerInterface; use FireflyIII\Support\CacheProperties; +use FireflyIII\Support\Http\Controllers\BasicDataSupport; use Illuminate\Http\JsonResponse; use Illuminate\Support\Collection; use Log; @@ -36,6 +40,7 @@ use Log; */ class ReportController extends Controller { + use BasicDataSupport; /** @var GeneratorInterface Chart generation methods. */ protected $generator; @@ -65,22 +70,58 @@ class ReportController extends Controller $cache = new CacheProperties; $cache->addProperty('chart.report.net-worth'); $cache->addProperty($start); - $cache->addProperty($accounts); + $cache->addProperty(implode(',', $accounts->pluck('id')->toArray())); $cache->addProperty($end); if ($cache->has()) { return response()->json($cache->get()); // @codeCoverageIgnore } $current = clone $start; $chartData = []; + /** @var NetWorthInterface $helper */ + $helper = app(NetWorthInterface::class); + $helper->setUser(auth()->user()); + + // filter accounts on having the preference for being included. + /** @var AccountRepositoryInterface $accountRepository */ + $accountRepository = app(AccountRepositoryInterface::class); + $filtered = $accounts->filter( + function (Account $account) use ($accountRepository) { + $includeNetWorth = $accountRepository->getMetaValue($account, 'include_net_worth'); + $result = null === $includeNetWorth ? true : '1' === $includeNetWorth; + if (false === $result) { + Log::debug(sprintf('Will not include "%s" in net worth charts.', $account->name)); + } + + return $result; + } + ); + + + while ($current < $end) { - $balances = app('steam')->balancesByAccounts($accounts, $current); - $sum = $this->arraySum($balances); - $label = $current->formatLocalized((string)trans('config.month_and_day')); - $chartData[$label] = $sum; + // get balances by date, grouped by currency. + $result = $helper->getNetWorthByCurrency($filtered, $current); + + // loop result, add to array. + /** @var array $netWorthItem */ + foreach ($result as $netWorthItem) { + $currencyId = $netWorthItem['currency']->id; + $label = $current->formatLocalized((string)trans('config.month_and_day')); + if (!isset($chartData[$currencyId])) { + $chartData[$currencyId] = [ + 'label' => 'Net worth in ' . $netWorthItem['currency']->name, + 'type' => 'line', + 'currency_symbol' => $netWorthItem['currency']->symbol, + 'entries' => [], + ]; + } + $chartData[$currencyId]['entries'][$label] = $netWorthItem['balance']; + + } $current->addDays(7); } - $data = $this->generator->singleSet((string)trans('firefly.net_worth'), $chartData); + $data = $this->generator->multiSet($chartData); $cache->store($data); return response()->json($data); @@ -89,6 +130,8 @@ class ReportController extends Controller /** * Shows income and expense, debit/credit: operations. * + * TODO this chart is not multi-currency aware. + * * @param Collection $accounts * @param Carbon $start * @param Carbon $end @@ -147,6 +190,8 @@ class ReportController extends Controller /** * Shows sum income and expense, debit/credit: operations. * + * TODO this chart is not multi-currency aware. + * * @param Collection $accounts * @param Carbon $start * @param Carbon $end @@ -219,23 +264,6 @@ class ReportController extends Controller return response()->json($data); } - /** - * Sum up an array. - * - * @param $array - * - * @return string - */ - private function arraySum($array): string - { - $sum = '0'; - foreach ($array as $entry) { - $sum = bcadd($sum, $entry); - } - - return $sum; - } - /** * Collects the incomes and expenses for the given periods, grouped per month. Will cache its results. * @@ -247,7 +275,7 @@ class ReportController extends Controller * * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - private function getChartData(Collection $accounts, Carbon $start, Carbon $end): array + protected function getChartData(Collection $accounts, Carbon $start, Carbon $end): array // chart helper function { $cache = new CacheProperties; $cache->addProperty('chart.report.get-chart-data'); diff --git a/app/Http/Controllers/Chart/TagReportController.php b/app/Http/Controllers/Chart/TagReportController.php index c8401452df..6be03027a3 100644 --- a/app/Http/Controllers/Chart/TagReportController.php +++ b/app/Http/Controllers/Chart/TagReportController.php @@ -18,7 +18,6 @@ * You should have received a copy of the GNU General Public License * along with Firefly III. If not, see . */ -/** @noinspection MoreThanThreeArgumentsInspection */ declare(strict_types=1); namespace FireflyIII\Http\Controllers\Chart; @@ -26,16 +25,11 @@ namespace FireflyIII\Http\Controllers\Chart; use Carbon\Carbon; use FireflyIII\Generator\Chart\Basic\GeneratorInterface; use FireflyIII\Helpers\Chart\MetaPieChartInterface; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; -use FireflyIII\Helpers\Filter\NegativeAmountFilter; -use FireflyIII\Helpers\Filter\OpposingAccountFilter; -use FireflyIII\Helpers\Filter\PositiveAmountFilter; -use FireflyIII\Helpers\Filter\TransferFilter; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Models\Tag; -use FireflyIII\Models\Transaction; -use FireflyIII\Models\TransactionType; use FireflyIII\Support\CacheProperties; +use FireflyIII\Support\Http\Controllers\AugumentData; +use FireflyIII\Support\Http\Controllers\TransactionCalculation; use Illuminate\Http\JsonResponse; use Illuminate\Support\Collection; @@ -44,6 +38,7 @@ use Illuminate\Support\Collection; */ class TagReportController extends Controller { + use AugumentData, TransactionCalculation; /** @var GeneratorInterface Chart generation methods. */ protected $generator; @@ -61,6 +56,8 @@ class TagReportController extends Controller /** * Generate expenses for tags grouped on account. * + * TODO this chart is not multi-currency aware. + * * @param Collection $accounts * @param Collection $tags * @param Carbon $start @@ -90,6 +87,8 @@ class TagReportController extends Controller /** * Generate income for tag grouped by account. * + * TODO this chart is not multi-currency aware. + * * @param Collection $accounts * @param Collection $tags * @param Carbon $start @@ -119,6 +118,8 @@ class TagReportController extends Controller /** * Generate expense for tag grouped on budget. * + * TODO this chart is not multi-currency aware. + * * @param Collection $accounts * @param Collection $tags * @param Carbon $start @@ -147,6 +148,8 @@ class TagReportController extends Controller /** * Generate expense for tag grouped on category. * + * TODO this chart is not multi-currency aware. + * * @param Collection $accounts * @param Collection $tags * @param Carbon $start @@ -175,6 +178,8 @@ class TagReportController extends Controller /** * Generate main tag overview chart. * + * TODO this chart is not multi-currency aware. + * * @param Collection $accounts * @param Collection $tags * @param Carbon $start @@ -239,8 +244,8 @@ class TagReportController extends Controller while ($currentStart < $end) { $currentEnd = clone $currentStart; $currentEnd = $currentEnd->$function(); - $expenses = $this->groupByTag($this->getExpenses($accounts, $tags, $currentStart, $currentEnd)); - $income = $this->groupByTag($this->getIncome($accounts, $tags, $currentStart, $currentEnd)); + $expenses = $this->groupByTag($this->getExpensesForTags($accounts, $tags, $currentStart, $currentEnd)); + $income = $this->groupByTag($this->getIncomeForTags($accounts, $tags, $currentStart, $currentEnd)); $label = $currentStart->formatLocalized($format); /** @var Tag $tag */ @@ -288,6 +293,8 @@ class TagReportController extends Controller /** * Show expense grouped by expense account. * + * TODO this chart is not multi-currency aware. + * * @param Collection $accounts * @param Collection $tags * @param Carbon $start @@ -317,6 +324,8 @@ class TagReportController extends Controller /** * Show income grouped by tag. * + * TODO this chart is not multi-currency aware. + * * @param Collection $accounts * @param Collection $tags * @param Carbon $start @@ -341,83 +350,4 @@ class TagReportController extends Controller return response()->json($data); } - - /** @noinspection MoreThanThreeArgumentsInspection */ - /** - * Get all expenses by tags. - * - * @param Collection $accounts - * @param Collection $tags - * @param Carbon $start - * @param Carbon $end - * - * @return Collection - * - * @SuppressWarnings(PHPMD.ExcessiveParameterList) - */ - private function getExpenses(Collection $accounts, Collection $tags, Carbon $start, Carbon $end): Collection - { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); - $collector->setAccounts($accounts)->setRange($start, $end)->setTypes([TransactionType::WITHDRAWAL, TransactionType::TRANSFER]) - ->setTags($tags)->withOpposingAccount(); - $collector->removeFilter(TransferFilter::class); - - $collector->addFilter(OpposingAccountFilter::class); - $collector->addFilter(PositiveAmountFilter::class); - - return $collector->getJournals(); - } - - /** @noinspection MoreThanThreeArgumentsInspection */ - /** - * Get all income by tag. - * - * @param Collection $accounts - * @param Collection $tags - * @param Carbon $start - * @param Carbon $end - * - * @return Collection - * - * @SuppressWarnings(PHPMD.ExcessiveParameterList) - */ - private function getIncome(Collection $accounts, Collection $tags, Carbon $start, Carbon $end): Collection - { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); - $collector->setAccounts($accounts)->setRange($start, $end)->setTypes([TransactionType::DEPOSIT, TransactionType::TRANSFER]) - ->setTags($tags)->withOpposingAccount(); - - $collector->addFilter(OpposingAccountFilter::class); - $collector->addFilter(NegativeAmountFilter::class); - - return $collector->getJournals(); - } - - /** - * Group transactions by tag. - * - * @param Collection $set - * - * @return array - */ - private function groupByTag(Collection $set): array - { - // group by category ID: - $grouped = []; - /** @var Transaction $transaction */ - foreach ($set as $transaction) { - $journal = $transaction->transactionJournal; - $journalTags = $journal->tags; - /** @var Tag $journalTag */ - foreach ($journalTags as $journalTag) { - $journalTagId = $journalTag->id; - $grouped[$journalTagId] = $grouped[$journalTagId] ?? '0'; - $grouped[$journalTagId] = bcadd($transaction->transaction_amount, $grouped[$journalTagId]); - } - } - - return $grouped; - } } diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index a90a31dc8f..9c8ee4fb29 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -23,17 +23,13 @@ declare(strict_types=1); namespace FireflyIII\Http\Controllers; use FireflyConfig; -use FireflyIII\Models\AccountType; -use FireflyIII\Models\Transaction; -use FireflyIII\Models\TransactionJournal; -use FireflyIII\Models\TransactionType; +use FireflyIII\Support\Http\Controllers\RequestInformation; +use FireflyIII\Support\Http\Controllers\UserNavigation; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Bus\DispatchesJobs; use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Routing\Controller as BaseController; -use Log; use Route; -use URL; /** * Class Controller. @@ -42,7 +38,7 @@ use URL; */ class Controller extends BaseController { - use AuthorizesRequests, DispatchesJobs, ValidatesRequests; + use AuthorizesRequests, DispatchesJobs, ValidatesRequests, UserNavigation, RequestInformation; /** @var string Format for date and time. */ protected $dateTimeFormat; @@ -94,132 +90,4 @@ class Controller extends BaseController ); } - /** - * Functionality:. - * - * - If the $identifier contains the word "delete" then a remembered uri with the text "/show/" in it will not be returned but instead the index (/) - * will be returned. - * - If the remembered uri contains "javascript/" the remembered uri will not be returned but instead the index (/) will be returned. - * - * @param string $identifier - * - * @return string - */ - protected function getPreviousUri(string $identifier): string - { - $uri = (string)session($identifier); - if (!(false === strpos($identifier, 'delete')) && !(false === strpos($uri, '/show/'))) { - $uri = $this->redirectUri; - } - if (!(false === strpos($uri, 'jscript'))) { - $uri = $this->redirectUri; // @codeCoverageIgnore - } - - return $uri; - } - - /** - * Is transaction opening balance? - * - * @param TransactionJournal $journal - * - * @return bool - */ - protected function isOpeningBalance(TransactionJournal $journal): bool - { - return TransactionType::OPENING_BALANCE === $journal->transactionType->type; - } - - - /** - * Redirect to asset account that transaction belongs to. - * - * @param TransactionJournal $journal - * - * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector - */ - protected function redirectToAccount(TransactionJournal $journal) - { - $valid = [AccountType::DEFAULT, AccountType::ASSET]; - $transactions = $journal->transactions; - /** @var Transaction $transaction */ - foreach ($transactions as $transaction) { - $account = $transaction->account; - if (\in_array($account->accountType->type, $valid, true)) { - return redirect(route('accounts.show', [$account->id])); - } - } - // @codeCoverageIgnoreStart - session()->flash('error', (string)trans('firefly.cannot_redirect_to_account')); - - return redirect(route('index')); - // @codeCoverageIgnoreEnd - } - - /** - * Remember previous URL. - * - * @param string $identifier - */ - protected function rememberPreviousUri(string $identifier): void - { - session()->put($identifier, URL::previous()); - } - - /** - * Get user's language. - * @return string - */ - private function getLanguage(): string - { - /** @var string $language */ - $language = app('preferences')->get('language', config('firefly.default_language', 'en_US'))->data; - - return $language; - } - - /** - * @return string - */ - private function getPageName(): string - { - return str_replace('.', '_', Route::currentRouteName()); - } - - /** - * Get the specific name of a page for intro. - * - * @return string - */ - private function getSpecificPageName(): string - { - return null === Route::current()->parameter('what') ? '' : '_' . Route::current()->parameter('what'); - } - - /** - * Returns if user has seen demo. - * - * @return bool - */ - private function hasSeenDemo(): bool - { - $page = $this->getPageName(); - $specificPage = $this->getSpecificPageName(); - - // indicator if user has seen the help for this page ( + special page): - $key = 'shown_demo_' . $page . $specificPage; - // is there an intro for this route? - $intro = config('intro.' . $page) ?? []; - $specialIntro = config('intro.' . $page . $specificPage) ?? []; - // some routes have a "what" parameter, which indicates a special page: - - $shownDemo = true; - // both must be array and either must be > 0 - if (\count($intro) > 0 || \count($specialIntro) > 0) { - $shownDemo = app('preferences')->get($key, false)->data; - Log::debug(sprintf('Check if user has already seen intro with key "%s". Result is %d', $key, $shownDemo)); - } - - return $shownDemo; - } } diff --git a/app/Http/Controllers/DebugController.php b/app/Http/Controllers/DebugController.php index 958c51f017..1a8c7ba20a 100644 --- a/app/Http/Controllers/DebugController.php +++ b/app/Http/Controllers/DebugController.php @@ -29,6 +29,7 @@ use DB; use Exception; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Http\Middleware\IsDemoUser; +use FireflyIII\Support\Http\Controllers\GetConfigurationData; use Illuminate\Http\Request; use Illuminate\Routing\Route; use Log; @@ -42,6 +43,8 @@ use Route as RouteFacade; */ class DebugController extends Controller { + use GetConfigurationData; + /** * HomeController constructor. */ @@ -120,7 +123,7 @@ class DebugController extends Controller $phpVersion = str_replace($search, $replace, PHP_VERSION); $phpOs = str_replace($search, $replace, PHP_OS); $interface = PHP_SAPI; - $now = Carbon::create()->format('Y-m-d H:i:s e'); + $now = Carbon::now()->format('Y-m-d H:i:s e'); $extensions = implode(', ', get_loaded_extensions()); $drivers = implode(', ', DB::availableDrivers()); $currentDriver = DB::getDriverName(); @@ -133,8 +136,8 @@ class DebugController extends Controller $errorReporting = $this->errorReporting((int)ini_get('error_reporting')); $appEnv = env('APP_ENV', ''); $appDebug = var_export(env('APP_DEBUG', false), true); - $appLog = env('APP_LOG', ''); - $appLogLevel = env('APP_LOG_LEVEL', ''); + $logChannel = env('LOG_CHANNEL', ''); + $appLogLevel = env('APP_LOG_LEVEL', 'info'); $packages = $this->collectPackages(); $cacheDriver = env('CACHE_DRIVER', 'unknown'); @@ -172,7 +175,7 @@ class DebugController extends Controller return view( 'debug', compact( - 'phpVersion', 'extensions', 'localeAttempts', 'appEnv', 'appDebug', 'appLog', 'appLogLevel', 'now', 'packages', 'drivers', + 'phpVersion', 'extensions', 'localeAttempts', 'appEnv', 'appDebug', 'logChannel', 'appLogLevel', 'now', 'packages', 'drivers', 'currentDriver', 'userAgent', 'displayErrors', 'errorReporting', 'phpOs', 'interface', 'logContent', 'cacheDriver', 'isDocker', 'isSandstorm', 'trustedProxies', @@ -241,54 +244,5 @@ class DebugController extends Controller return redirect(route('home')); } - /** - * Some common combinations. - * - * @param int $value - * - * @return string - */ - protected function errorReporting(int $value): string - { - $array = [ - -1 => 'ALL errors', - E_ALL & ~E_NOTICE & ~E_STRICT & ~E_DEPRECATED => 'E_ALL & ~E_NOTICE & ~E_STRICT & ~E_DEPRECATED', - E_ALL => 'E_ALL', - E_ALL & ~E_DEPRECATED & ~E_STRICT => 'E_ALL & ~E_DEPRECATED & ~E_STRICT', - E_ALL & ~E_NOTICE => 'E_ALL & ~E_NOTICE', - E_ALL & ~E_NOTICE & ~E_STRICT => 'E_ALL & ~E_NOTICE & ~E_STRICT', - E_COMPILE_ERROR | E_RECOVERABLE_ERROR | E_ERROR | E_CORE_ERROR => 'E_COMPILE_ERROR|E_RECOVERABLE_ERROR|E_ERROR|E_CORE_ERROR', - ]; - $result = (string)$value; - if (isset($array[$value])) { - $result = $array[$value]; - } - return $result; - } - - /** - * All packages that are installed. - * - * @return array - */ - private function collectPackages(): array - { - $packages = []; - $file = \dirname(__DIR__, 3) . '/vendor/composer/installed.json'; - if (file_exists($file)) { - // file exists! - $content = file_get_contents($file); - $json = json_decode($content, true); - foreach ($json as $package) { - $packages[] - = [ - 'name' => $package['name'], - 'version' => $package['version'], - ]; - } - } - - return $packages; - } } diff --git a/app/Http/Controllers/ExportController.php b/app/Http/Controllers/ExportController.php index 8426d5b2f7..379702255c 100644 --- a/app/Http/Controllers/ExportController.php +++ b/app/Http/Controllers/ExportController.php @@ -78,7 +78,7 @@ class ExportController extends Controller } $content = $repository->getContent($job); - $job->change('export_downloaded'); + $repository->changeStatus($job, 'export_downloaded'); /** @var LaravelResponse $response */ $response = response($content, 200); $response @@ -125,7 +125,7 @@ class ExportController extends Controller $formats = array_keys(config('firefly.export_formats')); $defaultFormat = app('preferences')->get('export_format', config('firefly.default_export_format'))->data; $first = session('first')->format('Y-m-d'); - $today = Carbon::create()->format('Y-m-d'); + $today = Carbon::now()->format('Y-m-d'); return view('export.index', compact('job', 'formats', 'defaultFormat', 'first', 'today')); } diff --git a/app/Http/Controllers/HelpController.php b/app/Http/Controllers/HelpController.php index 830d63ab31..a3b8f55a14 100644 --- a/app/Http/Controllers/HelpController.php +++ b/app/Http/Controllers/HelpController.php @@ -22,33 +22,15 @@ declare(strict_types=1); namespace FireflyIII\Http\Controllers; -use FireflyIII\Helpers\Help\HelpInterface; +use FireflyIII\Support\Http\Controllers\RequestInformation; use Illuminate\Http\JsonResponse; -use Log; /** * Class HelpController. */ class HelpController extends Controller { - /** @var HelpInterface Help interface. */ - private $help; - - /** - * HelpController constructor. - */ - public function __construct() - { - parent::__construct(); - - $this->middleware( - function ($request, $next) { - $this->help = app(HelpInterface::class); - - return $next($request); - } - ); - } + use RequestInformation; /** * Show help for a route. @@ -66,61 +48,4 @@ class HelpController extends Controller return response()->json(['html' => $html]); } - /** - * Gets the help text. - * - * @param string $route - * @param string $language - * - * @return string - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - */ - private function getHelpText(string $route, string $language): string - { - // get language and default variables. - $content = '

' . trans('firefly.route_has_no_help') . '

'; - - // if no such route, log error and return default text. - if (!$this->help->hasRoute($route)) { - Log::error('No such route: ' . $route); - - return $content; - } - - // help content may be cached: - if ($this->help->inCache($route, $language)) { - $content = $this->help->getFromCache($route, $language); - Log::debug(sprintf('Help text %s was in cache.', $language)); - - return $content; - } - - // get help content from Github: - $content = $this->help->getFromGitHub($route, $language); - - // content will have 0 length when Github failed. Try en_US when it does: - if ('' === $content) { - $language = 'en_US'; - - // also check cache first: - if ($this->help->inCache($route, $language)) { - Log::debug(sprintf('Help text %s was in cache.', $language)); - $content = $this->help->getFromCache($route, $language); - - return $content; - } - - $content = $this->help->getFromGitHub($route, $language); - } - - // help still empty? - if ('' !== $content) { - $this->help->putInCache($route, $language, $content); - - return $content; - } - - return '

' . trans('firefly.route_has_no_help') . '

'; - } } diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index cf2b749385..804deca82b 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -24,7 +24,7 @@ namespace FireflyIII\Http\Controllers; use Carbon\Carbon; use FireflyIII\Events\RequestedVersionCheckStatus; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Http\Middleware\Installer; use FireflyIII\Models\AccountType; use FireflyIII\Repositories\Account\AccountRepositoryInterface; @@ -108,7 +108,9 @@ class HomeController extends Controller } $subTitle = (string)trans('firefly.welcomeBack'); $transactions = []; - $frontPage = app('preferences')->get('frontPageAccounts', $repository->getAccountsByType([AccountType::DEFAULT, AccountType::ASSET])->pluck('id')->toArray()); + $frontPage = app('preferences')->get( + 'frontPageAccounts', $repository->getAccountsByType([AccountType::DEFAULT, AccountType::ASSET])->pluck('id')->toArray() + ); /** @var Carbon $start */ $start = session('start', Carbon::now()->startOfMonth()); /** @var Carbon $end */ @@ -122,9 +124,9 @@ class HomeController extends Controller $billCount = $billRepository->getBills()->count(); foreach ($accounts as $account) { - $collector = app(JournalCollectorInterface::class); + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts(new Collection([$account]))->setRange($start, $end)->setLimit(10)->setPage(1); - $set = $collector->getJournals(); + $set = $collector->getTransactions(); $transactions[] = [$set, $account]; } diff --git a/app/Http/Controllers/Import/CallbackController.php b/app/Http/Controllers/Import/CallbackController.php new file mode 100644 index 0000000000..f85915ac19 --- /dev/null +++ b/app/Http/Controllers/Import/CallbackController.php @@ -0,0 +1,75 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Http\Controllers\Import; + + +use FireflyIII\Http\Controllers\Controller; +use FireflyIII\Repositories\ImportJob\ImportJobRepositoryInterface; +use Illuminate\Http\Request; +use Log; + +/** + * Class CallbackController + */ +class CallbackController extends Controller +{ + + /** + * Callback specifically for YNAB logins. + * + * @param Request $request + * + * @param ImportJobRepositoryInterface $repository + * + * @return \Illuminate\Contracts\View\Factory|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|\Illuminate\View\View + */ + public function ynab(Request $request, ImportJobRepositoryInterface $repository) + { + $code = (string)$request->get('code'); + $jobKey = (string)$request->get('state'); + + if ('' === $code) { + return view('error')->with('message', 'You Need A Budget did not reply with a valid authorization code. Firefly III cannot continue.'); + } + + $importJob = $repository->findByKey($jobKey); + + if ('' === $jobKey || null === $importJob) { + return view('error')->with('message', 'You Need A Budget did not reply with the correct state identifier. Firefly III cannot continue.'); + } + Log::debug(sprintf('Got a code from YNAB: %s', $code)); + + // we have a code. Make the job ready for the next step, and then redirect the user. + $configuration = $repository->getConfiguration($importJob); + $configuration['auth_code'] = $code; + $repository->setConfiguration($importJob, $configuration); + + // set stage to make the import routine take the correct action: + $repository->setStatus($importJob, 'ready_to_run'); + $repository->setStage($importJob, 'get_access_token'); + + return redirect(route('import.job.status.index', [$importJob->key])); + } + +} \ No newline at end of file diff --git a/app/Http/Controllers/Import/IndexController.php b/app/Http/Controllers/Import/IndexController.php index 799904d0c2..fd8817913b 100644 --- a/app/Http/Controllers/Import/IndexController.php +++ b/app/Http/Controllers/Import/IndexController.php @@ -37,7 +37,7 @@ use Log; */ class IndexController extends Controller { - /** @var array */ + /** @var array All available providers */ public $providers; /** @var ImportJobRepositoryInterface The import job repository */ public $repository; @@ -76,23 +76,29 @@ class IndexController extends Controller */ public function create(string $importProvider) { - Log::debug(sprintf('Will create job for provider "%s"', $importProvider)); - $importJob = $this->repository->create($importProvider); - $hasPreReq = (bool)config(sprintf('import.has_prereq.%s', $importProvider)); - $hasConfig = (bool)config(sprintf('import.has_job_config.%s', $importProvider)); + + $hasPreReq = (bool)config(sprintf('import.has_prereq.%s', $importProvider)); + $hasConfig = (bool)config(sprintf('import.has_job_config.%s', $importProvider)); + $allowedForDemo = (bool)config(sprintf('import.allowed_for_demo.%s', $importProvider)); + $isDemoUser = $this->userRepository->hasRole(auth()->user(), 'demo'); + + Log::debug(sprintf('Will create job for provider "%s"', $importProvider)); + Log::debug(sprintf('Is demo user? %s',var_export($isDemoUser, true))); + Log::debug(sprintf('Is allowed for user? %s',var_export($allowedForDemo, true))); + Log::debug(sprintf('Has prerequisites? %s',var_export($hasPreReq, true))); + Log::debug(sprintf('Has config? %s',var_export($hasConfig, true))); + + + if ($isDemoUser && !$allowedForDemo) { + Log::debug('User is demo and this provider doesnt work for demo users.'); + return redirect(route('import.index')); + } + + $importJob = $this->repository->create($importProvider); Log::debug(sprintf('Created job #%d for provider %s', $importJob->id, $importProvider)); - // no prerequisites and no config: - if (false === $hasPreReq && false === $hasConfig) { - Log::debug('Provider needs no configuration for job. Job is ready to start.'); - $this->repository->updateStatus($importJob, 'ready_to_run'); - Log::debug('Redirect to status-page.'); - - return redirect(route('import.job.status.index', [$importJob->key])); - } - // no prerequisites but job has config: if (false === $hasPreReq && false !== $hasConfig) { Log::debug('Provider has no prerequisites. Continue.'); @@ -124,7 +130,7 @@ class IndexController extends Controller if (false === $hasConfig) { // @codeCoverageIgnoreStart Log::debug('Provider has no configuration. Job is ready to start.'); - $this->repository->updateStatus($importJob, 'ready_to_run'); + $this->repository->setStatus($importJob, 'ready_to_run'); Log::debug('Redirect to status-page.'); return redirect(route('import.job.status.index', [$importJob->key])); @@ -180,7 +186,8 @@ class IndexController extends Controller $providers = $this->providers; $subTitle = (string)trans('import.index_breadcrumb'); $subTitleIcon = 'fa-home'; + $isDemoUser = $this->userRepository->hasRole(auth()->user(), 'demo'); - return view('import.index', compact('subTitle', 'subTitleIcon', 'providers')); + return view('import.index', compact('subTitle', 'subTitleIcon', 'providers', 'isDemoUser')); } } diff --git a/app/Http/Controllers/Import/JobConfigurationController.php b/app/Http/Controllers/Import/JobConfigurationController.php index caaeee9825..0a2d9e96d5 100644 --- a/app/Http/Controllers/Import/JobConfigurationController.php +++ b/app/Http/Controllers/Import/JobConfigurationController.php @@ -24,9 +24,9 @@ namespace FireflyIII\Http\Controllers\Import; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Http\Controllers\Controller; -use FireflyIII\Import\JobConfiguration\JobConfigurationInterface; use FireflyIII\Models\ImportJob; use FireflyIII\Repositories\ImportJob\ImportJobRepositoryInterface; +use FireflyIII\Support\Http\Controllers\CreateStuff; use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; use Illuminate\Support\MessageBag; @@ -37,6 +37,7 @@ use Log; */ class JobConfigurationController extends Controller { + use CreateStuff; /** @var ImportJobRepositoryInterface The import job repository */ public $repository; @@ -87,7 +88,7 @@ class JobConfigurationController extends Controller if (!(bool)config(sprintf('import.has_job_config.%s', $importProvider))) { // @codeCoverageIgnoreStart Log::debug('Job needs no config, is ready to run!'); - $this->repository->updateStatus($importJob, 'ready_to_run'); + $this->repository->setStatus($importJob, 'ready_to_run'); return redirect(route('import.job.status.index', [$importJob->key])); // @codeCoverageIgnoreEnd @@ -96,7 +97,7 @@ class JobConfigurationController extends Controller $configurator = $this->makeConfigurator($importJob); if ($configurator->configurationComplete()) { Log::debug('Config is complete, set status to ready_to_run.'); - $this->repository->updateStatus($importJob, 'ready_to_run'); + $this->repository->setStatus($importJob, 'ready_to_run'); return redirect(route('import.job.status.index', [$importJob->key])); } @@ -136,7 +137,7 @@ class JobConfigurationController extends Controller // is the job already configured? if ($configurator->configurationComplete()) { - $this->repository->updateStatus($importJob, 'ready_to_run'); + $this->repository->setStatus($importJob, 'ready_to_run'); return redirect(route('import.job.status.index', [$importJob->key])); } @@ -161,27 +162,5 @@ class JobConfigurationController extends Controller return redirect(route('import.job.configuration.index', [$importJob->key])); } - /** - * Make a configurator object. - * - * @param ImportJob $importJob - * - * @return JobConfigurationInterface - * - * @throws FireflyException - */ - private function makeConfigurator(ImportJob $importJob): JobConfigurationInterface - { - $key = sprintf('import.configuration.%s', $importJob->provider); - $className = (string)config($key); - if (null === $className || !class_exists($className)) { - throw new FireflyException(sprintf('Cannot find configurator class for job with provider "%s".', $importJob->provider)); // @codeCoverageIgnore - } - Log::debug(sprintf('Going to create class "%s"', $className)); - /** @var JobConfigurationInterface $configurator */ - $configurator = app($className); - $configurator->setImportJob($importJob); - return $configurator; - } } diff --git a/app/Http/Controllers/Import/JobStatusController.php b/app/Http/Controllers/Import/JobStatusController.php index 62adfeb7eb..949f15d156 100644 --- a/app/Http/Controllers/Import/JobStatusController.php +++ b/app/Http/Controllers/Import/JobStatusController.php @@ -26,9 +26,9 @@ use Exception; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Import\Routine\RoutineInterface; -use FireflyIII\Import\Storage\ImportArrayStorage; use FireflyIII\Models\ImportJob; use FireflyIII\Repositories\ImportJob\ImportJobRepositoryInterface; +use FireflyIII\Support\Http\Controllers\CreateStuff; use Illuminate\Http\JsonResponse; use Log; @@ -37,6 +37,7 @@ use Log; */ class JobStatusController extends Controller { + use CreateStuff; /** @var ImportJobRepositoryInterface The import job repository */ private $repository; @@ -132,11 +133,12 @@ class JobStatusController extends Controller */ public function start(ImportJob $importJob): JsonResponse { + Log::debug('Now in JobStatusController::start'); // catch impossible status: $allowed = ['ready_to_run', 'need_job_config']; if (null !== $importJob && !\in_array($importJob->status, $allowed, true)) { - Log::error('Job is not ready.'); + Log::error(sprintf('Job is not ready. Status should be in array, but is %s', $importJob->status), $allowed); $this->repository->setStatus($importJob, 'error'); return response()->json( @@ -157,7 +159,11 @@ class JobStatusController extends Controller /** @var RoutineInterface $routine */ $routine = app($className); $routine->setImportJob($importJob); + + Log::debug(sprintf('Created class of type %s', $className)); + try { + Log::debug(sprintf('Try to call %s:run()', $className)); $routine->run(); } catch (FireflyException|Exception $e) { $message = 'The import routine crashed: ' . $e->getMessage(); @@ -189,7 +195,7 @@ class JobStatusController extends Controller // catch impossible status: $allowed = ['provider_finished', 'storing_data']; if (null !== $importJob && !\in_array($importJob->status, $allowed, true)) { - Log::error('Job is not ready.'); + Log::error(sprintf('Job is not ready. Status should be in array, but is %s', $importJob->status), $allowed); return response()->json( ['status' => 'NOK', 'message' => sprintf('JobStatusController::start expects status "provider_finished" instead of "%s".', $importJob->status)] @@ -219,22 +225,5 @@ class JobStatusController extends Controller return response()->json(['status' => 'OK', 'message' => 'storage_finished']); } - /** - * Store the transactions. - * - * @param ImportJob $importJob - * - * @throws FireflyException - */ - private function storeTransactions(ImportJob $importJob): void - { - /** @var ImportArrayStorage $storage */ - $storage = app(ImportArrayStorage::class); - $storage->setImportJob($importJob); - try { - $storage->store(); - } catch (FireflyException|Exception $e) { - throw new FireflyException($e->getMessage()); - } - } + } diff --git a/app/Http/Controllers/JavascriptController.php b/app/Http/Controllers/JavascriptController.php index d9432135bf..d5527846a6 100644 --- a/app/Http/Controllers/JavascriptController.php +++ b/app/Http/Controllers/JavascriptController.php @@ -22,21 +22,22 @@ declare(strict_types=1); namespace FireflyIII\Http\Controllers; -use Carbon\Carbon; use FireflyIII\Models\Account; use FireflyIII\Models\AccountType; use FireflyIII\Models\TransactionCurrency; use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface; +use FireflyIII\Support\Http\Controllers\GetConfigurationData; use Illuminate\Http\Request; use Illuminate\Http\Response; -use Log; /** * Class JavascriptController. */ class JavascriptController extends Controller { + use GetConfigurationData; + /** * Show info about accounts. * @@ -47,7 +48,7 @@ class JavascriptController extends Controller */ public function accounts(AccountRepositoryInterface $repository, CurrencyRepositoryInterface $currencyRepository): Response { - $accounts = $repository->getAccountsByType([AccountType::DEFAULT, AccountType::ASSET]); + $accounts = $repository->getAccountsByType([AccountType::DEFAULT, AccountType::ASSET, AccountType::DEBT,AccountType::LOAN,AccountType::MORTGAGE, AccountType::CREDITCARD]); $preference = app('preferences')->get('currencyPreference', config('firefly.default_currency', 'EUR')); /** @noinspection NullPointerExceptionInspection */ $default = $currencyRepository->findByCodeNull($preference->data); @@ -140,79 +141,4 @@ class JavascriptController extends Controller ->header('Content-Type', 'text/javascript'); } - /** - * Get config for date range. - * - * @return array - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) - */ - private function getDateRangeConfig(): array - { - $viewRange = app('preferences')->get('viewRange', '1M')->data; - /** @var Carbon $start */ - $start = session('start'); - /** @var Carbon $end */ - $end = session('end'); - /** @var Carbon $first */ - $first = session('first'); - $title = sprintf('%s - %s', $start->formatLocalized($this->monthAndDayFormat), $end->formatLocalized($this->monthAndDayFormat)); - $isCustom = true === session('is_custom_range', false); - $today = new Carbon; - $ranges = [ - // first range is the current range: - $title => [$start, $end], - ]; - Log::debug(sprintf('viewRange is %s', $viewRange)); - Log::debug(sprintf('isCustom is %s', var_export($isCustom, true))); - - // when current range is a custom range, add the current period as the next range. - if ($isCustom) { - Log::debug('Custom is true.'); - $index = app('navigation')->periodShow($start, $viewRange); - $customPeriodStart = app('navigation')->startOfPeriod($start, $viewRange); - $customPeriodEnd = app('navigation')->endOfPeriod($customPeriodStart, $viewRange); - $ranges[$index] = [$customPeriodStart, $customPeriodEnd]; - } - // then add previous range and next range - $previousDate = app('navigation')->subtractPeriod($start, $viewRange); - $index = app('navigation')->periodShow($previousDate, $viewRange); - $previousStart = app('navigation')->startOfPeriod($previousDate, $viewRange); - $previousEnd = app('navigation')->endOfPeriod($previousStart, $viewRange); - $ranges[$index] = [$previousStart, $previousEnd]; - - $nextDate = app('navigation')->addPeriod($start, $viewRange, 0); - $index = app('navigation')->periodShow($nextDate, $viewRange); - $nextStart = app('navigation')->startOfPeriod($nextDate, $viewRange); - $nextEnd = app('navigation')->endOfPeriod($nextStart, $viewRange); - $ranges[$index] = [$nextStart, $nextEnd]; - - // today: - /** @var Carbon $todayStart */ - $todayStart = app('navigation')->startOfPeriod($today, $viewRange); - /** @var Carbon $todayEnd */ - $todayEnd = app('navigation')->endOfPeriod($todayStart, $viewRange); - if ($todayStart->ne($start) || $todayEnd->ne($end)) { - $ranges[ucfirst((string)trans('firefly.today'))] = [$todayStart, $todayEnd]; - } - - // everything - $index = (string)trans('firefly.everything'); - $ranges[$index] = [$first, new Carbon]; - - $return = [ - 'title' => $title, - 'configuration' => [ - 'apply' => (string)trans('firefly.apply'), - 'cancel' => (string)trans('firefly.cancel'), - 'from' => (string)trans('firefly.from'), - 'to' => (string)trans('firefly.to'), - 'customRange' => (string)trans('firefly.customRange'), - 'start' => $start->format('Y-m-d'), - 'end' => $end->format('Y-m-d'), - 'ranges' => $ranges, - ], - ]; - - return $return; - } } diff --git a/app/Http/Controllers/Json/AutoCompleteController.php b/app/Http/Controllers/Json/AutoCompleteController.php index a2b1aba242..d6c758bb0d 100644 --- a/app/Http/Controllers/Json/AutoCompleteController.php +++ b/app/Http/Controllers/Json/AutoCompleteController.php @@ -22,7 +22,7 @@ declare(strict_types=1); namespace FireflyIII\Http\Controllers\Json; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Models\Account; use FireflyIII\Models\AccountType; @@ -68,14 +68,14 @@ class AutoCompleteController extends Controller /** * List of all journals. * - * @param JournalCollectorInterface $collector + * @param TransactionCollectorInterface $collector * * @return JsonResponse */ - public function allTransactionJournals(JournalCollectorInterface $collector): JsonResponse + public function allTransactionJournals(TransactionCollectorInterface $collector): JsonResponse { $collector->setLimit(250)->setPage(1); - $return = array_unique($collector->getJournals()->pluck('description')->toArray()); + $return = array_unique($collector->getTransactions()->pluck('description')->toArray()); sort($return); return response()->json($return); @@ -173,12 +173,12 @@ class AutoCompleteController extends Controller /** * List of journals with their ID. * - * @param JournalCollectorInterface $collector + * @param TransactionCollectorInterface $collector * @param TransactionJournal $except * * @return JsonResponse */ - public function journalsWithId(JournalCollectorInterface $collector, TransactionJournal $except): JsonResponse + public function journalsWithId(TransactionCollectorInterface $collector, TransactionJournal $except): JsonResponse { $cache = new CacheProperties; $cache->addProperty('recent-journals-id'); @@ -188,7 +188,7 @@ class AutoCompleteController extends Controller } $collector->setLimit(400)->setPage(1); - $set = $collector->getJournals()->pluck('description', 'journal_id')->toArray(); + $set = $collector->getTransactions()->pluck('description', 'journal_id')->toArray(); $return = []; foreach ($set as $id => $description) { $id = (int)$id; @@ -248,18 +248,18 @@ class AutoCompleteController extends Controller /** * List of journals by type. * - * @param JournalCollectorInterface $collector + * @param TransactionCollectorInterface $collector * @param string $what * * @return JsonResponse */ - public function transactionJournals(JournalCollectorInterface $collector, string $what): JsonResponse + public function transactionJournals(TransactionCollectorInterface $collector, string $what): JsonResponse { $type = config('firefly.transactionTypesByWhat.' . $what); $types = [$type]; $collector->setTypes($types)->setLimit(250)->setPage(1); - $return = array_unique($collector->getJournals()->pluck('description')->toArray()); + $return = array_unique($collector->getTransactions()->pluck('description')->toArray()); sort($return); return response()->json($return); diff --git a/app/Http/Controllers/Json/BoxController.php b/app/Http/Controllers/Json/BoxController.php index f059036100..60027f0a83 100644 --- a/app/Http/Controllers/Json/BoxController.php +++ b/app/Http/Controllers/Json/BoxController.php @@ -23,7 +23,8 @@ declare(strict_types=1); namespace FireflyIII\Http\Controllers\Json; use Carbon\Carbon; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; +use FireflyIII\Helpers\Report\NetWorthInterface; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Models\Account; use FireflyIII\Models\AccountType; @@ -35,13 +36,16 @@ use FireflyIII\Repositories\Bill\BillRepositoryInterface; use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface; use FireflyIII\Support\CacheProperties; +use FireflyIII\Support\Http\Controllers\RequestInformation; use Illuminate\Http\JsonResponse; +use Log; /** * Class BoxController. */ class BoxController extends Controller { + use RequestInformation; /** * How much money user has available. @@ -130,12 +134,12 @@ class BoxController extends Controller $sums = []; // collect income of user: - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setRange($start, $end) ->setTypes([TransactionType::DEPOSIT]) ->withOpposingAccount(); - $set = $collector->getJournals(); + $set = $collector->getTransactions(); /** @var Transaction $transaction */ foreach ($set as $transaction) { $currencyId = (int)$transaction->transaction_currency_id; @@ -146,12 +150,12 @@ class BoxController extends Controller } // collect expenses - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setRange($start, $end) ->setTypes([TransactionType::WITHDRAWAL]) ->withOpposingAccount(); - $set = $collector->getJournals(); + $set = $collector->getTransactions(); /** @var Transaction $transaction */ foreach ($set as $transaction) { $currencyId = (int)$transaction->transaction_currency_id; @@ -202,7 +206,7 @@ class BoxController extends Controller /** @var Carbon $start */ $start = session('start', Carbon::now()->startOfMonth()); /** @var Carbon $end */ - $end = session('end', Carbon::now()->endOfMonth()); + $end = session('end', Carbon::now()->endOfMonth()); $cache = new CacheProperties; $cache->addProperty($start); @@ -233,16 +237,13 @@ class BoxController extends Controller /** * Total user net worth. * - * @param AccountRepositoryInterface $repository - * * @return JsonResponse - * * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - public function netWorth(AccountRepositoryInterface $repository): JsonResponse + public function netWorth(): JsonResponse { - $date = new Carbon(date('Y-m-d')); // needed so its per day. + $date = Carbon::create()->startOfDay(); // start and end in the future? use $end if ($this->notInSessionRange($date)) { @@ -250,101 +251,44 @@ class BoxController extends Controller $date = session('end', Carbon::now()->endOfMonth()); } - // start in the past, end in the future? use $date - $cache = new CacheProperties; - $cache->addProperty($date); - $cache->addProperty('box-net-worth'); - if ($cache->has()) { - return response()->json($cache->get()); // @codeCoverageIgnore - } - $netWorth = []; - $accounts = $repository->getActiveAccountsByType([AccountType::DEFAULT, AccountType::ASSET]); + /** @var NetWorthInterface $netWorthHelper */ + $netWorthHelper = app(NetWorthInterface::class); + $netWorthHelper->setUser(auth()->user()); - $balances = app('steam')->balancesByAccounts($accounts, $date); + /** @var AccountRepositoryInterface $accountRepository */ + $accountRepository = app(AccountRepositoryInterface::class); + $allAccounts = $accountRepository->getActiveAccountsByType( + [AccountType::DEFAULT, AccountType::ASSET, AccountType::DEBT, AccountType::LOAN, AccountType::MORTGAGE, AccountType::CREDITCARD] + ); + Log::debug(sprintf('Found %d accounts.', $allAccounts->count())); - /** @var Account $account */ - foreach ($accounts as $account) { - $accountCurrency = $this->getCurrencyOrDefault($account); - $balance = $balances[$account->id] ?? '0'; + // filter list on preference of being included. + $filtered = $allAccounts->filter( + function (Account $account) use ($accountRepository) { + $includeNetWorth = $accountRepository->getMetaValue($account, 'include_net_worth'); + $result = null === $includeNetWorth ? true : '1' === $includeNetWorth; + if (false === $result) { + Log::debug(sprintf('Will not include "%s" in net worth charts.', $account->name)); + } - // if the account is a credit card, subtract the virtual balance from the balance, - // to better reflect that this is not money that is actually "yours". - $role = (string)$repository->getMetaValue($account, 'accountRole'); - $virtualBalance = (string)$account->virtual_balance; - if ('ccAsset' === $role && '' !== $virtualBalance && (float)$virtualBalance > 0) { - $balance = bcsub($balance, $virtualBalance); + return $result; } + ); + + $netWorthSet = $netWorthHelper->getNetWorthByCurrency($filtered, $date); - if (!isset($netWorth[$accountCurrency->id])) { - $netWorth[$accountCurrency->id]['currency'] = $accountCurrency; - $netWorth[$accountCurrency->id]['sum'] = '0'; - } - $netWorth[$accountCurrency->id]['sum'] = bcadd($netWorth[$accountCurrency->id]['sum'], $balance); - } $return = []; - foreach ($netWorth as $currencyId => $data) { - $return[$currencyId] = app('amount')->formatAnything($data['currency'], $data['sum'], false); + foreach ($netWorthSet as $index => $data) { + /** @var TransactionCurrency $currency */ + $currency = $data['currency']; + $return[$currency->id] = app('amount')->formatAnything($currency, $data['balance'], false); } $return = [ 'net_worths' => array_values($return), ]; - $cache->store($return); - return response()->json($return); } - /** - * Get a currency or return default currency. - * - * @param Account $account - * - * @return TransactionCurrency - */ - private function getCurrencyOrDefault(Account $account): TransactionCurrency - { - /** @var AccountRepositoryInterface $repository */ - $repository = app(AccountRepositoryInterface::class); - /** @var CurrencyRepositoryInterface $currencyRepos */ - $currencyRepos = app(CurrencyRepositoryInterface::class); - - $currency = app('amount')->getDefaultCurrency(); - $accountCurrency = null; - $currencyId = (int)$repository->getMetaValue($account, 'currency_id'); - if (0 !== $currencyId) { - $accountCurrency = $currencyRepos->findNull($currencyId); - } - if (null === $accountCurrency) { - $accountCurrency = $currency; - } - - return $accountCurrency; - } - - /** - * Check if date is outside session range. - * - * @param Carbon $date - * - * @return bool - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - */ - private function notInSessionRange(Carbon $date): bool - { - /** @var Carbon $start */ - $start = session('start', Carbon::now()->startOfMonth()); - /** @var Carbon $end */ - $end = session('end', Carbon::now()->endOfMonth()); - $result = false; - if ($start->greaterThanOrEqualTo($date) && $end->greaterThanOrEqualTo($date)) { - $result = true; - } - // start and end in the past? use $end - if ($start->lessThanOrEqualTo($date) && $end->lessThanOrEqualTo($date)) { - $result = true; - } - - return $result; - } } diff --git a/app/Http/Controllers/Json/FrontpageController.php b/app/Http/Controllers/Json/FrontpageController.php index b27dec9585..1303ba1109 100644 --- a/app/Http/Controllers/Json/FrontpageController.php +++ b/app/Http/Controllers/Json/FrontpageController.php @@ -26,6 +26,8 @@ use FireflyIII\Http\Controllers\Controller; use FireflyIII\Models\PiggyBank; use FireflyIII\Repositories\PiggyBank\PiggyBankRepositoryInterface; use Illuminate\Http\JsonResponse; +use Log; +use Throwable; /** * Class FrontpageController. @@ -38,7 +40,6 @@ class FrontpageController extends Controller * @param PiggyBankRepositoryInterface $repository * * @return JsonResponse - * @throws \Throwable */ public function piggyBanks(PiggyBankRepositoryInterface $repository): JsonResponse { @@ -64,7 +65,14 @@ class FrontpageController extends Controller } $html = ''; if (\count($info) > 0) { - $html = view('json.piggy-banks', compact('info'))->render(); + try { + $html = view('json.piggy-banks', compact('info'))->render(); + // @codeCoverageIgnoreStart + } catch (Throwable $e) { + Log::error(sprintf('Cannot render json.piggy-banks: %s', $e->getMessage())); + $html = 'Could not render view.'; + } + // @codeCoverageIgnoreEnd } return response()->json(['html' => $html]); diff --git a/app/Http/Controllers/Json/IntroController.php b/app/Http/Controllers/Json/IntroController.php index f146e06091..9ad233c2b1 100644 --- a/app/Http/Controllers/Json/IntroController.php +++ b/app/Http/Controllers/Json/IntroController.php @@ -22,6 +22,7 @@ declare(strict_types=1); namespace FireflyIII\Http\Controllers\Json; +use FireflyIII\Support\Http\Controllers\GetConfigurationData; use Illuminate\Http\JsonResponse; use Log; @@ -30,6 +31,8 @@ use Log; */ class IntroController { + use GetConfigurationData; + /** * Returns the introduction wizard for a page. * @@ -135,66 +138,4 @@ class IntroController return response()->json(['result' => sprintf('Reported demo watched for route "%s".', $route)]); } - /** - * Get the basic steps from config. - * - * @param string $route - * - * @return array - */ - private function getBasicSteps(string $route): array - { - $routeKey = str_replace('.', '_', $route); - $elements = config(sprintf('intro.%s', $routeKey)); - $steps = []; - if (\is_array($elements) && \count($elements) > 0) { - foreach ($elements as $key => $options) { - $currentStep = $options; - - // get the text: - $currentStep['intro'] = (string)trans('intro.' . $route . '_' . $key); - - // save in array: - $steps[] = $currentStep; - } - } - Log::debug(sprintf('Total basic steps for %s is %d', $routeKey, \count($steps))); - - return $steps; - } - - /** - * Get specific info for special routes. - * - * @param string $route - * @param string $specificPage - * - * @return array - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - */ - private function getSpecificSteps(string $route, string $specificPage): array - { - $steps = []; - $routeKey = ''; - - // user is on page with specific instructions: - if (\strlen($specificPage) > 0) { - $routeKey = str_replace('.', '_', $route); - $elements = config(sprintf('intro.%s', $routeKey . '_' . $specificPage)); - if (\is_array($elements) && \count($elements) > 0) { - foreach ($elements as $key => $options) { - $currentStep = $options; - - // get the text: - $currentStep['intro'] = (string)trans('intro.' . $route . '_' . $specificPage . '_' . $key); - - // save in array: - $steps[] = $currentStep; - } - } - } - Log::debug(sprintf('Total specific steps for route "%s" and page "%s" (routeKey is "%s") is %d', $route, $specificPage, $routeKey, \count($steps))); - - return $steps; - } } diff --git a/app/Http/Controllers/Json/ReconcileController.php b/app/Http/Controllers/Json/ReconcileController.php index dc36328675..0739102c7c 100644 --- a/app/Http/Controllers/Json/ReconcileController.php +++ b/app/Http/Controllers/Json/ReconcileController.php @@ -26,7 +26,7 @@ namespace FireflyIII\Http\Controllers\Json; use Carbon\Carbon; use FireflyIII\Exceptions\FireflyException; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Models\Account; use FireflyIII\Models\AccountType; @@ -34,9 +34,12 @@ use FireflyIII\Models\Transaction; use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface; use FireflyIII\Repositories\Journal\JournalRepositoryInterface; +use FireflyIII\Support\Http\Controllers\UserNavigation; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Collection; +use Log; +use Throwable; /** * @@ -46,7 +49,7 @@ use Illuminate\Support\Collection; */ class ReconcileController extends Controller { - + use UserNavigation; /** @var AccountRepositoryInterface The account repository */ private $accountRepos; /** @var CurrencyRepositoryInterface The currency repository */ @@ -90,7 +93,6 @@ class ReconcileController extends Controller * * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.ExcessiveMethodLength) - * @throws \Throwable */ public function overview(Request $request, Account $account, Carbon $start, Carbon $end): JsonResponse { @@ -117,21 +119,32 @@ class ReconcileController extends Controller /** @var Transaction $transaction */ foreach ($cleared as $transaction) { if ($transaction->transactionJournal->date <= $end) { - $clearedAmount = bcadd($clearedAmount, $transaction->amount); + $clearedAmount = bcadd($clearedAmount, $transaction->amount); // @codeCoverageIgnore ++$countCleared; } } $difference = bcadd(bcadd(bcsub($startBalance, $endBalance), $clearedAmount), $amount); $diffCompare = bccomp($difference, '0'); - $return = [ - 'post_uri' => $route, - 'html' => view( + + try { + $view = view( 'accounts.reconcile.overview', compact( 'account', 'start', 'diffCompare', 'difference', 'end', 'clearedIds', 'transactionIds', 'clearedAmount', 'startBalance', 'endBalance', 'amount', 'route', 'countCleared' ) - )->render(), + )->render(); + // @codeCoverageIgnoreStart + } catch (Throwable $e) { + Log::debug(sprintf('View error: %s', $e->getMessage())); + $view = 'Could not render accounts.reconcile.overview'; + } + // @codeCoverageIgnoreEnd + + + $return = [ + 'post_uri' => $route, + 'html' => $view, ]; return response()->json($return); @@ -148,7 +161,6 @@ class ReconcileController extends Controller * @return mixed * * @throws FireflyException - * @throws \Throwable */ public function transactions(Account $account, Carbon $start, Carbon $end) { @@ -175,43 +187,22 @@ class ReconcileController extends Controller $selectionEnd->addDays(3); // grab transactions: - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts(new Collection([$account])) ->setRange($selectionStart, $selectionEnd)->withBudgetInformation()->withOpposingAccount()->withCategoryInformation(); - $transactions = $collector->getJournals(); - $html = view( - 'accounts.reconcile.transactions', compact('account', 'transactions', 'currency', 'start', 'end', 'selectionStart', 'selectionEnd') - )->render(); + $transactions = $collector->getTransactions(); + try { + $html = view( + 'accounts.reconcile.transactions', compact('account', 'transactions', 'currency', 'start', 'end', 'selectionStart', 'selectionEnd') + )->render(); + // @codeCoverageIgnoreStart + } catch (Throwable $e) { + Log::debug(sprintf('Could not render: %s', $e->getMessage())); + $html = 'Could not render accounts.reconcile.transactions'; + } + // @codeCoverageIgnoreEnd return response()->json(['html' => $html, 'startBalance' => $startBalance, 'endBalance' => $endBalance]); } - - /** - * Redirect to actual account. - * - * @param Account $account - * - * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector - * - * @throws FireflyException - */ - private function redirectToOriginalAccount(Account $account) - { - /** @var Transaction $transaction */ - $transaction = $account->transactions()->first(); - if (null === $transaction) { - throw new FireflyException(sprintf('Expected a transaction. Account #%d has none. BEEP, error.', $account->id)); // @codeCoverageIgnore - } - - $journal = $transaction->transactionJournal; - /** @var Transaction $opposingTransaction */ - $opposingTransaction = $journal->transactions()->where('transactions.id', '!=', $transaction->id)->first(); - - if (null === $opposingTransaction) { - throw new FireflyException('Expected an opposing transaction. This account has none. BEEP, error.'); // @codeCoverageIgnore - } - - return redirect(route('accounts.show', [$opposingTransaction->account_id])); - } } diff --git a/app/Http/Controllers/Json/RecurrenceController.php b/app/Http/Controllers/Json/RecurrenceController.php index b983022876..37f2e58b81 100644 --- a/app/Http/Controllers/Json/RecurrenceController.php +++ b/app/Http/Controllers/Json/RecurrenceController.php @@ -103,10 +103,8 @@ class RecurrenceController extends Controller $repetition->repetition_skip = (int)$request->get('skip'); $repetition->weekend = (int)$request->get('weekend'); $actualEnd = clone $end; - + $occurrences = []; switch ($endsAt) { - default: - throw new FireflyException(sprintf('Cannot generate events for type that ends at "%s".', $endsAt)); case 'forever': // simply generate up until $end. No change from default behavior. $occurrences = $this->recurring->getOccurrencesInRange($repetition, $actualStart, $actualEnd); diff --git a/app/Http/Controllers/JsonController.php b/app/Http/Controllers/JsonController.php index a18561b929..a3f36b4ad0 100644 --- a/app/Http/Controllers/JsonController.php +++ b/app/Http/Controllers/JsonController.php @@ -24,6 +24,8 @@ namespace FireflyIII\Http\Controllers; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Log; +use Throwable; /** * Class JsonController. @@ -36,7 +38,6 @@ class JsonController extends Controller * @param Request $request * * @return JsonResponse - * @throws \Throwable */ public function action(Request $request): JsonResponse { @@ -46,7 +47,14 @@ class JsonController extends Controller foreach ($keys as $key) { $actions[$key] = (string)trans('firefly.rule_action_' . $key . '_choice'); } - $view = view('rules.partials.action', compact('actions', 'count'))->render(); + try { + $view = view('rules.partials.action', compact('actions', 'count'))->render(); + // @codeCoverageIgnoreStart + } catch (Throwable $e) { + Log::error(sprintf('Cannot render rules.partials.action: %s', $e->getMessage())); + $view = 'Could not render view.'; + } + // @codeCoverageIgnoreEnd return response()->json(['html' => $view]); } @@ -57,7 +65,6 @@ class JsonController extends Controller * @param Request $request * * @return JsonResponse - * @throws \Throwable */ public function trigger(Request $request): JsonResponse { @@ -71,7 +78,14 @@ class JsonController extends Controller } asort($triggers); - $view = view('rules.partials.trigger', compact('triggers', 'count'))->render(); + try { + $view = view('rules.partials.trigger', compact('triggers', 'count'))->render(); + // @codeCoverageIgnoreStart + } catch (Throwable $e) { + Log::error(sprintf('Cannot render rules.partials.trigger: %s', $e->getMessage())); + $view = 'Could not render view.'; + } + // @codeCoverageIgnoreEnd return response()->json(['html' => $view]); } diff --git a/app/Http/Controllers/NewUserController.php b/app/Http/Controllers/NewUserController.php index 0b67b0b609..8ad8aee7b1 100644 --- a/app/Http/Controllers/NewUserController.php +++ b/app/Http/Controllers/NewUserController.php @@ -22,11 +22,10 @@ declare(strict_types=1); namespace FireflyIII\Http\Controllers; -use Carbon\Carbon; use FireflyIII\Http\Requests\NewUserFormRequest; -use FireflyIII\Models\TransactionCurrency; use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface; +use FireflyIII\Support\Http\Controllers\CreateStuff; use View; /** @@ -34,6 +33,7 @@ use View; */ class NewUserController extends Controller { + use CreateStuff; /** @var AccountRepositoryInterface The account repository */ private $repository; @@ -120,87 +120,4 @@ class NewUserController extends Controller return redirect(route('index')); } - /** - * Creates an asset account. - * - * @param NewUserFormRequest $request - * @param TransactionCurrency $currency - * - * @return bool - */ - private function createAssetAccount(NewUserFormRequest $request, TransactionCurrency $currency): bool - { - $assetAccount = [ - 'name' => $request->get('bank_name'), - 'iban' => null, - 'accountType' => 'asset', - 'virtualBalance' => 0, - 'account_type_id' => null, - 'active' => true, - 'accountRole' => 'defaultAsset', - 'openingBalance' => $request->input('bank_balance'), - 'openingBalanceDate' => new Carbon, - 'currency_id' => $currency->id, - ]; - - $this->repository->store($assetAccount); - - return true; - } - - /** - * Creates a cash wallet. - * - * @param TransactionCurrency $currency - * @param string $language - * - * @return bool - */ - private function createCashWalletAccount(TransactionCurrency $currency, string $language): bool - { - $assetAccount = [ - 'name' => (string)trans('firefly.cash_wallet', [], $language), - 'iban' => null, - 'accountType' => 'asset', - 'virtualBalance' => 0, - 'account_type_id' => null, - 'active' => true, - 'accountRole' => 'cashWalletAsset', - 'openingBalance' => null, - 'openingBalanceDate' => null, - 'currency_id' => $currency->id, - ]; - - $this->repository->store($assetAccount); - - return true; - } - - /** - * Create a savings account. - * - * @param NewUserFormRequest $request - * @param TransactionCurrency $currency - * @param string $language - * - * @return bool - */ - private function createSavingsAccount(NewUserFormRequest $request, TransactionCurrency $currency, string $language): bool - { - $savingsAccount = [ - 'name' => (string)trans('firefly.new_savings_account', ['bank_name' => $request->get('bank_name')], $language), - 'iban' => null, - 'accountType' => 'asset', - 'account_type_id' => null, - 'virtualBalance' => 0, - 'active' => true, - 'accountRole' => 'savingAsset', - 'openingBalance' => $request->input('savings_balance'), - 'openingBalanceDate' => new Carbon, - 'currency_id' => $currency->id, - ]; - $this->repository->store($savingsAccount); - - return true; - } } diff --git a/app/Http/Controllers/PiggyBankController.php b/app/Http/Controllers/PiggyBankController.php index 7eb87a3f17..bc23632b74 100644 --- a/app/Http/Controllers/PiggyBankController.php +++ b/app/Http/Controllers/PiggyBankController.php @@ -208,7 +208,7 @@ class PiggyBankController extends Controller 'targetamount' => $piggyBank->targetamount, 'targetdate' => $targetDate, 'startdate' => $startDate, - 'note' => null === $note ? '' : $note->text, + 'notes' => null === $note ? '' : $note->text, ]; session()->flash('preFilled', $preFilled); diff --git a/app/Http/Controllers/Popup/ReportController.php b/app/Http/Controllers/Popup/ReportController.php index d07a09ab63..25dfe38cb4 100644 --- a/app/Http/Controllers/Popup/ReportController.php +++ b/app/Http/Controllers/Popup/ReportController.php @@ -22,20 +22,11 @@ declare(strict_types=1); namespace FireflyIII\Http\Controllers\Popup; -use Carbon\Carbon; -use FireflyIII\Helpers\Collection\BalanceLine; -use FireflyIII\Helpers\Report\PopupReportInterface; use FireflyIII\Http\Controllers\Controller; -use FireflyIII\Repositories\Account\AccountRepositoryInterface; -use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; -use FireflyIII\Repositories\Category\CategoryRepositoryInterface; -use FireflyIII\Support\Binder\AccountList; +use FireflyIII\Support\Http\Controllers\RenderPartialViews; +use FireflyIII\Support\Http\Controllers\RequestInformation; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Routing\Route; -use InvalidArgumentException; -use Log; -use Throwable; /** * Class ReportController. @@ -44,42 +35,11 @@ use Throwable; */ class ReportController extends Controller { - /** @var AccountRepositoryInterface The account repository */ - private $accountRepository; - /** @var BudgetRepositoryInterface The budget repository */ - private $budgetRepository; - /** @var CategoryRepositoryInterface The category repository */ - private $categoryRepository; - /** @var PopupReportInterface Various helper functions. */ - private $popupHelper; - - /** - * ReportController constructor. - */ - public function __construct() - { - parent::__construct(); - $this->middleware( - function ($request, $next) { - /** @var AccountRepositoryInterface accountRepository */ - $this->accountRepository = app(AccountRepositoryInterface::class); - - /** @var BudgetRepositoryInterface budgetRepository */ - $this->budgetRepository = app(BudgetRepositoryInterface::class); - - /** @var CategoryRepositoryInterface categoryRepository */ - $this->categoryRepository = app(CategoryRepositoryInterface::class); - - /** @var PopupReportInterface popupHelper */ - $this->popupHelper = app(PopupReportInterface::class); - - return $next($request); - } - ); - } + use RequestInformation, RenderPartialViews; /** * Generate popup view. + * * @param Request $request * * @return JsonResponse @@ -117,175 +77,5 @@ class ReportController extends Controller return response()->json(['html' => $html]); } - /** - * View for balance row. - * - * @param array $attributes - * - * @return string - * - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - */ - private function balanceAmount(array $attributes): string - { - $role = (int)$attributes['role']; - $budget = $this->budgetRepository->findNull((int)$attributes['budgetId']); - $account = $this->accountRepository->findNull((int)$attributes['accountId']); - - switch (true) { - case BalanceLine::ROLE_DEFAULTROLE === $role && null !== $budget && null !== $account: - // normal row with a budget: - $journals = $this->popupHelper->balanceForBudget($budget, $account, $attributes); - break; - case BalanceLine::ROLE_DEFAULTROLE === $role && null === $budget && null !== $account: - // normal row without a budget: - $journals = $this->popupHelper->balanceForNoBudget($account, $attributes); - $budget->name = (string)trans('firefly.no_budget'); - break; - case BalanceLine::ROLE_TAGROLE === $role: - // row with tag info. - return 'Firefly cannot handle this type of info-button (BalanceLine::TagRole)'; - } - try { - $view = view('popup.report.balance-amount', compact('journals', 'budget', 'account'))->render(); - } catch (Throwable $e) { - Log::error(sprintf('Could not render: %s', $e->getMessage())); - $view = 'Firefly III could not render the view. Please see the log files.'; - } - - return $view; - } - - /** - * View for spent in a single budget. - * - * @param array $attributes - * - * @return string - */ - private function budgetSpentAmount(array $attributes): string - { - $budget = $this->budgetRepository->findNull((int)$attributes['budgetId']); - if (null === $budget) { - return 'This is an unknown budget. Apologies.'; - } - $journals = $this->popupHelper->byBudget($budget, $attributes); - try { - $view = view('popup.report.budget-spent-amount', compact('journals', 'budget'))->render(); - } catch (Throwable $e) { - Log::error(sprintf('Could not render: %s', $e->getMessage())); - $view = 'Firefly III could not render the view. Please see the log files.'; - } - - return $view; - } - - /** - * View for transactions in a category. - * - * @param array $attributes - * - * @return string - */ - private function categoryEntry(array $attributes): string - { - $category = $this->categoryRepository->findNull((int)$attributes['categoryId']); - - if (null === $category) { - return 'This is an unknown category. Apologies.'; - } - - $journals = $this->popupHelper->byCategory($category, $attributes); - try { - $view = view('popup.report.category-entry', compact('journals', 'category'))->render(); - } catch (Throwable $e) { - Log::error(sprintf('Could not render: %s', $e->getMessage())); - $view = 'Firefly III could not render the view. Please see the log files.'; - } - - return $view; - } - - /** - * Returns all the expenses that went to the given expense account. - * - * @param array $attributes - * - * @return string - */ - private function expenseEntry(array $attributes): string - { - $account = $this->accountRepository->findNull((int)$attributes['accountId']); - - if (null === $account) { - return 'This is an unknown account. Apologies.'; - } - - $journals = $this->popupHelper->byExpenses($account, $attributes); - try { - $view = view('popup.report.expense-entry', compact('journals', 'account'))->render(); - } catch (Throwable $e) { - Log::error(sprintf('Could not render: %s', $e->getMessage())); - $view = 'Firefly III could not render the view. Please see the log files.'; - } - - return $view; - } - - /** - * Returns all the incomes that went to the given asset account. - * - * @param array $attributes - * - * @return string - */ - private function incomeEntry(array $attributes): string - { - $account = $this->accountRepository->findNull((int)$attributes['accountId']); - - if (null === $account) { - return 'This is an unknown category. Apologies.'; - } - - $journals = $this->popupHelper->byIncome($account, $attributes); - try { - $view = view('popup.report.income-entry', compact('journals', 'account'))->render(); - } catch (Throwable $e) { - Log::error(sprintf('Could not render: %s', $e->getMessage())); - $view = 'Firefly III could not render the view. Please see the log files.'; - } - - return $view; - } - - /** - * Parses attributes from URI. - * - * @param array $attributes - * - * @return array - */ - private function parseAttributes(array $attributes): array - { - $attributes['location'] = $attributes['location'] ?? ''; - $attributes['accounts'] = AccountList::routeBinder($attributes['accounts'] ?? '', new Route('get', '', [])); - try { - $attributes['startDate'] = Carbon::createFromFormat('Ymd', $attributes['startDate']); - } catch (InvalidArgumentException $e) { - Log::debug(sprintf('Not important error message: %s', $e->getMessage())); - $date = Carbon::create()->startOfMonth(); - $attributes['startDate'] = $date; - } - - try { - $attributes['endDate'] = Carbon::createFromFormat('Ymd', $attributes['endDate']); - } catch (InvalidArgumentException $e) { - Log::debug(sprintf('Not important error message: %s', $e->getMessage())); - $date = Carbon::create()->startOfMonth(); - $attributes['endDate'] = $date; - } - - return $attributes; - } } diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index 944a221915..a22196b051 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -35,15 +35,15 @@ use FireflyIII\Http\Requests\ProfileFormRequest; use FireflyIII\Http\Requests\TokenFormRequest; use FireflyIII\Models\Preference; use FireflyIII\Repositories\User\UserRepositoryInterface; +use FireflyIII\Support\Http\Controllers\CreateStuff; +use FireflyIII\Support\Http\Controllers\RequestInformation; use FireflyIII\User; use Google2FA; use Hash; use Illuminate\Contracts\Auth\Guard; use Illuminate\Support\Collection; use Laravel\Passport\ClientRepository; -use Laravel\Passport\Passport; use Log; -use phpseclib\Crypt\RSA; /** * Class ProfileController. @@ -54,6 +54,8 @@ use phpseclib\Crypt\RSA; */ class ProfileController extends Controller { + use RequestInformation, CreateStuff; + /** * ProfileController constructor. */ @@ -188,17 +190,10 @@ class ProfileController extends Controller /** * Enable 2FA screen. * - * @param UserRepositoryInterface $repository - * * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector */ - public function enable2FA(UserRepositoryInterface $repository) + public function enable2FA() { - /** @var User $user */ - $user = auth()->user(); - if ($repository->hasRole($user, 'demo')) { - return redirect(route('profile.index')); - } $hasSecret = (null !== app('preferences')->get('twoFactorAuthSecret')); // if we don't have a valid secret yet, redirect to the code page to get one. @@ -340,6 +335,7 @@ class ProfileController extends Controller session()->flash('success', (string)trans('firefly.saved_preferences')); app('preferences')->mark(); + return redirect(route('profile.index')); } @@ -371,6 +367,7 @@ class ProfileController extends Controller /** * Regenerate access token. + * * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector */ public function regenerate() @@ -437,64 +434,5 @@ class ProfileController extends Controller return redirect(route('login')); } - /** - * Validate users new password. - * - * @param User $user - * @param string $current - * @param string $new - * - * @return bool - * - * @throws ValidationException - */ - protected function validatePassword(User $user, string $current, string $new): bool - { - if (!Hash::check($current, $user->password)) { - throw new ValidationException((string)trans('firefly.invalid_current_password')); - } - if ($current === $new) { - throw new ValidationException((string)trans('firefly.should_change')); - } - - return true; - } - - /** - * Create new RSA keys. - */ - private function createOAuthKeys(): void - { - $rsa = new RSA(); - $keys = $rsa->createKey(4096); - - [$publicKey, $privateKey] = [ - Passport::keyPath('oauth-public.key'), - Passport::keyPath('oauth-private.key'), - ]; - - if (file_exists($publicKey) || file_exists($privateKey)) { - return; - } - // @codeCoverageIgnoreStart - Log::alert('NO OAuth keys were found. They have been created.'); - - file_put_contents($publicKey, array_get($keys, 'publickey')); - file_put_contents($privateKey, array_get($keys, 'privatekey')); - } - // @codeCoverageIgnoreEnd - - /** - * Get the domain of FF system. - * - * @return string - */ - private function getDomain(): string - { - $url = url()->to('/'); - $parts = parse_url($url); - - return $parts['host']; - } } diff --git a/app/Http/Controllers/Recurring/CreateController.php b/app/Http/Controllers/Recurring/CreateController.php index 38bde82fe2..88e5abd5a4 100644 --- a/app/Http/Controllers/Recurring/CreateController.php +++ b/app/Http/Controllers/Recurring/CreateController.php @@ -86,7 +86,7 @@ class CreateController extends Controller $this->rememberPreviousUri('recurring.create.uri'); } $request->session()->forget('recurring.create.fromStore'); - $repetitionEnds = [ + $repetitionEnds = [ 'forever' => (string)trans('firefly.repeat_forever'), 'until_date' => (string)trans('firefly.repeat_until_date'), 'times' => (string)trans('firefly.repeat_times'), diff --git a/app/Http/Controllers/Recurring/EditController.php b/app/Http/Controllers/Recurring/EditController.php index 400bd07a5b..ebae84cf4d 100644 --- a/app/Http/Controllers/Recurring/EditController.php +++ b/app/Http/Controllers/Recurring/EditController.php @@ -86,7 +86,7 @@ class EditController extends Controller $budgets = app('expandedform')->makeSelectListWithEmpty($this->budgets->getActiveBudgets()); /** @var RecurrenceRepetition $repetition */ - $repetition = $recurrence->recurrenceRepetitions()->first(); + $repetition = $recurrence->recurrenceRepetitions()->first(); $currentRepType = $repetition->repetition_type; if ('' !== $repetition->repetition_moment) { $currentRepType .= ',' . $repetition->repetition_moment; diff --git a/app/Http/Controllers/Recurring/IndexController.php b/app/Http/Controllers/Recurring/IndexController.php index 4aceec0e57..e26a5507fe 100644 --- a/app/Http/Controllers/Recurring/IndexController.php +++ b/app/Http/Controllers/Recurring/IndexController.php @@ -29,8 +29,11 @@ use FireflyIII\Exceptions\FireflyException; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Models\Recurrence; use FireflyIII\Repositories\Recurring\RecurringRepositoryInterface; +use FireflyIII\Support\Http\Controllers\GetConfigurationData; use FireflyIII\Transformers\RecurrenceTransformer; use Illuminate\Http\Request; +use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; use Symfony\Component\HttpFoundation\ParameterBag; /** @@ -39,6 +42,7 @@ use Symfony\Component\HttpFoundation\ParameterBag; */ class IndexController extends Controller { + use GetConfigurationData; /** @var RecurringRepositoryInterface Recurring repository */ private $recurring; @@ -77,19 +81,27 @@ class IndexController extends Controller $pageSize = (int)app('preferences')->get('listPageSize', 50)->data; $collection = $this->recurring->get(); + // split collection + $total = $collection->count(); + /** @var Collection $recurrences */ + $recurrences = $collection->slice(($page - 1) * $pageSize, $pageSize); $transformer = new RecurrenceTransformer(new ParameterBag); $recurring = []; /** @var Recurrence $recurrence */ - foreach ($collection as $recurrence) { + foreach ($recurrences as $recurrence) { $array = $transformer->transform($recurrence); $array['first_date'] = new Carbon($array['first_date']); $array['repeat_until'] = null === $array['repeat_until'] ? null : new Carbon($array['repeat_until']); $array['latest_date'] = null === $array['latest_date'] ? null : new Carbon($array['latest_date']); $recurring[] = $array; } + $paginator = new LengthAwarePaginator($recurring, $total, $pageSize, $page); + $paginator->setPath(route('recurring.index')); - return view('recurring.index', compact('recurring', 'page', 'pageSize')); + $this->verifyRecurringCronJob(); + + return view('recurring.index', compact('paginator', 'page', 'pageSize', 'total')); } /** diff --git a/app/Http/Controllers/Report/AccountController.php b/app/Http/Controllers/Report/AccountController.php index 68c0b078ef..01b67723af 100644 --- a/app/Http/Controllers/Report/AccountController.php +++ b/app/Http/Controllers/Report/AccountController.php @@ -27,6 +27,8 @@ use FireflyIII\Http\Controllers\Controller; use FireflyIII\Repositories\Account\AccountTaskerInterface; use FireflyIII\Support\CacheProperties; use Illuminate\Support\Collection; +use Log; +use Throwable; /** * Class AccountController. @@ -43,7 +45,6 @@ class AccountController extends Controller * * @return mixed|string * - * @throws \Throwable */ public function general(Collection $accounts, Carbon $start, Carbon $end) { @@ -60,8 +61,12 @@ class AccountController extends Controller /** @var AccountTaskerInterface $accountTasker */ $accountTasker = app(AccountTaskerInterface::class); $accountReport = $accountTasker->getAccountReport($accounts, $start, $end); - - $result = view('reports.partials.accounts', compact('accountReport'))->render(); + try { + $result = view('reports.partials.accounts', compact('accountReport'))->render(); + } catch (Throwable $e) { + Log::debug(sprintf('Could not render reports.partials.accounts: %s', $e->getMessage())); + $result = 'Could not render view.'; + } $cache->store($result); return $result; diff --git a/app/Http/Controllers/Report/BalanceController.php b/app/Http/Controllers/Report/BalanceController.php index 6ac7642c85..1c08c89f27 100644 --- a/app/Http/Controllers/Report/BalanceController.php +++ b/app/Http/Controllers/Report/BalanceController.php @@ -27,6 +27,8 @@ use FireflyIII\Helpers\Report\BalanceReportHelperInterface; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Support\CacheProperties; use Illuminate\Support\Collection; +use Log; +use Throwable; /** * Class BalanceController. @@ -42,7 +44,6 @@ class BalanceController extends Controller * @param Carbon $end * * @return mixed|string - * @throws \Throwable */ public function general(Collection $accounts, Carbon $start, Carbon $end) { @@ -58,8 +59,12 @@ class BalanceController extends Controller } $helper = app(BalanceReportHelperInterface::class); $balance = $helper->getBalanceReport($accounts, $start, $end); - - $result = view('reports.partials.balance', compact('balance'))->render(); + try { + $result = view('reports.partials.balance', compact('balance'))->render(); + } catch (Throwable $e) { + Log::debug(sprintf('Could not render reports.partials.balance: %s', $e->getMessage())); + $result = 'Could not render view.'; + } $cache->store($result); return $result; diff --git a/app/Http/Controllers/Report/BudgetController.php b/app/Http/Controllers/Report/BudgetController.php index 420fd485c7..3eddbc2839 100644 --- a/app/Http/Controllers/Report/BudgetController.php +++ b/app/Http/Controllers/Report/BudgetController.php @@ -27,13 +27,17 @@ use FireflyIII\Helpers\Report\BudgetReportHelperInterface; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; use FireflyIII\Support\CacheProperties; +use FireflyIII\Support\Http\Controllers\BasicDataSupport; use Illuminate\Support\Collection; +use Log; +use Throwable; /** * Class BudgetController. */ class BudgetController extends Controller { + use BasicDataSupport; /** * Show partial overview of budgets. @@ -43,7 +47,6 @@ class BudgetController extends Controller * @param Carbon $end * * @return mixed|string - * @throws \Throwable */ public function general(Collection $accounts, Carbon $start, Carbon $end) { @@ -58,8 +61,12 @@ class BudgetController extends Controller } $helper = app(BudgetReportHelperInterface::class); $budgets = $helper->getBudgetReport($start, $end, $accounts); - - $result = view('reports.partials.budgets', compact('budgets'))->render(); + try { + $result = view('reports.partials.budgets', compact('budgets'))->render(); + } catch (Throwable $e) { + Log::debug(sprintf('Could not render reports.partials.budgets: %s', $e->getMessage())); + $result = 'Could not render view.'; + } $cache->store($result); return $result; @@ -74,7 +81,6 @@ class BudgetController extends Controller * @param Carbon $end * * @return mixed|string - * @throws \Throwable */ public function period(Collection $accounts, Carbon $start, Carbon $end) { @@ -93,39 +99,17 @@ class BudgetController extends Controller $budgets = $repository->getBudgets(); $data = $repository->getBudgetPeriodReport($budgets, $accounts, $start, $end); $data[0] = $repository->getNoBudgetPeriodReport($accounts, $start, $end); // append report data for "no budget" - $report = $this->filterBudgetPeriodReport($data); + $report = $this->filterPeriodReport($data); $periods = app('navigation')->listOfPeriods($start, $end); - - $result = view('reports.partials.budget-period', compact('report', 'periods'))->render(); + try { + $result = view('reports.partials.budget-period', compact('report', 'periods'))->render(); + } catch (Throwable $e) { + Log::debug(sprintf('Could not render reports.partials.budget-period: %s', $e->getMessage())); + $result = 'Could not render view.'; + } $cache->store($result); return $result; } - /** - * Filters empty results from getBudgetPeriodReport. - * - * @param array $data - * - * @return array - */ - private function filterBudgetPeriodReport(array $data): array - { - /** - * @var int - * @var array $set - */ - foreach ($data as $budgetId => $set) { - $sum = '0'; - foreach ($set['entries'] as $amount) { - $sum = bcadd($amount, $sum); - } - $data[$budgetId]['sum'] = $sum; - if (0 === bccomp('0', $sum)) { - unset($data[$budgetId]); - } - } - - return $data; - } } diff --git a/app/Http/Controllers/Report/CategoryController.php b/app/Http/Controllers/Report/CategoryController.php index 34693bf846..7214d556cb 100644 --- a/app/Http/Controllers/Report/CategoryController.php +++ b/app/Http/Controllers/Report/CategoryController.php @@ -27,6 +27,7 @@ use FireflyIII\Http\Controllers\Controller; use FireflyIII\Models\Category; use FireflyIII\Repositories\Category\CategoryRepositoryInterface; use FireflyIII\Support\CacheProperties; +use FireflyIII\Support\Http\Controllers\BasicDataSupport; use Illuminate\Support\Collection; use Log; use Throwable; @@ -36,6 +37,7 @@ use Throwable; */ class CategoryController extends Controller { + use BasicDataSupport; /** * Show overview of expenses in category. @@ -61,7 +63,7 @@ class CategoryController extends Controller $categories = $repository->getCategories(); $data = $repository->periodExpenses($categories, $accounts, $start, $end); $data[0] = $repository->periodExpensesNoCategory($accounts, $start, $end); - $report = $this->filterReport($data); + $report = $this->filterPeriodReport($data); $periods = app('navigation')->listOfPeriods($start, $end); try { $result = view('reports.partials.category-period', compact('report', 'periods'))->render(); @@ -101,7 +103,7 @@ class CategoryController extends Controller $categories = $repository->getCategories(); $data = $repository->periodIncome($categories, $accounts, $start, $end); $data[0] = $repository->periodIncomeNoCategory($accounts, $start, $end); - $report = $this->filterReport($data); + $report = $this->filterPeriodReport($data); $periods = app('navigation')->listOfPeriods($start, $end); try { $result = view('reports.partials.category-period', compact('report', 'periods'))->render(); @@ -166,26 +168,5 @@ class CategoryController extends Controller return $result; } - /** - * Filters empty results from category period report. - * - * @param array $data - * - * @return array - */ - private function filterReport(array $data): array - { - foreach ($data as $categoryId => $set) { - $sum = '0'; - foreach ($set['entries'] as $amount) { - $sum = bcadd($amount, $sum); - } - $data[$categoryId]['sum'] = $sum; - if (0 === bccomp('0', $sum)) { - unset($data[$categoryId]); - } - } - return $data; - } } diff --git a/app/Http/Controllers/Report/ExpenseController.php b/app/Http/Controllers/Report/ExpenseController.php index 7749607f7f..2e2b3a9429 100644 --- a/app/Http/Controllers/Report/ExpenseController.php +++ b/app/Http/Controllers/Report/ExpenseController.php @@ -23,14 +23,13 @@ declare(strict_types=1); namespace FireflyIII\Http\Controllers\Report; use Carbon\Carbon; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Http\Controllers\Controller; -use FireflyIII\Models\Account; -use FireflyIII\Models\AccountType; use FireflyIII\Models\Transaction; use FireflyIII\Models\TransactionType; use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Support\CacheProperties; +use FireflyIII\Support\Http\Controllers\AugumentData; use Illuminate\Support\Collection; use Log; use Throwable; @@ -42,6 +41,8 @@ use Throwable; */ class ExpenseController extends Controller { + use AugumentData; + /** @var AccountRepositoryInterface The account repository */ protected $accountRepository; @@ -258,11 +259,11 @@ class ExpenseController extends Controller $all = $all->merge($combi); } // get all expenses in period: - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setRange($start, $end)->setTypes([TransactionType::WITHDRAWAL])->setAccounts($accounts); $collector->setOpposingAccounts($all); - $set = $collector->getJournals(); + $set = $collector->getTransactions(); $sorted = $set->sortBy( function (Transaction $transaction) { return (float)$transaction->transaction_amount; @@ -307,11 +308,11 @@ class ExpenseController extends Controller $all = $all->merge($combi); } // get all expenses in period: - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setRange($start, $end)->setTypes([TransactionType::DEPOSIT])->setAccounts($accounts); $collector->setOpposingAccounts($all); - $set = $collector->getJournals(); + $set = $collector->getTransactions(); $sorted = $set->sortByDesc( function (Transaction $transaction) { return (float)$transaction->transaction_amount; @@ -328,30 +329,7 @@ class ExpenseController extends Controller return $result; } - /** - * Combine accounts into a single list. - * - * @param Collection $accounts - * - * @return array - */ - protected function combineAccounts(Collection $accounts): array - { - $combined = []; - /** @var Account $expenseAccount */ - foreach ($accounts as $expenseAccount) { - $collection = new Collection; - $collection->push($expenseAccount); - $revenue = $this->accountRepository->findByName($expenseAccount->name, [AccountType::REVENUE]); - if (null !== $revenue) { - $collection->push($revenue); - } - $combined[$expenseAccount->name] = $collection; - } - - return $combined; - } /** @noinspection MoreThanThreeArgumentsInspection */ /** @@ -367,13 +345,13 @@ class ExpenseController extends Controller * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - protected function earnedByCategory(Collection $assets, Collection $opposing, Carbon $start, Carbon $end): array + protected function earnedByCategory(Collection $assets, Collection $opposing, Carbon $start, Carbon $end): array // get data + augment with info { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setRange($start, $end)->setTypes([TransactionType::DEPOSIT])->setAccounts($assets); $collector->setOpposingAccounts($opposing)->withCategoryInformation(); - $set = $collector->getJournals(); + $set = $collector->getTransactions(); $sum = []; // loop to support multi currency foreach ($set as $transaction) { @@ -431,13 +409,13 @@ class ExpenseController extends Controller * * @return array */ - protected function earnedInPeriod(Collection $assets, Collection $opposing, Carbon $start, Carbon $end): array + protected function earnedInPeriod(Collection $assets, Collection $opposing, Carbon $start, Carbon $end): array // get data + augment with info { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setRange($start, $end)->setTypes([TransactionType::DEPOSIT])->setAccounts($assets); $collector->setOpposingAccounts($opposing); - $set = $collector->getJournals(); + $set = $collector->getTransactions(); $sum = [ 'grand_sum' => '0', 'per_currency' => [], @@ -478,13 +456,13 @@ class ExpenseController extends Controller * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - protected function spentByBudget(Collection $assets, Collection $opposing, Carbon $start, Carbon $end): array + protected function spentByBudget(Collection $assets, Collection $opposing, Carbon $start, Carbon $end): array // get data + augment with info { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setRange($start, $end)->setTypes([TransactionType::WITHDRAWAL])->setAccounts($assets); $collector->setOpposingAccounts($opposing)->withBudgetInformation(); - $set = $collector->getJournals(); + $set = $collector->getTransactions(); $sum = []; // loop to support multi currency foreach ($set as $transaction) { @@ -545,13 +523,13 @@ class ExpenseController extends Controller * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - protected function spentByCategory(Collection $assets, Collection $opposing, Carbon $start, Carbon $end): array + protected function spentByCategory(Collection $assets, Collection $opposing, Carbon $start, Carbon $end): array // get data + augment with info { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setRange($start, $end)->setTypes([TransactionType::WITHDRAWAL])->setAccounts($assets); $collector->setOpposingAccounts($opposing)->withCategoryInformation(); - $set = $collector->getJournals(); + $set = $collector->getTransactions(); $sum = []; // loop to support multi currency foreach ($set as $transaction) { @@ -609,13 +587,13 @@ class ExpenseController extends Controller * * @return array */ - protected function spentInPeriod(Collection $assets, Collection $opposing, Carbon $start, Carbon $end): array + protected function spentInPeriod(Collection $assets, Collection $opposing, Carbon $start, Carbon $end): array // get data + augment with info { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setRange($start, $end)->setTypes([TransactionType::WITHDRAWAL])->setAccounts($assets); $collector->setOpposingAccounts($opposing); - $set = $collector->getJournals(); + $set = $collector->getTransactions(); $sum = [ 'grand_sum' => '0', 'per_currency' => [], diff --git a/app/Http/Controllers/Report/OperationsController.php b/app/Http/Controllers/Report/OperationsController.php index 04a5307c0b..383629446b 100644 --- a/app/Http/Controllers/Report/OperationsController.php +++ b/app/Http/Controllers/Report/OperationsController.php @@ -27,6 +27,8 @@ use FireflyIII\Http\Controllers\Controller; use FireflyIII\Repositories\Account\AccountTaskerInterface; use FireflyIII\Support\CacheProperties; use Illuminate\Support\Collection; +use Log; +use Throwable; /** * Class OperationsController. @@ -38,7 +40,7 @@ class OperationsController extends Controller private $tasker; /** - * + * OperationsController constructor. */ public function __construct() { @@ -63,7 +65,6 @@ class OperationsController extends Controller * @param Carbon $end * * @return mixed|string - * @throws \Throwable */ public function expenses(Collection $accounts, Carbon $start, Carbon $end) { @@ -78,7 +79,12 @@ class OperationsController extends Controller } $entries = $this->tasker->getExpenseReport($start, $end, $accounts); $type = 'expense-entry'; - $result = view('reports.partials.income-expenses', compact('entries', 'type'))->render(); + try { + $result = view('reports.partials.income-expenses', compact('entries', 'type'))->render(); + } catch (Throwable $e) { + Log::debug(sprintf('Could not render reports.partials.income-expense: %s', $e->getMessage())); + $result = 'Could not render view.'; + } $cache->store($result); return $result; @@ -92,7 +98,6 @@ class OperationsController extends Controller * @param Carbon $end * * @return string - * @throws \Throwable */ public function income(Collection $accounts, Carbon $start, Carbon $end): string { @@ -107,7 +112,12 @@ class OperationsController extends Controller } $entries = $this->tasker->getIncomeReport($start, $end, $accounts); $type = 'income-entry'; - $result = view('reports.partials.income-expenses', compact('entries', 'type'))->render(); + try { + $result = view('reports.partials.income-expenses', compact('entries', 'type'))->render(); + } catch (Throwable $e) { + Log::debug(sprintf('Could not render reports.partials.income-expenses: %s', $e->getMessage())); + $result = 'Could not render view.'; + } $cache->store($result); @@ -122,7 +132,6 @@ class OperationsController extends Controller * @param Carbon $end * * @return mixed|string - * @throws \Throwable */ public function operations(Collection $accounts, Carbon $start, Carbon $end) { @@ -155,8 +164,12 @@ class OperationsController extends Controller $expenses ) ); - - $result = view('reports.partials.operations', compact('incomeSum', 'expensesSum'))->render(); + try { + $result = view('reports.partials.operations', compact('incomeSum', 'expensesSum'))->render(); + } catch (Throwable $e) { + Log::debug(sprintf('Could not render reports.partials.operations: %s', $e->getMessage())); + $result = 'Could not render view.'; + } $cache->store($result); return $result; diff --git a/app/Http/Controllers/ReportController.php b/app/Http/Controllers/ReportController.php index ff77965966..82339668ff 100644 --- a/app/Http/Controllers/ReportController.php +++ b/app/Http/Controllers/ReportController.php @@ -28,11 +28,9 @@ use FireflyIII\Generator\Report\ReportGeneratorFactory; use FireflyIII\Helpers\Report\ReportHelperInterface; use FireflyIII\Http\Requests\ReportFormRequest; use FireflyIII\Models\AccountType; -use FireflyIII\Models\Tag; use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; -use FireflyIII\Repositories\Category\CategoryRepositoryInterface; -use FireflyIII\Repositories\Tag\TagRepositoryInterface; +use FireflyIII\Support\Http\Controllers\RenderPartialViews; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Collection; use Log; @@ -44,6 +42,8 @@ use Log; */ class ReportController extends Controller { + use RenderPartialViews; + /** @var ReportHelperInterface Helper interface. */ protected $helper; @@ -277,7 +277,6 @@ class ReportController extends Controller * * @return mixed * @SuppressWarnings(PHPMD.CyclomaticComplexity) - * @throws \Throwable */ public function options(string $reportType) { @@ -324,7 +323,7 @@ class ReportController extends Controller $accounts = implode(',', $request->getAccountList()->pluck('id')->toArray()); $categories = implode(',', $request->getCategoryList()->pluck('id')->toArray()); $budgets = implode(',', $request->getBudgetList()->pluck('id')->toArray()); - $tags = implode(',', $request->getTagList()->pluck('tag')->toArray()); + $tags = implode(',', $request->getTagList()->pluck('id')->toArray()); $expense = implode(',', $request->getExpenseList()->pluck('id')->toArray()); $uri = route('reports.index'); @@ -391,7 +390,6 @@ class ReportController extends Controller * @param Carbon $end * * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View|string - * * @throws \FireflyIII\Exceptions\FireflyException */ public function tagReport(Collection $accounts, Collection $tags, Carbon $start, Carbon $end) @@ -419,82 +417,5 @@ class ReportController extends Controller return $generator->generate(); } - /** - * Get options for account report. - * - * @return string - * @throws \Throwable - */ - private function accountReportOptions(): string - { - /** @var AccountRepositoryInterface $repository */ - $repository = app(AccountRepositoryInterface::class); - $expense = $repository->getActiveAccountsByType([AccountType::EXPENSE]); - $revenue = $repository->getActiveAccountsByType([AccountType::REVENUE]); - $set = new Collection; - $names = $revenue->pluck('name')->toArray(); - foreach ($expense as $exp) { - if (\in_array($exp->name, $names, true)) { - $set->push($exp); - } - } - return view('reports.options.account', compact('set'))->render(); - } - - /** - * Get options for budget report. - * @return string - * @throws \Throwable - */ - private function budgetReportOptions(): string - { - /** @var BudgetRepositoryInterface $repository */ - $repository = app(BudgetRepositoryInterface::class); - $budgets = $repository->getBudgets(); - - return view('reports.options.budget', compact('budgets'))->render(); - } - - /** - * Get options for category report. - * @return string - * @throws \Throwable - */ - private function categoryReportOptions(): string - { - /** @var CategoryRepositoryInterface $repository */ - $repository = app(CategoryRepositoryInterface::class); - $categories = $repository->getCategories(); - - return view('reports.options.category', compact('categories'))->render(); - } - - /** - * Get options for default report. - * @return string - * @throws \Throwable - */ - private function noReportOptions(): string - { - return view('reports.options.no-options')->render(); - } - - /** - * Get options for tag report. - * @return string - * @throws \Throwable - */ - private function tagReportOptions(): string - { - /** @var TagRepositoryInterface $repository */ - $repository = app(TagRepositoryInterface::class); - $tags = $repository->get()->sortBy( - function (Tag $tag) { - return $tag->tag; - } - ); - - return view('reports.options.tag', compact('tags'))->render(); - } } diff --git a/app/Http/Controllers/Rule/CreateController.php b/app/Http/Controllers/Rule/CreateController.php index e35b243640..2cc5c1eb06 100644 --- a/app/Http/Controllers/Rule/CreateController.php +++ b/app/Http/Controllers/Rule/CreateController.php @@ -28,9 +28,7 @@ use FireflyIII\Http\Controllers\Controller; use FireflyIII\Http\Requests\RuleFormRequest; use FireflyIII\Models\Bill; use FireflyIII\Models\RuleGroup; -use FireflyIII\Repositories\Bill\BillRepositoryInterface; use FireflyIII\Repositories\Rule\RuleRepositoryInterface; - use FireflyIII\Support\Http\Controllers\RuleManagement; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -43,8 +41,6 @@ use Throwable; class CreateController extends Controller { use RuleManagement; - /** @var BillRepositoryInterface Bill repository */ - private $billRepos; /** @var RuleRepositoryInterface Rule repository */ private $ruleRepos; @@ -60,7 +56,6 @@ class CreateController extends Controller app('view')->share('title', (string)trans('firefly.rules')); app('view')->share('mainTitleIcon', 'fa-random'); - $this->billRepos = app(BillRepositoryInterface::class); $this->ruleRepos = app(RuleRepositoryInterface::class); return $next($request); @@ -78,51 +73,33 @@ class CreateController extends Controller * @SuppressWarnings(PHPMD.ExcessiveMethodLength) * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ - public function create(Request $request, RuleGroup $ruleGroup) + public function create(Request $request, RuleGroup $ruleGroup = null) { $this->createDefaultRuleGroup(); $this->createDefaultRule(); - $bill = null; - $billId = (int)$request->get('fromBill'); - $preFilled = [ + $preFilled = [ 'strict' => true, ]; - $oldTriggers = []; - $oldActions = []; - $returnToBill = false; + $oldTriggers = []; + $oldActions = []; - if ('true' === $request->get('return')) { - $returnToBill = true; - } - - // has bill? - if ($billId > 0) { - $bill = $this->billRepos->find($billId); - } - - // has old input? + // restore actions and triggers from old input: if ($request->old()) { $oldTriggers = $this->getPreviousTriggers($request); $oldActions = $this->getPreviousActions($request); } - // has existing bill refered to in URI? - if (null !== $bill && !$request->old()) { - - // create some sensible defaults: - $preFilled['title'] = (string)trans('firefly.new_rule_for_bill_title', ['name' => $bill->name]); - $preFilled['description'] = (string)trans('firefly.new_rule_for_bill_description', ['name' => $bill->name]); - - - // get triggers and actions for bill: - $oldTriggers = $this->getTriggersForBill($bill); - $oldActions = $this->getActionsForBill($bill); - } $triggerCount = \count($oldTriggers); $actionCount = \count($oldActions); $subTitleIcon = 'fa-clone'; - $subTitle = (string)trans('firefly.make_new_rule', ['title' => $ruleGroup->title]); + // title depends on whether or not there is a rule group: + $subTitle = (string)trans('firefly.make_new_rule_no_group'); + if (null !== $ruleGroup) { + $subTitle = (string)trans('firefly.make_new_rule', ['title' => $ruleGroup->title]); + } + + // flash old data $request->session()->flash('preFilled', $preFilled); // put previous url in session if not redirect from store (not "create another"). @@ -132,11 +109,56 @@ class CreateController extends Controller session()->forget('rules.create.fromStore'); return view( - 'rules.rule.create', - compact( - 'subTitleIcon', 'oldTriggers', 'returnToBill', 'preFilled', 'bill', 'oldActions', 'triggerCount', 'actionCount', 'ruleGroup', - 'subTitle' - ) + 'rules.rule.create', compact('subTitleIcon', 'oldTriggers', 'preFilled', 'oldActions', 'triggerCount', 'actionCount', 'ruleGroup', 'subTitle') + ); + } + + /** + * Create a new rule. It will be stored under the given $ruleGroup. + * + * @param Request $request + * @param Bill $bill + * + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + public function createFromBill(Request $request, Bill $bill) + { + $request->session()->flash('info', (string)trans('firefly.instructions_rule_from_bill', ['name' => $bill->name])); + + $this->createDefaultRuleGroup(); + $this->createDefaultRule(); + $preFilled = [ + 'strict' => true, + 'title' => (string)trans('firefly.new_rule_for_bill_title', ['name' => $bill->name]), + 'description' => (string)trans('firefly.new_rule_for_bill_description', ['name' => $bill->name]), + ]; + + // make triggers and actions from the bill itself. + + // get triggers and actions for bill: + $oldTriggers = $this->getTriggersForBill($bill); + $oldActions = $this->getActionsForBill($bill); + + $triggerCount = \count($oldTriggers); + $actionCount = \count($oldActions); + $subTitleIcon = 'fa-clone'; + + // title depends on whether or not there is a rule group: + $subTitle = (string)trans('firefly.make_new_rule_no_group'); + + // flash old data + $request->session()->flash('preFilled', $preFilled); + + // put previous url in session if not redirect from store (not "create another"). + if (true !== session('rules.create.fromStore')) { + $this->rememberPreviousUri('rules.create.uri'); + } + session()->forget('rules.create.fromStore'); + + return view( + 'rules.rule.create', compact('subTitleIcon', 'oldTriggers', 'preFilled', 'oldActions', 'triggerCount', 'actionCount', 'subTitle') ); } @@ -184,7 +206,7 @@ class CreateController extends Controller * * @return array */ - private function getActionsForBill(Bill $bill): array + protected function getActionsForBill(Bill $bill): array // get info and augument { try { $result = view( @@ -215,7 +237,7 @@ class CreateController extends Controller * * @return array */ - private function getTriggersForBill(Bill $bill): array + protected function getTriggersForBill(Bill $bill): array // get info and augument { $result = []; $triggers = ['currency_is', 'amount_more', 'amount_less', 'description_contains']; diff --git a/app/Http/Controllers/Rule/EditController.php b/app/Http/Controllers/Rule/EditController.php index edd265844a..c4c4623e68 100644 --- a/app/Http/Controllers/Rule/EditController.php +++ b/app/Http/Controllers/Rule/EditController.php @@ -154,7 +154,7 @@ class EditController extends Controller * * @return array */ - private function getCurrentActions(Rule $rule): array + protected function getCurrentActions(Rule $rule): array // get info from object and present. { $index = 0; $actions = []; @@ -192,7 +192,7 @@ class EditController extends Controller * @return array * */ - private function getCurrentTriggers(Rule $rule): array + protected function getCurrentTriggers(Rule $rule): array // get info from object and present. { $index = 0; $triggers = []; diff --git a/app/Http/Controllers/Rule/IndexController.php b/app/Http/Controllers/Rule/IndexController.php index 5de05fc9a8..13a6d21978 100644 --- a/app/Http/Controllers/Rule/IndexController.php +++ b/app/Http/Controllers/Rule/IndexController.php @@ -130,7 +130,7 @@ class IndexController extends Controller /** * Move rule ip. - * + * * @param Rule $rule * * @return RedirectResponse|\Illuminate\Routing\Redirector diff --git a/app/Http/Controllers/Rule/SelectController.php b/app/Http/Controllers/Rule/SelectController.php index 6381fbf21c..04e30eb455 100644 --- a/app/Http/Controllers/Rule/SelectController.php +++ b/app/Http/Controllers/Rule/SelectController.php @@ -32,6 +32,7 @@ use FireflyIII\Http\Requests\TestRuleFormRequest; use FireflyIII\Jobs\ExecuteRuleOnExistingTransactions; use FireflyIII\Models\Rule; use FireflyIII\Repositories\Account\AccountRepositoryInterface; +use FireflyIII\Support\Http\Controllers\RequestInformation; use FireflyIII\Support\Http\Controllers\RuleManagement; use FireflyIII\TransactionRules\TransactionMatcher; use FireflyIII\User; @@ -48,7 +49,7 @@ use Throwable; */ class SelectController extends Controller { - use RuleManagement; + use RuleManagement, RequestInformation; /** @var AccountRepositoryInterface The account repository */ private $accountRepos; @@ -118,13 +119,12 @@ class SelectController extends Controller { // does the user have shared accounts? $first = session('first')->format('Y-m-d'); - $today = Carbon::create()->format('Y-m-d'); + $today = Carbon::now()->format('Y-m-d'); $subTitle = (string)trans('firefly.apply_rule_selection', ['title' => $rule->title]); return view('rules.rule.select-transactions', compact('first', 'today', 'rule', 'subTitle')); } - /** * This method allows the user to test a certain set of rule triggers. The rule triggers are passed along * using the URL parameters (GET), and are usually put there using a Javascript thing. @@ -152,11 +152,13 @@ class SelectController extends Controller $limit = (int)config('firefly.test-triggers.limit'); $range = (int)config('firefly.test-triggers.range'); $matchingTransactions = new Collection; + $strict = $request->get('strict') === '1'; /** @var TransactionMatcher $matcher */ $matcher = app(TransactionMatcher::class); $matcher->setLimit($limit); $matcher->setRange($range); $matcher->setTriggers($triggers); + $matcher->setStrict($strict); try { $matchingTransactions = $matcher->findTransactionsByTriggers(); // @codeCoverageIgnoreStart @@ -257,32 +259,4 @@ class SelectController extends Controller } - /** - * Get a list of triggers. - * - * @param TestRuleFormRequest $request - * - * @return array - */ - private function getValidTriggerList(TestRuleFormRequest $request): array - { - $triggers = []; - $data = [ - 'rule-triggers' => $request->get('rule-trigger'), - 'rule-trigger-values' => $request->get('rule-trigger-value'), - 'rule-trigger-stop' => $request->get('rule-trigger-stop'), - ]; - if (\is_array($data['rule-triggers'])) { - foreach ($data['rule-triggers'] as $index => $triggerType) { - $data['rule-trigger-stop'][$index] = (int)($data['rule-trigger-stop'][$index] ?? 0.0); - $triggers[] = [ - 'type' => $triggerType, - 'value' => $data['rule-trigger-values'][$index], - 'stopProcessing' => 1 === (int)$data['rule-trigger-stop'][$index], - ]; - } - } - - return $triggers; - } } diff --git a/app/Http/Controllers/RuleGroupController.php b/app/Http/Controllers/RuleGroupController.php index c814776eb8..8a99cd2ab7 100644 --- a/app/Http/Controllers/RuleGroupController.php +++ b/app/Http/Controllers/RuleGroupController.php @@ -208,7 +208,7 @@ class RuleGroupController extends Controller public function selectTransactions(RuleGroup $ruleGroup) { $first = session('first')->format('Y-m-d'); - $today = Carbon::create()->format('Y-m-d'); + $today = Carbon::now()->format('Y-m-d'); $subTitle = (string)trans('firefly.apply_rule_group_selection', ['title' => $ruleGroup->title]); return view('rules.rule-group.select-transactions', compact('first', 'today', 'ruleGroup', 'subTitle')); diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 6308bd1770..3799299e90 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -27,6 +27,8 @@ use FireflyIII\Support\Search\SearchInterface; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Collection; +use Log; +use Throwable; /** * Class SearchController. @@ -77,7 +79,6 @@ class SearchController extends Controller * @param SearchInterface $searcher * * @return \Illuminate\Http\JsonResponse - * @throws \Throwable */ public function search(Request $request, SearchInterface $searcher): JsonResponse { @@ -99,8 +100,14 @@ class SearchController extends Controller $transactions = $searcher->searchTransactions(); $cache->store($transactions); } - - $html = view('search.search', compact('transactions'))->render(); + try { + $html = view('search.search', compact('transactions'))->render(); + // @codeCoverageIgnoreStart + } catch (Throwable $e) { + Log::error(sprintf('Cannot render search.search: %s', $e->getMessage())); + $html = 'Could not render view.'; + } + // @codeCoverageIgnoreEnd return response()->json(['count' => $transactions->count(), 'html' => $html]); } diff --git a/app/Http/Controllers/System/CronController.php b/app/Http/Controllers/System/CronController.php new file mode 100644 index 0000000000..46a50c8384 --- /dev/null +++ b/app/Http/Controllers/System/CronController.php @@ -0,0 +1,65 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Http\Controllers\System; + +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Support\Cronjobs\RecurringCronjob; + +/** + * Class CronController + */ +class CronController +{ + /** + * @param string $token + * + * @return string + */ + public function cron(string $token): string + { + $results = []; + $results[] = $this->runRecurring(); + + return implode("
\n", $results); + } + + /** + * @return string + */ + private function runRecurring(): string + { + $recurring = new RecurringCronjob; + try { + $result = $recurring->fire(); + } catch (FireflyException $e) { + return $e->getMessage(); + } + if (false === $result) { + return 'The recurring transaction cron job did not fire.'; + } + + return 'The recurring transaction cron job fired successfully.'; + } + +} \ No newline at end of file diff --git a/app/Http/Controllers/System/InstallController.php b/app/Http/Controllers/System/InstallController.php index 23056a5642..3c7a2039b1 100644 --- a/app/Http/Controllers/System/InstallController.php +++ b/app/Http/Controllers/System/InstallController.php @@ -27,6 +27,7 @@ namespace FireflyIII\Http\Controllers\System; use Artisan; use Exception; use FireflyIII\Http\Controllers\Controller; +use FireflyIII\Support\Http\Controllers\GetConfigurationData; use Illuminate\Http\JsonResponse; use Laravel\Passport\Passport; use Log; @@ -34,10 +35,12 @@ use phpseclib\Crypt\RSA; /** * Class InstallController + * * @codeCoverageIgnore */ class InstallController extends Controller { + use GetConfigurationData; /** @var string Forbidden error */ public const FORBIDDEN_ERROR = 'Internal PHP function "proc_close" is disabled for your installation. Auto-migration is not possible.'; /** @var string Basedir error */ @@ -177,29 +180,5 @@ class InstallController extends Controller return response()->json(['error' => false, 'message' => 'OK']); } - /** - * Check if forbidden functions are set. - * - * @return bool - */ - private function hasForbiddenFunctions(): bool - { - $list = ['proc_close']; - $forbidden = explode(',', ini_get('disable_functions')); - $trimmed = array_map( - function (string $value) { - return trim($value); - }, $forbidden - ); - foreach ($list as $entry) { - if (\in_array($entry, $trimmed, true)) { - Log::error('Method "%s" is FORBIDDEN, so the console command cannot be executed.'); - - return true; - } - } - - return false; - } } diff --git a/app/Http/Controllers/TagController.php b/app/Http/Controllers/TagController.php index 102f1d7bff..a2731043dc 100644 --- a/app/Http/Controllers/TagController.php +++ b/app/Http/Controllers/TagController.php @@ -24,12 +24,12 @@ declare(strict_types=1); namespace FireflyIII\Http\Controllers; use Carbon\Carbon; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Helpers\Filter\InternalTransferFilter; use FireflyIII\Http\Requests\TagFormRequest; use FireflyIII\Models\Tag; use FireflyIII\Repositories\Tag\TagRepositoryInterface; -use FireflyIII\Support\CacheProperties; +use FireflyIII\Support\Http\Controllers\PeriodOverview; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -39,6 +39,8 @@ use Illuminate\Support\Collection; */ class TagController extends Controller { + use PeriodOverview; + /** @var TagRepositoryInterface The tag repository. */ protected $repository; @@ -170,72 +172,72 @@ class TagController extends Controller * * @param Request $request * @param Tag $tag - * @param string|null $moment + * @param Carbon|null $start + * @param Carbon|null $end * * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View * * @SuppressWarnings(PHPMD.ExcessiveMethodLength) * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ - public function show(Request $request, Tag $tag, string $moment = null) + public function show(Request $request, Tag $tag, Carbon $start = null, Carbon $end = null) { // default values: - $moment = $moment ?? ''; - $subTitle = $tag->tag; $subTitleIcon = 'fa-tag'; $page = (int)$request->get('page'); $pageSize = (int)app('preferences')->get('listPageSize', 50)->data; - $range = app('preferences')->get('viewRange', '1M')->data; - $start = null; - $end = null; - $periods = new Collection; - $path = route('tags.show', [$tag->id]); + $start = $start ?? session('start'); + $end = $end ?? session('end'); + $subTitle = trans( + 'firefly.journals_in_period_for_tag', ['tag' => $tag->tag, 'start' => $start->formatLocalized($this->monthAndDayFormat), + 'end' => $end->formatLocalized($this->monthAndDayFormat),] + ); + $periods = $this->getTagPeriodOverview($tag); + $path = route('tags.show', [$tag->id, $start->format('Y-m-d'), $end->format('Y-m-d')]); - // prep for "all" view. - if ('all' === $moment) { - $subTitle = (string)trans('firefly.all_journals_for_tag', ['tag' => $tag->tag]); - $start = $this->repository->firstUseDate($tag) ?? new Carbon; - $end = new Carbon; - $path = route('tags.show', [$tag->id, 'all']); - } - - // prep for "specific date" view. - if ('all' !== $moment && \strlen($moment) > 0) { - $start = new Carbon($moment); - /** @var Carbon $end */ - $end = app('navigation')->endOfPeriod($start, $range); - $subTitle = trans( - 'firefly.journals_in_period_for_tag', - ['tag' => $tag->tag, - 'start' => $start->formatLocalized($this->monthAndDayFormat), 'end' => $end->formatLocalized($this->monthAndDayFormat),] - ); - $periods = $this->getPeriodOverview($tag); - $path = route('tags.show', [$tag->id, $moment]); - } - - // prep for current period - if ('' === $moment) { - /** @var Carbon $start */ - $start = clone session('start', app('navigation')->startOfPeriod(new Carbon, $range)); - /** @var Carbon $end */ - $end = clone session('end', app('navigation')->endOfPeriod(new Carbon, $range)); - $periods = $this->getPeriodOverview($tag); - $subTitle = trans( - 'firefly.journals_in_period_for_tag', - ['tag' => $tag->tag, 'start' => $start->formatLocalized($this->monthAndDayFormat), 'end' => $end->formatLocalized($this->monthAndDayFormat)] - ); - } - - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setRange($start, $end)->setLimit($pageSize)->setPage($page)->withOpposingAccount() ->setTag($tag)->withBudgetInformation()->withCategoryInformation()->removeFilter(InternalTransferFilter::class); - $transactions = $collector->getPaginatedJournals(); + $transactions = $collector->getPaginatedTransactions(); $transactions->setPath($path); $sums = $this->repository->sumsOfTag($tag, $start, $end); - return view('tags.show', compact('tag', 'sums', 'periods', 'subTitle', 'subTitleIcon', 'transactions', 'start', 'end', 'moment')); + return view('tags.show', compact('tag', 'sums', 'periods', 'subTitle', 'subTitleIcon', 'transactions', 'start', 'end')); + } + + /** + * Show a single tag over all time. + * + * @param Request $request + * @param Tag $tag + * + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + * + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + public function showAll(Request $request, Tag $tag) + { + // default values: + $subTitleIcon = 'fa-tag'; + $page = (int)$request->get('page'); + $pageSize = (int)app('preferences')->get('listPageSize', 50)->data; + $periods = new Collection; + $subTitle = (string)trans('firefly.all_journals_for_tag', ['tag' => $tag->tag]); + $start = $this->repository->firstUseDate($tag) ?? new Carbon; + $end = new Carbon; + $path = route('tags.show', [$tag->id, 'all']); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); + $collector->setAllAssetAccounts()->setRange($start, $end)->setLimit($pageSize)->setPage($page)->withOpposingAccount() + ->setTag($tag)->withBudgetInformation()->withCategoryInformation()->removeFilter(InternalTransferFilter::class); + $transactions = $collector->getPaginatedTransactions(); + $transactions->setPath($path); + $sums = $this->repository->sumsOfTag($tag, $start, $end); + + return view('tags.show', compact('tag', 'sums', 'periods', 'subTitle', 'subTitleIcon', 'transactions', 'start', 'end')); } /** @@ -295,57 +297,5 @@ class TagController extends Controller return $redirect; } - /** - * Get overview of periods for tag. - * - * @param Tag $tag - * - * @return Collection - * - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) - */ - private function getPeriodOverview(Tag $tag): Collection - { - // get first and last tag date from tag: - $range = app('preferences')->get('viewRange', '1M')->data; - /** @var Carbon $end */ - $end = app('navigation')->endOfX($this->repository->lastUseDate($tag) ?? new Carbon, $range, null); - $start = $this->repository->firstUseDate($tag) ?? new Carbon; - - // properties for entries with their amounts. - $cache = new CacheProperties; - $cache->addProperty($start); - $cache->addProperty($end); - $cache->addProperty('tag.entries'); - $cache->addProperty($tag->id); - - if ($cache->has()) { - return $cache->get(); // @codeCoverageIgnore - } - - $collection = new Collection; - $currentEnd = clone $end; - // while end larger or equal to start - while ($currentEnd >= $start) { - $currentStart = app('navigation')->startOfPeriod($currentEnd, $range); - - // get expenses and what-not in this period and this tag. - $arr = [ - 'string' => $end->format('Y-m-d'), - 'name' => app('navigation')->periodShow($currentEnd, $range), - 'date' => clone $end, - 'spent' => $this->repository->spentInPeriod($tag, $currentStart, $currentEnd), - 'earned' => $this->repository->earnedInPeriod($tag, $currentStart, $currentEnd), - ]; - $collection->push($arr); - - /** @var Carbon $currentEnd */ - $currentEnd = clone $currentStart; - $currentEnd->subDay(); - } - $cache->store($collection); - - return $collection; - } } diff --git a/app/Http/Controllers/Transaction/ConvertController.php b/app/Http/Controllers/Transaction/ConvertController.php index 897af76c00..d1b41f4fe7 100644 --- a/app/Http/Controllers/Transaction/ConvertController.php +++ b/app/Http/Controllers/Transaction/ConvertController.php @@ -29,6 +29,7 @@ use FireflyIII\Models\TransactionJournal; use FireflyIII\Models\TransactionType; use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Repositories\Journal\JournalRepositoryInterface; +use FireflyIII\Support\Http\Controllers\ModelInformation; use Illuminate\Http\Request; use Log; use View; @@ -38,6 +39,8 @@ use View; */ class ConvertController extends Controller { + use ModelInformation; + /** @var JournalRepositoryInterface Journals and transactions overview */ private $repository; @@ -181,7 +184,8 @@ class ConvertController extends Controller * @SuppressWarnings(PHPMD.ExcessiveMethodLength) * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ - private function getDestinationAccount(TransactionJournal $journal, TransactionType $destinationType, array $data): Account + protected function getDestinationAccount(TransactionJournal $journal, TransactionType $destinationType, array $data + ): Account // helper for conversion. Get info from obj. { /** @var AccountRepositoryInterface $accountRepository */ $accountRepository = app(AccountRepositoryInterface::class); @@ -242,7 +246,8 @@ class ConvertController extends Controller * @SuppressWarnings(PHPMD.ExcessiveMethodLength) * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ - private function getSourceAccount(TransactionJournal $journal, TransactionType $destinationType, array $data): Account + protected function getSourceAccount(TransactionJournal $journal, TransactionType $destinationType, array $data + ): Account // helper for conversion. Get info from obj. { /** @var AccountRepositoryInterface $accountRepository */ $accountRepository = app(AccountRepositoryInterface::class); diff --git a/app/Http/Controllers/Transaction/LinkController.php b/app/Http/Controllers/Transaction/LinkController.php index ed11993a1e..ab938ff166 100644 --- a/app/Http/Controllers/Transaction/LinkController.php +++ b/app/Http/Controllers/Transaction/LinkController.php @@ -136,6 +136,7 @@ class LinkController extends Controller /** * Switch link from A <> B to B <> A. + * * @param TransactionJournalLink $link * * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector diff --git a/app/Http/Controllers/Transaction/MassController.php b/app/Http/Controllers/Transaction/MassController.php index 9fdf5b8437..afd7c3bda1 100644 --- a/app/Http/Controllers/Transaction/MassController.php +++ b/app/Http/Controllers/Transaction/MassController.php @@ -23,7 +23,7 @@ declare(strict_types=1); namespace FireflyIII\Http\Controllers\Transaction; use Carbon\Carbon; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Helpers\Filter\TransactionViewFilter; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Http\Requests\MassDeleteJournalRequest; @@ -141,13 +141,13 @@ class MassController extends Controller $this->rememberPreviousUri('transactions.mass-edit.uri'); $transformer = new TransactionTransformer(new ParameterBag); - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setUser($user); $collector->withOpposingAccount()->withCategoryInformation()->withBudgetInformation(); $collector->setJournals($journals); $collector->addFilter(TransactionViewFilter::class); - $collection = $collector->getJournals(); + $collection = $collector->getTransactions(); $transactions = $collection->map( function (Transaction $transaction) use ($transformer) { $transformed = $transformer->transform($transaction); diff --git a/app/Http/Controllers/Transaction/SingleController.php b/app/Http/Controllers/Transaction/SingleController.php index 67ca017160..71f4a1df21 100644 --- a/app/Http/Controllers/Transaction/SingleController.php +++ b/app/Http/Controllers/Transaction/SingleController.php @@ -33,6 +33,7 @@ use FireflyIII\Models\TransactionJournal; use FireflyIII\Models\TransactionType; use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; use FireflyIII\Repositories\Journal\JournalRepositoryInterface; +use FireflyIII\Support\Http\Controllers\ModelInformation; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Log; @@ -45,6 +46,8 @@ use View; */ class SingleController extends Controller { + use ModelInformation; + /** @var AttachmentHelperInterface The attachment helper. */ private $attachments; /** @var BudgetRepositoryInterface The budget repository */ @@ -366,6 +369,7 @@ class SingleController extends Controller event(new StoredTransactionJournal($journal, $data['piggy_bank_id'])); + session()->flash('success_uri', route('transactions.show', [$journal->id])); session()->flash('success', (string)trans('firefly.stored_journal', ['description' => $journal->description])); app('preferences')->mark(); @@ -442,18 +446,4 @@ class SingleController extends Controller // redirect to previous URL. return redirect($this->getPreviousUri('transactions.edit.uri')); } - - /** - * Checks if journal is split. - * - * @param TransactionJournal $journal - * - * @return bool - */ - private function isSplitJournal(TransactionJournal $journal): bool - { - $count = $this->repository->countTransactions($journal); - - return $count > 2; - } } diff --git a/app/Http/Controllers/Transaction/SplitController.php b/app/Http/Controllers/Transaction/SplitController.php index d407c4b4c5..2f8ecf1b15 100644 --- a/app/Http/Controllers/Transaction/SplitController.php +++ b/app/Http/Controllers/Transaction/SplitController.php @@ -25,19 +25,15 @@ namespace FireflyIII\Http\Controllers\Transaction; use FireflyIII\Events\UpdatedTransactionJournal; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Helpers\Attachments\AttachmentHelperInterface; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Http\Requests\SplitJournalFormRequest; -use FireflyIII\Models\Transaction; use FireflyIII\Models\TransactionJournal; -use FireflyIII\Models\TransactionType; use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface; use FireflyIII\Repositories\Journal\JournalRepositoryInterface; -use FireflyIII\Transformers\TransactionTransformer; +use FireflyIII\Support\Http\Controllers\ModelInformation; +use FireflyIII\Support\Http\Controllers\RequestInformation; use Illuminate\Http\Request; -use Illuminate\Support\Collection; -use Symfony\Component\HttpFoundation\ParameterBag; use View; /** @@ -47,6 +43,8 @@ use View; */ class SplitController extends Controller { + use ModelInformation, RequestInformation; + /** @var AttachmentHelperInterface Attachment helper */ private $attachments; @@ -173,95 +171,6 @@ class SplitController extends Controller return redirect($this->getPreviousUri('transactions.edit-split.uri')); } - /** - * Create data-array from a journal. - * - * @param SplitJournalFormRequest|Request $request - * @param TransactionJournal $journal - * - * @return array - * @throws FireflyException - */ - private function arrayFromJournal(Request $request, TransactionJournal $journal): array - { - $sourceAccounts = $this->repository->getJournalSourceAccounts($journal); - $destinationAccounts = $this->repository->getJournalDestinationAccounts($journal); - $array = [ - 'journal_description' => $request->old('journal_description', $journal->description), - 'journal_amount' => '0', - 'journal_foreign_amount' => '0', - 'sourceAccounts' => $sourceAccounts, - 'journal_source_id' => $request->old('journal_source_id', $sourceAccounts->first()->id), - 'journal_source_name' => $request->old('journal_source_name', $sourceAccounts->first()->name), - 'journal_destination_id' => $request->old('journal_destination_id', $destinationAccounts->first()->id), - 'destinationAccounts' => $destinationAccounts, - 'what' => strtolower($this->repository->getTransactionType($journal)), - 'date' => $request->old('date', $this->repository->getJournalDate($journal, null)), - 'tags' => implode(',', $journal->tags->pluck('tag')->toArray()), - - // all custom fields: - 'interest_date' => $request->old('interest_date', $this->repository->getMetaField($journal, 'interest_date')), - 'book_date' => $request->old('book_date', $this->repository->getMetaField($journal, 'book_date')), - 'process_date' => $request->old('process_date', $this->repository->getMetaField($journal, 'process_date')), - 'due_date' => $request->old('due_date', $this->repository->getMetaField($journal, 'due_date')), - 'payment_date' => $request->old('payment_date', $this->repository->getMetaField($journal, 'payment_date')), - 'invoice_date' => $request->old('invoice_date', $this->repository->getMetaField($journal, 'invoice_date')), - 'internal_reference' => $request->old('internal_reference', $this->repository->getMetaField($journal, 'internal_reference')), - 'notes' => $request->old('notes', $this->repository->getNoteText($journal)), - - // transactions. - 'transactions' => $this->getTransactionDataFromJournal($journal), - ]; - // update transactions array with old request data. - $array['transactions'] = $this->updateWithPrevious($array['transactions'], $request->old()); - - // update journal amount and foreign amount: - $array['journal_amount'] = array_sum(array_column($array['transactions'], 'amount')); - $array['journal_foreign_amount'] = array_sum(array_column($array['transactions'], 'foreign_amount')); - - return $array; - } - - /** - * Get transaction overview from journal. - * - * @param TransactionJournal $journal - * - * @return array - * @throws FireflyException - * - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - */ - private function getTransactionDataFromJournal(TransactionJournal $journal): array - { - // use collector to collect transactions. - $collector = app(JournalCollectorInterface::class); - $collector->setUser(auth()->user()); - $collector->withOpposingAccount()->withCategoryInformation()->withBudgetInformation(); - // filter on specific journals. - $collector->setJournals(new Collection([$journal])); - $set = $collector->getJournals(); - $transactions = []; - $transformer = new TransactionTransformer(new ParameterBag); - /** @var Transaction $transaction */ - foreach ($set as $transaction) { - $res = []; - if ((float)$transaction->transaction_amount > 0 && $journal->transactionType->type === TransactionType::DEPOSIT) { - $res = $transformer->transform($transaction); - } - if ((float)$transaction->transaction_amount < 0 && $journal->transactionType->type !== TransactionType::DEPOSIT) { - $res = $transformer->transform($transaction); - } - - if (\count($res) > 0) { - $res['amount'] = app('steam')->positive((string)$res['amount']); - $res['foreign_amount'] = app('steam')->positive((string)$res['foreign_amount']); - $transactions[] = $res; - } - } - - return $transactions; - } /** * Get info from old input. @@ -274,7 +183,7 @@ class SplitController extends Controller * @SuppressWarnings(PHPMD.ExcessiveMethodLength) * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ - private function updateWithPrevious($array, $old): array + protected function updateWithPrevious($array, $old): array // update object with new info { if (0 === \count($old) || !isset($old['transactions'])) { return $array; diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php index c9685012a6..e6f19afc26 100644 --- a/app/Http/Controllers/TransactionController.php +++ b/app/Http/Controllers/TransactionController.php @@ -26,7 +26,7 @@ namespace FireflyIII\Http\Controllers; use Carbon\Carbon; use FireflyIII\Exceptions\FireflyException; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Helpers\Filter\CountAttachmentsFilter; use FireflyIII\Helpers\Filter\InternalTransferFilter; use FireflyIII\Helpers\Filter\SplitIndicatorFilter; @@ -35,6 +35,8 @@ use FireflyIII\Models\TransactionJournal; use FireflyIII\Models\TransactionType; use FireflyIII\Repositories\Journal\JournalRepositoryInterface; use FireflyIII\Repositories\LinkType\LinkTypeRepositoryInterface; +use FireflyIII\Support\Http\Controllers\ModelInformation; +use FireflyIII\Support\Http\Controllers\PeriodOverview; use FireflyIII\Transformers\TransactionTransformer; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -50,6 +52,7 @@ use View; */ class TransactionController extends Controller { + use ModelInformation, PeriodOverview; /** @var JournalRepositoryInterface Journals and transactions overview */ private $repository; @@ -104,17 +107,17 @@ class TransactionController extends Controller $startStr = $start->formatLocalized($this->monthAndDayFormat); $endStr = $end->formatLocalized($this->monthAndDayFormat); $subTitle = (string)trans('firefly.title_' . $what . '_between', ['start' => $startStr, 'end' => $endStr]); - $periods = $this->getPeriodOverview($what, $end); + $periods = $this->getTransactionPeriodOverview($what, $end); - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setRange($start, $end) ->setTypes($types)->setLimit($pageSize)->setPage($page)->withOpposingAccount() ->withBudgetInformation()->withCategoryInformation(); $collector->removeFilter(InternalTransferFilter::class); $collector->addFilter(SplitIndicatorFilter::class); $collector->addFilter(CountAttachmentsFilter::class); - $transactions = $collector->getPaginatedJournals(); + $transactions = $collector->getPaginatedTransactions(); $transactions->setPath($path); return view('transactions.index', compact('subTitle', 'what', 'subTitleIcon', 'transactions', 'periods', 'start', 'end')); @@ -140,15 +143,15 @@ class TransactionController extends Controller $end = new Carbon; $subTitle = (string)trans('firefly.all_' . $what); - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setRange($start, $end) ->setTypes($types)->setLimit($pageSize)->setPage($page)->withOpposingAccount() ->withBudgetInformation()->withCategoryInformation(); $collector->removeFilter(InternalTransferFilter::class); $collector->addFilter(SplitIndicatorFilter::class); $collector->addFilter(CountAttachmentsFilter::class); - $transactions = $collector->getPaginatedJournals(); + $transactions = $collector->getPaginatedTransactions(); $transactions->setPath($path); return view('transactions.index', compact('subTitle', 'what', 'subTitleIcon', 'transactions', 'start', 'end')); @@ -226,12 +229,12 @@ class TransactionController extends Controller $links = $linkTypeRepository->getLinks($journal); // get transactions using the collector: - $collector = app(JournalCollectorInterface::class); + $collector = app(TransactionCollectorInterface::class); $collector->setUser(auth()->user()); $collector->withOpposingAccount()->withCategoryInformation()->withBudgetInformation(); // filter on specific journals. $collector->setJournals(new Collection([$journal])); - $set = $collector->getJournals(); + $set = $collector->getTransactions(); $transactions = []; $transformer = new TransactionTransformer(new ParameterBag); /** @var Transaction $transaction */ @@ -246,98 +249,5 @@ class TransactionController extends Controller return view('transactions.show', compact('journal', 'events', 'subTitle', 'what', 'transactions', 'linkTypes', 'links')); } - /** - * Get period overview for index. - * - * @param string $what - * - * @param Carbon $date - * - * @return Collection - * - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - */ - private function getPeriodOverview(string $what, Carbon $date): Collection - { - $range = app('preferences')->get('viewRange', '1M')->data; - $first = $this->repository->firstNull(); - $start = Carbon::create()->subYear(); - $types = config('firefly.transactionTypesByWhat.' . $what); - $entries = new Collection; - if (null !== $first) { - $start = $first->date; - } - if ($date < $start) { - [$start, $date] = [$date, $start]; // @codeCoverageIgnore - } - /** @var array $dates */ - $dates = app('navigation')->blockPeriods($start, $date, $range); - - foreach ($dates as $currentDate) { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); - $collector->setAllAssetAccounts()->setRange($currentDate['start'], $currentDate['end'])->withOpposingAccount()->setTypes($types); - $collector->removeFilter(InternalTransferFilter::class); - $journals = $collector->getJournals(); - - if ($journals->count() > 0) { - $sums = $this->sumPerCurrency($journals); - $dateName = app('navigation')->periodShow($currentDate['start'], $currentDate['period']); - $sum = $journals->sum('transaction_amount'); - /** @noinspection PhpUndefinedMethodInspection */ - $entries->push( - [ - 'name' => $dateName, - 'sums' => $sums, - 'sum' => $sum, - 'start' => $currentDate['start']->format('Y-m-d'), - 'end' => $currentDate['end']->format('Y-m-d'), - ] - ); - } - } - - return $entries; - } - - /** - * Collect the sum per currency. - * - * @param Collection $collection - * - * @return array - */ - private function sumPerCurrency(Collection $collection): array - { - $return = []; - /** @var Transaction $transaction */ - foreach ($collection as $transaction) { - $currencyId = (int)$transaction->transaction_currency_id; - - // save currency information: - if (!isset($return[$currencyId])) { - $currencySymbol = $transaction->transaction_currency_symbol; - $decimalPlaces = $transaction->transaction_currency_dp; - $currencyCode = $transaction->transaction_currency_code; - $return[$currencyId] = [ - 'currency' => [ - 'id' => $currencyId, - 'code' => $currencyCode, - 'symbol' => $currencySymbol, - 'dp' => $decimalPlaces, - ], - 'sum' => '0', - 'count' => 0, - ]; - } - // save amount: - $return[$currencyId]['sum'] = bcadd($return[$currencyId]['sum'], $transaction->transaction_amount); - ++$return[$currencyId]['count']; - } - asort($return); - - return $return; - } } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index b21c6bf743..90ad53be88 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -32,6 +32,7 @@ use FireflyIII\Http\Middleware\Range; use FireflyIII\Http\Middleware\RedirectIfAuthenticated; use FireflyIII\Http\Middleware\RedirectIfTwoFactorAuthenticated; use FireflyIII\Http\Middleware\Sandstorm; +use FireflyIII\Http\Middleware\SecureHeaders; use FireflyIII\Http\Middleware\StartFireflySession; use FireflyIII\Http\Middleware\TrimStrings; use FireflyIII\Http\Middleware\TrustProxies; @@ -63,6 +64,7 @@ class Kernel extends HttpKernel */ protected $middleware = [ + SecureHeaders::class, CheckForMaintenanceMode::class, ValidatePostSize::class, TrimStrings::class, @@ -80,7 +82,7 @@ class Kernel extends HttpKernel // does not check login // does not check 2fa // does not check activation - 'web' => [ + 'web' => [ Sandstorm::class, EncryptCookies::class, AddQueuedCookiesToResponse::class, @@ -90,6 +92,14 @@ class Kernel extends HttpKernel CreateFreshApiToken::class, ], + // only the basic variable binders. + 'binders-only' => [ + Installer::class, + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + Binder::class, + ], + // MUST NOT be logged in. Does not care about 2FA or confirmation. 'user-not-logged-in' => [ Installer::class, diff --git a/app/Http/Middleware/SecureHeaders.php b/app/Http/Middleware/SecureHeaders.php new file mode 100644 index 0000000000..f958484a3b --- /dev/null +++ b/app/Http/Middleware/SecureHeaders.php @@ -0,0 +1,88 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Http\Middleware; + +use Closure; +use Illuminate\Http\Request; + +/** + * + * Class SecureHeaders + */ +class SecureHeaders +{ + /** + * Handle an incoming request. May not be a limited user (ie. Sandstorm env. or demo user). + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @param string|null $guard + * + * @return mixed + */ + public function handle(Request $request, Closure $next) + { + $response = $next($request); + $google = ''; + $analyticsId = env('ANALYTICS_ID', ''); + if ('' !== $analyticsId) { + $google = 'https://www.google-analytics.com/analytics.js'; + } + $csp = [ + "default-src 'none'", + sprintf("script-src 'self' 'unsafe-eval' 'unsafe-inline' %s", $google), + "style-src 'self' 'unsafe-inline'", + "base-uri 'self'", + "form-action 'self'", + "font-src 'self'", + "connect-src 'self'", + "img-src 'self'", + ]; + + $featurePolicies = [ + "geolocation 'none'", + "midi 'none'", + //"notifications 'none'", + //"push 'self'", + "sync-xhr 'self'", + "microphone 'none'", + "camera 'none'", + "magnetometer 'none'", + "gyroscope 'none'", + "speaker 'none'", + //"vibrate 'none'", + "fullscreen 'self'", + "payment 'none'", + ]; + + $response->header('X-Frame-Options', 'deny'); + $response->header('Content-Security-Policy', implode('; ', $csp)); + $response->header('X-XSS-Protection', '1; mode=block'); + $response->header('X-Content-Type-Options', 'nosniff'); + $response->header('Referrer-Policy', 'no-referrer'); + $response->header('Feature-Policy', implode('; ', $featurePolicies)); + + return $response; + } +} \ No newline at end of file diff --git a/app/Http/Middleware/TrustProxies.php b/app/Http/Middleware/TrustProxies.php index 3377dd6720..59900edc22 100644 --- a/app/Http/Middleware/TrustProxies.php +++ b/app/Http/Middleware/TrustProxies.php @@ -45,7 +45,7 @@ class TrustProxies extends Middleware { $trustedProxies = (string)env('TRUSTED_PROXIES', null); $this->proxies = explode(',', $trustedProxies); - if ($trustedProxies === '**') { + if ('**' === $trustedProxies) { $this->proxies = '**'; } parent::__construct($config); diff --git a/app/Http/Requests/AccountFormRequest.php b/app/Http/Requests/AccountFormRequest.php index f79500515a..3d01ff4a3c 100644 --- a/app/Http/Requests/AccountFormRequest.php +++ b/app/Http/Requests/AccountFormRequest.php @@ -48,7 +48,7 @@ class AccountFormRequest extends Request */ public function getAccountData(): array { - return [ + $data = [ 'name' => $this->string('name'), 'active' => $this->boolean('active'), 'accountType' => $this->string('what'), @@ -64,7 +64,26 @@ class AccountFormRequest extends Request 'ccType' => $this->string('ccType'), 'ccMonthlyPaymentDate' => $this->string('ccMonthlyPaymentDate'), 'notes' => $this->string('notes'), + 'interest' => $this->string('interest'), + 'interest_period' => $this->string('interest_period'), + 'include_net_worth' => '1', ]; + if (false === $this->boolean('include_net_worth')) { + $data['include_net_worth'] = '0'; + } + + // if the account type is "liabilities" there are actually four types of liability + // that could have been selected. + if ('liabilities' === $data['accountType']) { + $data['accountType'] = null; + $data['account_type_id'] = $this->integer('liability_type_id'); + // also reverse the opening balance: + if ('' !== $data['openingBalance']) { + $data['openingBalance'] = bcmul($data['openingBalance'], '-1'); + } + } + + return $data; } /** @@ -93,14 +112,20 @@ class AccountFormRequest extends Request 'amount_currency_id_openingBalance' => 'exists:transaction_currencies,id', 'amount_currency_id_virtualBalance' => 'exists:transaction_currencies,id', 'what' => 'in:' . $types, + 'interest_period' => 'in:daily,monthly,yearly', ]; + if ('liabilities' === $this->get('what')) { + $rules['openingBalance'] = 'numeric|required|more:0'; + $rules['openingBalanceDate'] = 'date|required'; + } + /** @var Account $account */ $account = $this->route()->parameter('account'); if (null !== $account) { // add rules: $rules['id'] = 'belongsToUser:accounts'; - $rules['name'] = 'required|min:1|uniqueAccountForUser:' . (int)$this->get('id'); + $rules['name'] = 'required|min:1|uniqueAccountForUser:' . $account->id; $rules['iban'] = ['iban', 'nullable', new UniqueIban($account, $account->accountType->type)]; } diff --git a/app/Http/Requests/BillFormRequest.php b/app/Http/Requests/BillFormRequest.php index 59d7eaae1a..464a2e1167 100644 --- a/app/Http/Requests/BillFormRequest.php +++ b/app/Http/Requests/BillFormRequest.php @@ -22,6 +22,8 @@ declare(strict_types=1); namespace FireflyIII\Http\Requests; +use FireflyIII\Models\Bill; + /** * Class BillFormRequest. */ @@ -46,15 +48,16 @@ class BillFormRequest extends Request public function getBillData(): array { return [ - 'name' => $this->string('name'), - 'amount_min' => $this->string('amount_min'), - 'transaction_currency_id' => $this->integer('transaction_currency_id'), - 'amount_max' => $this->string('amount_max'), - 'date' => $this->date('date'), - 'repeat_freq' => $this->string('repeat_freq'), - 'skip' => $this->integer('skip'), - 'notes' => $this->string('notes'), - 'active' => $this->boolean('active'), + 'name' => $this->string('name'), + 'amount_min' => $this->string('amount_min'), + 'currency_id' => $this->integer('transaction_currency_id'), + 'currency_code' => '', + 'amount_max' => $this->string('amount_max'), + 'date' => $this->date('date'), + 'repeat_freq' => $this->string('repeat_freq'), + 'skip' => $this->integer('skip'), + 'notes' => $this->string('notes'), + 'active' => $this->boolean('active'), ]; } @@ -66,8 +69,10 @@ class BillFormRequest extends Request public function rules(): array { $nameRule = 'required|between:1,255|uniqueObjectForUser:bills,name'; - if ($this->integer('id') > 0) { - $nameRule .= ',' . $this->integer('id'); + /** @var Bill $bill */ + $bill = $this->route()->parameter('bill'); + if (null !== $bill) { + $nameRule = 'required|between:1,255|uniqueObjectForUser:bills,name,' . $bill->id; } // is OK $rules = [ diff --git a/app/Http/Requests/BudgetFormRequest.php b/app/Http/Requests/BudgetFormRequest.php index b087071a92..c803cdaaaa 100644 --- a/app/Http/Requests/BudgetFormRequest.php +++ b/app/Http/Requests/BudgetFormRequest.php @@ -22,7 +22,7 @@ declare(strict_types=1); namespace FireflyIII\Http\Requests; -use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; +use FireflyIII\Models\Budget; /** * Class BudgetFormRequest. @@ -61,12 +61,13 @@ class BudgetFormRequest extends Request */ public function rules(): array { - // fixed - /** @var BudgetRepositoryInterface $repository */ - $repository = app(BudgetRepositoryInterface::class); - $nameRule = 'required|between:1,100|uniqueObjectForUser:budgets,name'; - if (null !== $repository->findNull((int)$this->get('id'))) { - $nameRule = 'required|between:1,100|uniqueObjectForUser:budgets,name,' . (int)$this->get('id'); + $nameRule = 'required|between:1,100|uniqueObjectForUser:budgets,name'; + + /** @var Budget $budget */ + $budget = $this->route()->parameter('budget'); + + if (null !== $budget) { + $nameRule = 'required|between:1,100|uniqueObjectForUser:budgets,name,' . $budget->id; } return [ diff --git a/app/Http/Requests/CategoryFormRequest.php b/app/Http/Requests/CategoryFormRequest.php index bb5bb973f6..1cef099368 100644 --- a/app/Http/Requests/CategoryFormRequest.php +++ b/app/Http/Requests/CategoryFormRequest.php @@ -22,7 +22,7 @@ declare(strict_types=1); namespace FireflyIII\Http\Requests; -use FireflyIII\Repositories\Category\CategoryRepositoryInterface; +use FireflyIII\Models\Category; /** * Class CategoryFormRequest. @@ -59,11 +59,12 @@ class CategoryFormRequest extends Request */ public function rules(): array { - /** @var CategoryRepositoryInterface $repository */ - $repository = app(CategoryRepositoryInterface::class); - $nameRule = 'required|between:1,100|uniqueObjectForUser:categories,name'; - if (null !== $repository->findNull($this->integer('id'))) { - $nameRule = 'required|between:1,100|uniqueObjectForUser:categories,name,' . $this->integer('id'); + $nameRule = 'required|between:1,100|uniqueObjectForUser:categories,name'; + /** @var Category $category */ + $category = $this->route()->parameter('category'); + + if (null !== $category) { + $nameRule = 'required|between:1,100|uniqueObjectForUser:categories,name,' . $category->id; } // fixed diff --git a/app/Http/Requests/CurrencyFormRequest.php b/app/Http/Requests/CurrencyFormRequest.php index 49ba96a5c5..82a4b1bc6b 100644 --- a/app/Http/Requests/CurrencyFormRequest.php +++ b/app/Http/Requests/CurrencyFormRequest.php @@ -22,6 +22,8 @@ declare(strict_types=1); namespace FireflyIII\Http\Requests; +use FireflyIII\Models\TransactionCurrency; + /** * Class CurrencyFormRequest. */ @@ -67,7 +69,11 @@ class CurrencyFormRequest extends Request 'symbol' => 'required|min:1|max:8|unique:transaction_currencies,symbol', 'decimal_places' => 'required|min:0|max:12|numeric', ]; - if ($this->integer('id') > 0) { + + /** @var TransactionCurrency $currency */ + $currency = $this->route()->parameter('currency'); + + if (null !== $currency) { $rules = [ 'name' => 'required|max:48|min:1', 'code' => 'required|min:3|max:3', diff --git a/app/Http/Requests/ExportFormRequest.php b/app/Http/Requests/ExportFormRequest.php index 942aca6870..d19441209c 100644 --- a/app/Http/Requests/ExportFormRequest.php +++ b/app/Http/Requests/ExportFormRequest.php @@ -50,7 +50,7 @@ class ExportFormRequest extends Request /** @var Carbon $sessionFirst */ $sessionFirst = clone session('first'); $first = $sessionFirst->subDay()->format('Y-m-d'); - $today = Carbon::create()->addDay()->format('Y-m-d'); + $today = Carbon::now()->addDay()->format('Y-m-d'); $formats = implode(',', array_keys(config('firefly.export_formats'))); // fixed diff --git a/app/Http/Requests/JournalFormRequest.php b/app/Http/Requests/JournalFormRequest.php index e63b041a1f..ee45cfe6ce 100644 --- a/app/Http/Requests/JournalFormRequest.php +++ b/app/Http/Requests/JournalFormRequest.php @@ -270,7 +270,7 @@ class JournalFormRequest extends Request */ private function validateDeposit(Validator $validator): void { - $data = $validator->getData(); + $data = $validator->getData(); $selectedCurrency = (int)($data['amount_currency_id_amount'] ?? 0); $accountCurrency = (int)($data['destination_account_currency'] ?? 0); $nativeAmount = (string)($data['native_amount'] ?? ''); @@ -290,7 +290,7 @@ class JournalFormRequest extends Request */ private function validateTransfer(Validator $validator): void { - $data = $validator->getData(); + $data = $validator->getData(); $sourceCurrency = (int)($data['source_account_currency'] ?? 0); $destinationCurrency = (int)($data['destination_account_currency'] ?? 0); $sourceAmount = (string)($data['source_amount'] ?? ''); diff --git a/app/Http/Requests/JournalLinkRequest.php b/app/Http/Requests/JournalLinkRequest.php index a9b3a0d6a5..9e99a3f43f 100644 --- a/app/Http/Requests/JournalLinkRequest.php +++ b/app/Http/Requests/JournalLinkRequest.php @@ -82,7 +82,6 @@ class JournalLinkRequest extends Request // fixed return [ 'link_type' => sprintf('required|in:%s', $string), - 'link_other' => 'belongsToUser:transaction_journals', 'link_journal_id' => 'belongsToUser:transaction_journals', ]; } diff --git a/app/Http/Requests/PiggyBankFormRequest.php b/app/Http/Requests/PiggyBankFormRequest.php index 6aa7ad5d9f..6325eddc11 100644 --- a/app/Http/Requests/PiggyBankFormRequest.php +++ b/app/Http/Requests/PiggyBankFormRequest.php @@ -22,6 +22,8 @@ declare(strict_types=1); namespace FireflyIII\Http\Requests; +use FireflyIII\Models\PiggyBank; + /** * Class PiggyBankFormRequest. */ @@ -51,7 +53,7 @@ class PiggyBankFormRequest extends Request 'account_id' => $this->integer('account_id'), 'targetamount' => $this->string('targetamount'), 'targetdate' => $this->date('targetdate'), - 'note' => $this->string('note'), + 'notes' => $this->string('notes'), ]; } @@ -63,8 +65,12 @@ class PiggyBankFormRequest extends Request public function rules(): array { $nameRule = 'required|between:1,255|uniquePiggyBankForUser'; - if ($this->integer('id')) { - $nameRule = 'required|between:1,255|uniquePiggyBankForUser:' . $this->integer('id'); + + /** @var PiggyBank $piggy */ + $piggy = $this->route()->parameter('piggyBank'); + + if (null !== $piggy) { + $nameRule = 'required|between:1,255|uniquePiggyBankForUser:' . $piggy->id; } $rules = [ diff --git a/app/Http/Requests/RecurrenceFormRequest.php b/app/Http/Requests/RecurrenceFormRequest.php index 47c389922e..a20ffccbe1 100644 --- a/app/Http/Requests/RecurrenceFormRequest.php +++ b/app/Http/Requests/RecurrenceFormRequest.php @@ -149,8 +149,8 @@ class RecurrenceFormRequest extends Request public function rules(): array { $today = new Carbon; - $tomorrow = Carbon::create()->addDay(); - $rules = [ + $tomorrow = Carbon::now()->addDay(); + $rules = [ // mandatory info for recurrence. 'title' => 'required|between:1,255|uniqueObjectForUser:recurrences,title', 'first_date' => 'required|date|after:' . $today->format('Y-m-d'), diff --git a/app/Http/Requests/ReportFormRequest.php b/app/Http/Requests/ReportFormRequest.php index b59e8dbec5..9d9707b4f1 100644 --- a/app/Http/Requests/ReportFormRequest.php +++ b/app/Http/Requests/ReportFormRequest.php @@ -213,6 +213,12 @@ class ReportFormRequest extends Request $tag = $repository->findByTag($tagTag); if (null !== $tag) { $collection->push($tag); + continue; + } + $tag = $repository->findNull((int)$tagTag); + if (null !== $tag) { + $collection->push($tag); + continue; } } } diff --git a/app/Http/Requests/Request.php b/app/Http/Requests/Request.php index f03a05093d..9a1873fbdd 100644 --- a/app/Http/Requests/Request.php +++ b/app/Http/Requests/Request.php @@ -61,9 +61,10 @@ class Request extends FormRequest public function float(string $field): ?float { $res = $this->get($field); - if(null === $res) { + if (null === $res) { return null; } + return (float)$res; } diff --git a/app/Http/Requests/RuleFormRequest.php b/app/Http/Requests/RuleFormRequest.php index 84a6e3fe30..40df4da5d8 100644 --- a/app/Http/Requests/RuleFormRequest.php +++ b/app/Http/Requests/RuleFormRequest.php @@ -22,7 +22,7 @@ declare(strict_types=1); namespace FireflyIII\Http\Requests; -use FireflyIII\Repositories\Rule\RuleRepositoryInterface; +use FireflyIII\Models\Rule; /** * Class RuleFormRequest. @@ -50,44 +50,17 @@ class RuleFormRequest extends Request */ public function getRuleData(): array { - $data = [ + $data = [ 'title' => $this->string('title'), 'rule_group_id' => $this->integer('rule_group_id'), 'active' => $this->boolean('active'), 'trigger' => $this->string('trigger'), 'description' => $this->string('description'), - 'stop-processing' => $this->boolean('stop_processing'), + 'stop_processing' => $this->boolean('stop_processing'), 'strict' => $this->boolean('strict'), - 'rule-triggers' => [], - 'rule-actions' => [], + 'rule_triggers' => $this->getRuleTriggerData(), + 'rule_actions' => $this->getRuleActionData(), ]; - $triggers = $this->get('rule-trigger'); - $triggerValues = $this->get('rule-trigger-value'); - $triggerStop = $this->get('rule-trigger-stop'); - - $actions = $this->get('rule-action'); - $actionValues = $this->get('rule-action-value'); - $actionStop = $this->get('rule-action-stop'); - - if (\is_array($triggers)) { - foreach ($triggers as $index => $value) { - $data['rule-triggers'][] = [ - 'name' => $value, - 'value' => $triggerValues[$index] ?? '', - 'stop-processing' => 1 === (int)($triggerStop[$index] ?? 0), - ]; - } - } - - if (\is_array($actions)) { - foreach ($actions as $index => $value) { - $data['rule-actions'][] = [ - 'name' => $value, - 'value' => $actionValues[$index] ?? '', - 'stop-processing' => 1 === (int)($actionStop[$index] ?? 0), - ]; - } - } return $data; } @@ -99,8 +72,6 @@ class RuleFormRequest extends Request */ public function rules(): array { - /** @var RuleRepositoryInterface $repository */ - $repository = app(RuleRepositoryInterface::class); $validTriggers = array_keys(config('firefly.rule-triggers')); $validActions = array_keys(config('firefly.rule-actions')); @@ -108,25 +79,72 @@ class RuleFormRequest extends Request $contextActions = implode(',', config('firefly.rule-actions-text')); $titleRule = 'required|between:1,100|uniqueObjectForUser:rules,title'; - if (null !== $repository->find((int)$this->get('id'))) { - $titleRule = 'required|between:1,100|uniqueObjectForUser:rules,title,' . (int)$this->get('id'); + /** @var Rule $rule */ + $rule = $this->route()->parameter('rule'); + + if (null !== $rule) { + $titleRule = 'required|between:1,100|uniqueObjectForUser:rules,title,' . $rule->id; } $rules = [ - 'title' => $titleRule, - 'description' => 'between:1,5000|nullable', - 'stop_processing' => 'boolean', - 'rule_group_id' => 'required|belongsToUser:rule_groups', - 'trigger' => 'required|in:store-journal,update-journal', - 'rule-trigger.*' => 'required|in:' . implode(',', $validTriggers), - 'rule-trigger-value.*' => 'required|min:1|ruleTriggerValue', - 'rule-action.*' => 'required|in:' . implode(',', $validActions), - 'strict' => 'in:0,1', + 'title' => $titleRule, + 'description' => 'between:1,5000|nullable', + 'stop_processing' => 'boolean', + 'rule_group_id' => 'required|belongsToUser:rule_groups', + 'trigger' => 'required|in:store-journal,update-journal', + 'rule_triggers.*.name' => 'required|in:' . implode(',', $validTriggers), + 'rule_triggers.*.value' => 'required|min:1|ruleTriggerValue', + 'rule-actions.*.name' => 'required|in:' . implode(',', $validActions), + 'strict' => 'in:0,1', ]; // since Laravel does not support this stuff yet, here's a trick. for ($i = 0; $i < 10; ++$i) { - $rules['rule-action-value.' . $i] = 'required_if:rule-action.' . $i . ',' . $contextActions . '|ruleActionValue'; + $key = sprintf('rule_actions.%d.value', $i); + $rule = sprintf('required-if:rule_actions.%d.name,%s|ruleActionValue', $i, $contextActions); + $rules[$key] = $rule; } return $rules; } + + /** + * @return array + */ + private function getRuleActionData(): array + { + $return = []; + $actionData = $this->get('rule_actions'); + if (\is_array($actionData)) { + foreach ($actionData as $action) { + $stopProcessing = $action['stop_processing'] ?? '0'; + $return[] = [ + 'name' => $action['name'] ?? 'invalid', + 'value' => $action['value'] ?? '', + 'stop_processing' => 1 === (int)$stopProcessing, + ]; + } + } + + return $return; + } + + /** + * @return array + */ + private function getRuleTriggerData(): array + { + $return = []; + $triggerData = $this->get('rule_triggers'); + if (\is_array($triggerData)) { + foreach ($triggerData as $trigger) { + $stopProcessing = $trigger['stop_processing'] ?? '0'; + $return[] = [ + 'name' => $trigger['name'] ?? 'invalid', + 'value' => $trigger['value'] ?? '', + 'stop_processing' => 1 === (int)$stopProcessing, + ]; + } + } + + return $return; + } } diff --git a/app/Http/Requests/RuleGroupFormRequest.php b/app/Http/Requests/RuleGroupFormRequest.php index e1c29c6ba4..671607eabb 100644 --- a/app/Http/Requests/RuleGroupFormRequest.php +++ b/app/Http/Requests/RuleGroupFormRequest.php @@ -22,7 +22,7 @@ declare(strict_types=1); namespace FireflyIII\Http\Requests; -use FireflyIII\Repositories\RuleGroup\RuleGroupRepositoryInterface; +use FireflyIII\Models\RuleGroup; /** * Class RuleGroupFormRequest. @@ -60,12 +60,13 @@ class RuleGroupFormRequest extends Request */ public function rules(): array { - // fixed - /** @var RuleGroupRepositoryInterface $repository */ - $repository = app(RuleGroupRepositoryInterface::class); - $titleRule = 'required|between:1,100|uniqueObjectForUser:rule_groups,title'; - if (null !== $repository->find((int)$this->get('id'))) { - $titleRule = 'required|between:1,100|uniqueObjectForUser:rule_groups,title,' . (int)$this->get('id'); + $titleRule = 'required|between:1,100|uniqueObjectForUser:rule_groups,title'; + + /** @var RuleGroup $ruleGroup */ + $ruleGroup = $this->route()->parameter('ruleGroup'); + + if (null !== $ruleGroup) { + $titleRule = 'required|between:1,100|uniqueObjectForUser:rule_groups,title,' . $ruleGroup->id; } return [ diff --git a/app/Http/Requests/SelectTransactionsRequest.php b/app/Http/Requests/SelectTransactionsRequest.php index 9207a51159..5697fdfb62 100644 --- a/app/Http/Requests/SelectTransactionsRequest.php +++ b/app/Http/Requests/SelectTransactionsRequest.php @@ -53,7 +53,7 @@ class SelectTransactionsRequest extends Request /** @var Carbon $sessionFirst */ $sessionFirst = clone session('first'); $first = $sessionFirst->subDay()->format('Y-m-d'); - $today = Carbon::create()->addDay()->format('Y-m-d'); + $today = Carbon::now()->addDay()->format('Y-m-d'); return [ 'start_date' => 'required|date|after:' . $first, diff --git a/app/Http/Requests/SplitJournalFormRequest.php b/app/Http/Requests/SplitJournalFormRequest.php index 65e861c5d9..7cedd16a18 100644 --- a/app/Http/Requests/SplitJournalFormRequest.php +++ b/app/Http/Requests/SplitJournalFormRequest.php @@ -95,7 +95,7 @@ class SplitJournalFormRequest extends Request 'foreign_currency_code' => null, 'reconciled' => false, 'identifier' => $index, - 'currency_id' => $this->integer('journal_currency_id'), + 'currency_id' => (int)$transaction['transaction_currency_id'], 'currency_code' => null, 'description' => $transaction['transaction_description'] ?? '', 'amount' => $transaction['amount'] ?? '', diff --git a/app/Http/Requests/TagFormRequest.php b/app/Http/Requests/TagFormRequest.php index e2a86b5183..19adba7873 100644 --- a/app/Http/Requests/TagFormRequest.php +++ b/app/Http/Requests/TagFormRequest.php @@ -22,7 +22,7 @@ declare(strict_types=1); namespace FireflyIII\Http\Requests; -use FireflyIII\Repositories\Tag\TagRepositoryInterface; +use FireflyIII\Models\Tag; /** * Class TagFormRequest. @@ -76,13 +76,14 @@ class TagFormRequest extends Request */ public function rules(): array { - /** @var TagRepositoryInterface $repository */ - $repository = app(TagRepositoryInterface::class); - $idRule = ''; - $tagRule = 'required|min:1|uniqueObjectForUser:tags,tag'; - if (null !== $repository->findNull((int)$this->get('id'))) { + $idRule = ''; + + /** @var Tag $tag */ + $tag = $this->route()->parameter('tag'); + $tagRule = 'required|min:1|uniqueObjectForUser:tags,tag'; + if (null !== $tag) { $idRule = 'belongsToUser:tags'; - $tagRule = 'required|min:1|uniqueObjectForUser:tags,tag,' . $this->get('id'); + $tagRule = 'required|min:1|uniqueObjectForUser:tags,tag,' . $tag->id; } return [ diff --git a/app/Import/JobConfiguration/YnabJobConfiguration.php b/app/Import/JobConfiguration/YnabJobConfiguration.php new file mode 100644 index 0000000000..b74ef30fbb --- /dev/null +++ b/app/Import/JobConfiguration/YnabJobConfiguration.php @@ -0,0 +1,141 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Import\JobConfiguration; + +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Models\ImportJob; +use FireflyIII\Repositories\ImportJob\ImportJobRepositoryInterface; +use FireflyIII\Support\Import\JobConfiguration\Ynab\NewYnabJobHandler; +use FireflyIII\Support\Import\JobConfiguration\Ynab\SelectAccountsHandler; +use FireflyIII\Support\Import\JobConfiguration\Ynab\SelectBudgetHandler; +use FireflyIII\Support\Import\JobConfiguration\Ynab\YnabJobConfigurationInterface; +use Illuminate\Support\MessageBag; +use Log; + +/** + * Class YnabJobConfiguration + */ +class YnabJobConfiguration implements JobConfigurationInterface +{ + /** @var YnabJobConfigurationInterface The job handler. */ + private $handler; + /** @var ImportJob The import job */ + private $importJob; + /** @var ImportJobRepositoryInterface Import job repository */ + private $repository; + + /** + * Returns true when the initial configuration for this job is complete. + * + * @return bool + */ + public function configurationComplete(): bool + { + return $this->handler->configurationComplete(); + } + + /** + * Store any data from the $data array into the job. Anything in the message bag will be flashed + * as an error to the user, regardless of its content. + * + * @param array $data + * + * @return MessageBag + */ + public function configureJob(array $data): MessageBag + { + return $this->handler->configureJob($data); + } + + /** + * Return the data required for the next step in the job configuration. + * + * @return array + */ + public function getNextData(): array + { + return $this->handler->getNextData(); + } + + /** + * Returns the view of the next step in the job configuration. + * + * @return string + */ + public function getNextView(): string + { + return $this->handler->getNextView(); + } + + /** + * Set import job. + * + * @param ImportJob $importJob + * + * @throws FireflyException + */ + public function setImportJob(ImportJob $importJob): void + { + $this->importJob = $importJob; + $this->repository = app(ImportJobRepositoryInterface::class); + $this->repository->setUser($importJob->user); + $this->handler = $this->getHandler(); + } + + /** + * Get correct handler. + * + * @return YnabJobConfigurationInterface + * @throws FireflyException + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + private function getHandler(): YnabJobConfigurationInterface + { + Log::debug(sprintf('Now in YnabJobConfiguration::getHandler() with stage "%s"', $this->importJob->stage)); + $handler = null; + switch ($this->importJob->stage) { + case 'new': + /** @var NewYnabJobHandler $handler */ + $handler = app(NewYnabJobHandler::class); + $handler->setImportJob($this->importJob); + break; + case 'select_budgets': + /** @var SelectBudgetHandler $handler */ + $handler = app(SelectBudgetHandler::class); + $handler->setImportJob($this->importJob); + break; + case 'select_accounts': + $handler = app(SelectAccountsHandler::class); + $handler->setImportJob($this->importJob); + break; + default: + // @codeCoverageIgnoreStart + throw new FireflyException(sprintf('Firefly III cannot create a YNAB configuration handler for stage "%s"', $this->importJob->stage)); + // @codeCoverageIgnoreEnd + } + + return $handler; + } +} \ No newline at end of file diff --git a/app/Import/Mapper/OpposingAccountIbans.php b/app/Import/Mapper/OpposingAccountIbans.php index ad15bf94e6..d11a75323a 100644 --- a/app/Import/Mapper/OpposingAccountIbans.php +++ b/app/Import/Mapper/OpposingAccountIbans.php @@ -33,6 +33,7 @@ class OpposingAccountIbans implements MapperInterface { /** * Get map of opposing accounts. + * * @return array */ public function getMap(): array diff --git a/app/Import/Prerequisites/BunqPrerequisites.php b/app/Import/Prerequisites/BunqPrerequisites.php index 1aeecf1499..55f07df71d 100644 --- a/app/Import/Prerequisites/BunqPrerequisites.php +++ b/app/Import/Prerequisites/BunqPrerequisites.php @@ -22,8 +22,8 @@ declare(strict_types=1); namespace FireflyIII\Import\Prerequisites; -use bunq\Exception\BunqException; use bunq\Util\BunqEnumApiEnvironmentType; +use Exception; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Services\Bunq\ApiContext; use FireflyIII\Services\IP\IPRetrievalInterface; @@ -134,7 +134,7 @@ class BunqPrerequisites implements PrerequisitesInterface try { $json = $apiContext->toJson(); // @codeCoverageIgnoreStart - } catch (BunqException $e) { + } catch (Exception $e) { $messages = new MessageBag(); $messages->add('bunq_error', $e->getMessage()); diff --git a/app/Import/Prerequisites/YnabPrerequisites.php b/app/Import/Prerequisites/YnabPrerequisites.php new file mode 100644 index 0000000000..187d188199 --- /dev/null +++ b/app/Import/Prerequisites/YnabPrerequisites.php @@ -0,0 +1,146 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Import\Prerequisites; + +use FireflyIII\User; +use Illuminate\Support\MessageBag; +use Log; + +/** + * Class YnabPrerequisites + */ +class YnabPrerequisites implements PrerequisitesInterface +{ + /** @var User The current user */ + private $user; + + /** + * Returns view name that allows user to fill in prerequisites. + * + * @return string + */ + public function getView(): string + { + return 'import.ynab.prerequisites'; + } + + /** + * Returns any values required for the prerequisites-view. + * + * @return array + */ + public function getViewParameters(): array + { + Log::debug('Now in YnabPrerequisites::getViewParameters()'); + $clientId = ''; + $clientSecret = ''; + if ($this->hasClientId()) { + $clientId = app('preferences')->getForUser($this->user, 'ynab_client_id', null)->data; + } + if ($this->hasClientSecret()) { + $clientSecret = app('preferences')->getForUser($this->user, 'ynab_client_secret', null)->data; + } + + $callBackUri = route('import.callback.ynab'); + $isHttps = 0 === strpos($callBackUri, 'https://'); + + return ['client_id' => $clientId, 'client_secret' => $clientSecret, 'callback_uri' => $callBackUri, 'is_https' => $isHttps]; + } + + /** + * Indicate if all prerequisites have been met. + * + * @return bool + */ + public function isComplete(): bool + { + return $this->hasClientId() && $this->hasClientSecret(); + } + + /** + * Set the user for this Prerequisites-routine. Class is expected to implement and save this. + * + * @param User $user + */ + public function setUser(User $user): void + { + $this->user = $user; + } + + /** + * This method responds to the user's submission of an API key. Should do nothing but store the value. + * + * Errors must be returned in the message bag under the field name they are requested by. + * + * @param array $data + * + * @return MessageBag + */ + public function storePrerequisites(array $data): MessageBag + { + $clientId = $data['client_id'] ?? ''; + $clientSecret = $data['client_secret'] ?? ''; + Log::debug('Storing YNAB client data'); + app('preferences')->setForUser($this->user, 'ynab_client_id', $clientId); + app('preferences')->setForUser($this->user, 'ynab_client_secret', $clientSecret); + + return new MessageBag; + } + + /** + * Check if we have the client ID. + * + * @return bool + */ + private function hasClientId(): bool + { + $clientId = app('preferences')->getForUser($this->user, 'ynab_client_id', null); + if (null === $clientId) { + return false; + } + if ('' === (string)$clientId->data) { + return false; + } + + return true; + } + + /** + * Check if we have the client secret + * + * @return bool + */ + private function hasClientSecret(): bool + { + $clientSecret = app('preferences')->getForUser($this->user, 'ynab_client_secret', null); + if (null === $clientSecret) { + return false; + } + if ('' === (string)$clientSecret->data) { + return false; + } + + return true; + } +} \ No newline at end of file diff --git a/app/Import/Routine/BunqRoutine.php b/app/Import/Routine/BunqRoutine.php index d450655345..c53c75a720 100644 --- a/app/Import/Routine/BunqRoutine.php +++ b/app/Import/Routine/BunqRoutine.php @@ -50,12 +50,12 @@ class BunqRoutine implements RoutineInterface */ public function run(): void { - Log::debug(sprintf('Now in SpectreRoutine::run() with status "%s" and stage "%s".', $this->importJob->status, $this->importJob->stage)); + Log::debug(sprintf('Now in BunqRoutine::run() with status "%s" and stage "%s".', $this->importJob->status, $this->importJob->stage)); $valid = ['ready_to_run']; // should be only ready_to_run if (\in_array($this->importJob->status, $valid, true)) { switch ($this->importJob->stage) { default: - throw new FireflyException(sprintf('SpectreRoutine cannot handle stage "%s".', $this->importJob->stage)); // @codeCoverageIgnore + throw new FireflyException(sprintf('BunqRoutine cannot handle stage "%s".', $this->importJob->stage)); // @codeCoverageIgnore case 'new': // list all of the users accounts. $this->repository->setStatus($this->importJob, 'running'); @@ -77,10 +77,19 @@ class BunqRoutine implements RoutineInterface $handler->setImportJob($this->importJob); $handler->run(); $transactions = $handler->getTransactions(); + // could be that more transactions will arrive in a second run. + if (true === $handler->stillRunning) { + Log::debug('Handler indicates that it is still working.'); + $this->repository->setStatus($this->importJob, 'ready_to_run'); + $this->repository->setStage($this->importJob, 'go-for-import'); + } + $this->repository->appendTransactions($this->importJob, $transactions); + if (false === $handler->stillRunning) { + Log::info('Handler indicates that its done!'); + $this->repository->setStatus($this->importJob, 'provider_finished'); + $this->repository->setStage($this->importJob, 'final'); + } - $this->repository->setTransactions($this->importJob, $transactions); - $this->repository->setStatus($this->importJob, 'provider_finished'); - $this->repository->setStage($this->importJob, 'final'); return; } diff --git a/app/Import/Routine/YnabRoutine.php b/app/Import/Routine/YnabRoutine.php new file mode 100644 index 0000000000..6540d36c19 --- /dev/null +++ b/app/Import/Routine/YnabRoutine.php @@ -0,0 +1,160 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Import\Routine; + +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Models\ImportJob; +use FireflyIII\Repositories\ImportJob\ImportJobRepositoryInterface; +use FireflyIII\Support\Import\Routine\Ynab\GetAccountsHandler; +use FireflyIII\Support\Import\Routine\Ynab\ImportDataHandler; +use FireflyIII\Support\Import\Routine\Ynab\StageGetAccessHandler; +use FireflyIII\Support\Import\Routine\Ynab\StageGetBudgetsHandler; +use Log; + +/** + * Class YnabRoutine + */ +class YnabRoutine implements RoutineInterface +{ + /** @var ImportJob The import job */ + private $importJob; + + /** @var ImportJobRepositoryInterface Import job repository */ + private $repository; + + /** + * At the end of each run(), the import routine must set the job to the expected status. + * + * The final status of the routine must be "provider_finished". + * + * @throws FireflyException + */ + public function run(): void + { + Log::debug(sprintf('Now in YNAB routine::run() with status "%s" and stage "%s".', $this->importJob->status, $this->importJob->stage)); + $valid = ['ready_to_run']; // should be only ready_to_run + if (\in_array($this->importJob->status, $valid, true)) { + + // get access token from YNAB + if ('get_access_token' === $this->importJob->stage) { + // list all of the users accounts. + $this->repository->setStatus($this->importJob, 'running'); + /** @var StageGetAccessHandler $handler */ + $handler = app(StageGetAccessHandler::class); + $handler->setImportJob($this->importJob); + $handler->run(); + + // back to correct stage: + $this->repository->setStatus($this->importJob, 'ready_to_run'); + $this->repository->setStage($this->importJob, 'get_budgets'); + + return; + } + if ('get_budgets' === $this->importJob->stage) { + $this->repository->setStatus($this->importJob, 'running'); + /** @var StageGetBudgetsHandler $handler */ + $handler = app(StageGetBudgetsHandler::class); + $handler->setImportJob($this->importJob); + $handler->run(); + + // count budgets in job, to determine next step. + $configuration = $this->repository->getConfiguration($this->importJob); + $budgets = $configuration['budgets'] ?? []; + + // if more than 1 budget, select budget first. + if (\count($budgets) > 1) { + $this->repository->setStage($this->importJob, 'select_budgets'); + $this->repository->setStatus($this->importJob, 'need_job_config'); + + return; + } + + if (1 === \count($budgets)) { + $this->repository->setStatus($this->importJob, 'ready_to_run'); + $this->repository->setStage($this->importJob, 'get_accounts'); + } + + return; + } + if ('get_accounts' === $this->importJob->stage) { + $this->repository->setStatus($this->importJob, 'running'); + + /** @var GetAccountsHandler $handler */ + $handler = app(GetAccountsHandler::class); + $handler->setImportJob($this->importJob); + $handler->run(); + + $this->repository->setStage($this->importJob, 'select_accounts'); + $this->repository->setStatus($this->importJob, 'need_job_config'); + + return; + } + if ('go-for-import' === $this->importJob->stage) { + $this->repository->setStatus($this->importJob, 'running'); + $this->repository->setStage($this->importJob, 'do_import'); + /** @var ImportDataHandler $handler */ + $handler = app(ImportDataHandler::class); + $handler->setImportJob($this->importJob); + $handler->run(); + $this->repository->setStatus($this->importJob, 'provider_finished'); + $this->repository->setStage($this->importJob, 'final'); + + return; + } + + // if ('match_accounts' === $this->importJob->stage) { + // // $this->repository->setStatus($this->importJob, 'running'); + // /** @var StageGetBudgetsHandler $handler */ + // $handler = app(StageGetBudgetsHandler::class); + // $handler->setImportJob($this->importJob); + // $handler->run(); + // $this->repository->setStage($this->importJob, 'get_transactions'); + // } + // + // if ('get_transactions' === $this->importJob->stage) { + // // $this->repository->setStatus($this->importJob, 'running'); + // /** @var StageGetBudgetsHandler $handler */ + // $handler = app(StageGetBudgetsHandler::class); + // $handler->setImportJob($this->importJob); + // $handler->run(); + // $this->repository->setStage($this->importJob, 'get_transactions'); + // } + throw new FireflyException(sprintf('YNAB import routine cannot handle stage "%s"', $this->importJob->stage)); + } + } + + /** + * Set the import job. + * + * @param ImportJob $importJob + * + * @return void + */ + public function setImportJob(ImportJob $importJob): void + { + $this->importJob = $importJob; + $this->repository = app(ImportJobRepositoryInterface::class); + $this->repository->setUser($importJob->user); + } +} \ No newline at end of file diff --git a/app/Import/Storage/ImportArrayStorage.php b/app/Import/Storage/ImportArrayStorage.php index 19868ec17e..b475b67bb6 100644 --- a/app/Import/Storage/ImportArrayStorage.php +++ b/app/Import/Storage/ImportArrayStorage.php @@ -26,9 +26,12 @@ namespace FireflyIII\Import\Storage; use Carbon\Carbon; use DB; +use FireflyIII\Events\RequestedReportOnJournals; use FireflyIII\Exceptions\FireflyException; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Helpers\Filter\InternalTransferFilter; +use FireflyIII\Helpers\Filter\NegativeAmountFilter; +use FireflyIII\Helpers\Filter\PositiveAmountFilter; use FireflyIII\Models\ImportJob; use FireflyIII\Models\Rule; use FireflyIII\Models\Transaction; @@ -112,6 +115,9 @@ class ImportArrayStorage app('preferences')->mark(); + // email about this: + event(new RequestedReportOnJournals($this->importJob->user_id, $collection)); + return $collection; } @@ -129,7 +135,9 @@ class ImportArrayStorage $rules->each( function (Rule $rule) use ($journal) { Log::debug(sprintf('Going to apply rule #%d to journal %d.', $rule->id, $journal->id)); - $processor = Processor::make($rule); + /** @var Processor $processor */ + $processor = app(Processor::class); + $processor->make($rule); $processor->handleTransactionJournal($journal); if ($rule->stop_processing) { return false; @@ -154,7 +162,7 @@ class ImportArrayStorage $array = $this->importJob->transactions; $count = 0; foreach ($array as $index => $transaction) { - if (strtolower(TransactionType::TRANSFER) === $transaction['type']) { + if (strtolower(TransactionType::TRANSFER) === strtolower($transaction['type'])) { $count++; Log::debug(sprintf('Row #%d is a transfer, increase count to %d', $index + 1, $count)); } @@ -212,6 +220,35 @@ class ImportArrayStorage return $set; } + /** + * @param $journal + * + * @return Transaction + */ + private function getTransactionFromJournal($journal): Transaction + { + // collect transactions using the journal collector + $collector = app(TransactionCollectorInterface::class); + $collector->setUser($this->importJob->user); + $collector->withOpposingAccount(); + // filter on specific journals. + $collector->setJournals(new Collection([$journal])); + + // add filter to remove transactions: + $transactionType = $journal->transactionType->type; + if ($transactionType === TransactionType::WITHDRAWAL) { + $collector->addFilter(PositiveAmountFilter::class); + } + if (!($transactionType === TransactionType::WITHDRAWAL)) { + $collector->addFilter(NegativeAmountFilter::class); + } + /** @var Transaction $result */ + $result = $collector->getTransactions()->first(); + Log::debug(sprintf('Return transaction #%d with journal id #%d based on ID #%d', $result->id, $result->journal_id, $journal->id)); + + return $result; + } + /** * Get the users transfers, so they can be compared to whatever the user is trying to import. */ @@ -220,15 +257,15 @@ class ImportArrayStorage Log::debug('Now in getTransfers()'); app('preferences')->mark(); - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setUser($this->importJob->user); $collector->setAllAssetAccounts() ->ignoreCache() ->setTypes([TransactionType::TRANSFER]) ->withOpposingAccount(); $collector->removeFilter(InternalTransferFilter::class); - $this->transfers = $collector->getJournals(); + $this->transfers = $collector->getTransactions(); Log::debug(sprintf('Count of getTransfers() is %d', $this->transfers->count())); } @@ -414,6 +451,18 @@ class ImportArrayStorage continue; } + // do transfer detection again! + if ($this->checkForTransfers && $this->transferExists($store)) { + $this->logDuplicateTransfer($store); + $this->repository->addErrorMessage( + $this->importJob, sprintf( + 'Row #%d ("%s") could not be imported. Such a transfer already exists.', + $index, + $store['description'] + ) + ); + continue; + } Log::debug(sprintf('Going to store entry %d of %d', $index + 1, $count)); // convert the date to an object: @@ -430,6 +479,14 @@ class ImportArrayStorage } Log::debug(sprintf('Stored as journal #%d', $journal->id)); $collection->push($journal); + + // add to collection of transfers, if necessary: + if ('transfer' === strtolower($store['type'])) { + $transaction = $this->getTransactionFromJournal($journal); + Log::debug('We just stored a transfer, so add the journal to the list of transfers.'); + $this->transfers->push($transaction); + Log::debug(sprintf('List length is now %d', $this->transfers->count())); + } } Log::debug('DONE storing!'); @@ -450,7 +507,7 @@ class ImportArrayStorage private function transferExists(array $transaction): bool { Log::debug('Check if is a double transfer.'); - if (strtolower(TransactionType::TRANSFER) !== $transaction['type']) { + if (strtolower(TransactionType::TRANSFER) !== strtolower($transaction['type'])) { Log::debug(sprintf('Is a %s, not a transfer so no.', $transaction['type'])); return false; diff --git a/app/Jobs/CreateRecurringTransactions.php b/app/Jobs/CreateRecurringTransactions.php index 16c0bcd15d..22177cc9a2 100644 --- a/app/Jobs/CreateRecurringTransactions.php +++ b/app/Jobs/CreateRecurringTransactions.php @@ -55,7 +55,7 @@ class CreateRecurringTransactions implements ShouldQueue /** @var Carbon The current date */ private $date; - /** @var JournalRepositoryInterface Journal repository */ + /** @var JournalRepositoryInterface Journal repository */ private $journalRepository; /** @var RecurringRepositoryInterface Recurring transactions repository. */ private $repository; @@ -119,6 +119,9 @@ class CreateRecurringTransactions implements ShouldQueue } Log::debug('Done with handle()'); + + // clear cache: + app('preferences')->mark(); } /** @@ -154,7 +157,10 @@ class CreateRecurringTransactions implements ShouldQueue $this->rules[$userId]->each( function (Rule $rule) use ($journal) { Log::debug(sprintf('Going to apply rule #%d to journal %d.', $rule->id, $journal->id)); - $processor = Processor::make($rule); + /** @var Processor $processor */ + $processor = app(Processor::class); + $processor->make($rule); + /** @noinspection ExceptionsAnnotatingAndHandlingInspection */ $processor->handleTransactionJournal($journal); if ($rule->stop_processing) { return; @@ -281,9 +287,9 @@ class CreateRecurringTransactions implements ShouldQueue Log::debug(sprintf('%s IS today (%s)', $date->format('Y-m-d'), $this->date->format('Y-m-d'))); // count created journals on THIS day. - $created = $this->repository->getJournals($recurrence, $date, $date); - if ($created->count() > 0) { - Log::info(sprintf('Already created %d journal(s) for date %s', $created->count(), $date->format('Y-m-d'))); + $journalCount = $this->repository->getJournalCount($recurrence, $date, $date); + if ($journalCount > 0) { + Log::info(sprintf('Already created %d journal(s) for date %s', $journalCount, $date->format('Y-m-d'))); continue; } @@ -418,8 +424,8 @@ class CreateRecurringTransactions implements ShouldQueue } // has repeated X times. - $journals = $this->repository->getJournals($recurrence, null, null); - if (0 !== $recurrence->repetitions && $journals->count() >= $recurrence->repetitions) { + $journalCount = $this->repository->getJournalCount($recurrence, null, null); + if (0 !== $recurrence->repetitions && $journalCount >= $recurrence->repetitions) { Log::info(sprintf('Recurrence #%d has run %d times, so will run no longer.', $recurrence->id, $recurrence->repetitions)); return false; diff --git a/app/Jobs/ExecuteRuleGroupOnExistingTransactions.php b/app/Jobs/ExecuteRuleGroupOnExistingTransactions.php index f2676061f6..f6901a6f3f 100644 --- a/app/Jobs/ExecuteRuleGroupOnExistingTransactions.php +++ b/app/Jobs/ExecuteRuleGroupOnExistingTransactions.php @@ -23,7 +23,7 @@ declare(strict_types=1); namespace FireflyIII\Jobs; use Carbon\Carbon; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Models\RuleGroup; use FireflyIII\TransactionRules\Processor; use FireflyIII\User; @@ -174,12 +174,12 @@ class ExecuteRuleGroupOnExistingTransactions extends Job implements ShouldQueue */ protected function collectJournals(): Collection { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setUser($this->user); $collector->setAccounts($this->accounts)->setRange($this->startDate, $this->endDate); - return $collector->getJournals(); + return $collector->getTransactions(); } /** @@ -200,7 +200,10 @@ class ExecuteRuleGroupOnExistingTransactions extends Job implements ShouldQueue // Create a list of processors for these rules return array_map( function ($rule) { - return Processor::make($rule); + /** @var Processor $processor */ + $processor = app(Processor::class); + $processor->make($rule); + return $processor; }, $rules->all() ); diff --git a/app/Jobs/ExecuteRuleOnExistingTransactions.php b/app/Jobs/ExecuteRuleOnExistingTransactions.php index 7cfea5706e..16d0e660d2 100644 --- a/app/Jobs/ExecuteRuleOnExistingTransactions.php +++ b/app/Jobs/ExecuteRuleOnExistingTransactions.php @@ -23,7 +23,7 @@ declare(strict_types=1); namespace FireflyIII\Jobs; use Carbon\Carbon; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Models\Rule; use FireflyIII\TransactionRules\Processor; use FireflyIII\User; @@ -162,7 +162,9 @@ class ExecuteRuleOnExistingTransactions extends Job implements ShouldQueue { // Lookup all journals that match the parameters specified $transactions = $this->collectJournals(); - $processor = Processor::make($this->rule, true); + /** @var Processor $processor */ + $processor = app(Processor::class); + $processor->make($this->rule, true); $hits = 0; $misses = 0; $total = 0; @@ -192,11 +194,11 @@ class ExecuteRuleOnExistingTransactions extends Job implements ShouldQueue */ protected function collectJournals(): Collection { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setUser($this->user); $collector->setAccounts($this->accounts)->setRange($this->startDate, $this->endDate); - return $collector->getJournals(); + return $collector->getTransactions(); } } diff --git a/app/Models/Account.php b/app/Models/Account.php index ba5aef5da0..c4a25daf2f 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -53,8 +53,11 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; * @property Carbon lastActivityDate * @property Collection accountMeta * @property bool encrypted - * @property int account_type_id - * @property Collection piggyBanks + * @property int account_type_id + * @property Collection piggyBanks + * @property string $interest + * @property string $interestPeriod + * @property string accountTypeString * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ @@ -75,18 +78,20 @@ class Account extends Model 'active' => 'boolean', 'encrypted' => 'boolean', ]; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['user_id', 'account_type_id', 'name', 'active', 'virtual_balance', 'iban']; - /** @var array */ + /** @var array Hidden from view */ protected $hidden = ['encrypted']; /** @var bool */ private $joinedAccountTypes; /** + * Route binder. Converts the key in the URL to the specified object (or throw 404). + * * @param string $value * * @return Account - * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * @throws NotFoundHttpException */ public static function routeBinder(string $value): Account { diff --git a/app/Models/AccountMeta.php b/app/Models/AccountMeta.php index 4f6e68e52c..ff36c2ec93 100644 --- a/app/Models/AccountMeta.php +++ b/app/Models/AccountMeta.php @@ -44,11 +44,9 @@ class AccountMeta extends Model 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['account_id', 'name', 'data']; - /** - * @var string - */ + /** @var string The table to store the data in */ protected $table = 'account_meta'; /** diff --git a/app/Models/AccountType.php b/app/Models/AccountType.php index 2a0c2fa7ca..ae4fd5369d 100644 --- a/app/Models/AccountType.php +++ b/app/Models/AccountType.php @@ -35,46 +35,32 @@ use Illuminate\Database\Eloquent\Relations\HasMany; */ class AccountType extends Model { - /** - * - */ + /** @var string */ public const DEFAULT = 'Default account'; - /** - * - */ + /** @var string */ public const CASH = 'Cash account'; - /** - * - */ + /** @var string */ public const ASSET = 'Asset account'; - /** - * - */ + /** @var string */ public const EXPENSE = 'Expense account'; - /** - * - */ + /** @var string */ public const REVENUE = 'Revenue account'; - /** - * - */ + /** @var string */ public const INITIAL_BALANCE = 'Initial balance account'; - /** - * - */ + /** @var string */ public const BENEFICIARY = 'Beneficiary account'; - /** - * - */ + /** @var string */ public const IMPORT = 'Import account'; - /** - * - */ + /** @var string */ public const RECONCILIATION = 'Reconciliation account'; - /** - * - */ + /** @var string */ public const LOAN = 'Loan'; + /** @var string */ + public const DEBT = 'Debt'; + /** @var string */ + public const MORTGAGE = 'Mortgage'; + /** @var string */ + public const CREDITCARD = 'Credit card'; /** * The attributes that should be casted to native types. * @@ -85,7 +71,7 @@ class AccountType extends Model 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['type']; /** diff --git a/app/Models/Attachment.php b/app/Models/Attachment.php index 591f2dfa71..8c700089e7 100644 --- a/app/Models/Attachment.php +++ b/app/Models/Attachment.php @@ -66,14 +66,16 @@ class Attachment extends Model 'deleted_at' => 'datetime', 'uploaded' => 'boolean', ]; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['attachable_id', 'attachable_type', 'user_id', 'md5', 'filename', 'mime', 'title', 'description', 'size', 'uploaded']; /** + * Route binder. Converts the key in the URL to the specified object (or throw 404). + * * @param string $value * * @return Attachment - * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * @throws NotFoundHttpException */ public static function routeBinder(string $value): Attachment { @@ -82,7 +84,7 @@ class Attachment extends Model /** @var User $user */ $user = auth()->user(); /** @var Attachment $attachment */ - $attachment = $user->attachments()->find($attachmentId); + $attachment = $user->attachments()->find($attachmentId); if (null !== $attachment) { return $attachment; } diff --git a/app/Models/AvailableBudget.php b/app/Models/AvailableBudget.php index 4950cb7661..0276ba7084 100644 --- a/app/Models/AvailableBudget.php +++ b/app/Models/AvailableBudget.php @@ -58,14 +58,16 @@ class AvailableBudget extends Model 'start_date' => 'date', 'end_date' => 'date', ]; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['user_id', 'transaction_currency_id', 'amount', 'start_date', 'end_date']; /** + * Route binder. Converts the key in the URL to the specified object (or throw 404). + * * @param string $value * * @return AvailableBudget - * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * @throws NotFoundHttpException */ public static function routeBinder(string $value): AvailableBudget { @@ -74,7 +76,7 @@ class AvailableBudget extends Model /** @var User $user */ $user = auth()->user(); /** @var AvailableBudget $availableBudget */ - $availableBudget = $user->availableBudgets()->find($availableBudgetId); + $availableBudget = $user->availableBudgets()->find($availableBudgetId); if (null !== $availableBudget) { return $availableBudget; } @@ -84,7 +86,7 @@ class AvailableBudget extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ public function transactionCurrency(): BelongsTo { diff --git a/app/Models/Bill.php b/app/Models/Bill.php index f0b0ce6711..610d2695ce 100644 --- a/app/Models/Bill.php +++ b/app/Models/Bill.php @@ -77,22 +77,21 @@ class Bill extends Model 'name_encrypted' => 'boolean', 'match_encrypted' => 'boolean', ]; - /** - * @var array - */ + + /** @var array Fields that can be filled */ protected $fillable = ['name', 'match', 'amount_min', 'match_encrypted', 'name_encrypted', 'user_id', 'amount_max', 'date', 'repeat_freq', 'skip', 'automatch', 'active', 'transaction_currency_id']; - /** - * @var array - */ + /** @var array Hidden from view */ protected $hidden = ['amount_min_encrypted', 'amount_max_encrypted', 'name_encrypted', 'match_encrypted']; /** + * Route binder. Converts the key in the URL to the specified object (or throw 404). + * * @param string $value * * @return Bill - * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * @throws NotFoundHttpException */ public static function routeBinder(string $value): Bill { @@ -111,9 +110,9 @@ class Bill extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\MorphMany + * @return MorphMany */ - public function attachments(): \Illuminate\Database\Eloquent\Relations\MorphMany + public function attachments(): MorphMany { return $this->morphMany(Attachment::class, 'attachable'); } diff --git a/app/Models/Budget.php b/app/Models/Budget.php index ab87f02080..0ef5142e05 100644 --- a/app/Models/Budget.php +++ b/app/Models/Budget.php @@ -26,6 +26,8 @@ use Crypt; use FireflyIII\User; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Collection; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -39,7 +41,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; * @property int $user_id * @property-read string $email * @property bool encrypted - * @property Collection budgetlimits + * @property Collection budgetlimits */ class Budget extends Model { @@ -58,16 +60,18 @@ class Budget extends Model 'active' => 'boolean', 'encrypted' => 'boolean', ]; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['user_id', 'name', 'active']; - /** @var array */ + /** @var array Hidden from view */ protected $hidden = ['encrypted']; /** + * Route binder. Converts the key in the URL to the specified object (or throw 404). + * * @param string $value * * @return Budget - * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * @throws NotFoundHttpException */ public static function routeBinder(string $value): Budget { @@ -76,7 +80,7 @@ class Budget extends Model /** @var User $user */ $user = auth()->user(); /** @var Budget $budget */ - $budget = $user->budgets()->find($budgetId); + $budget = $user->budgets()->find($budgetId); if (null !== $budget) { return $budget; } @@ -86,9 +90,9 @@ class Budget extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ - public function budgetlimits(): \Illuminate\Database\Eloquent\Relations\HasMany + public function budgetlimits(): HasMany { return $this->hasMany(BudgetLimit::class); } @@ -126,18 +130,18 @@ class Budget extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + * @return BelongsToMany */ - public function transactionJournals(): \Illuminate\Database\Eloquent\Relations\BelongsToMany + public function transactionJournals(): BelongsToMany { return $this->belongsToMany(TransactionJournal::class, 'budget_transaction_journal', 'budget_id'); } /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + * @return BelongsToMany */ - public function transactions(): \Illuminate\Database\Eloquent\Relations\BelongsToMany + public function transactions(): BelongsToMany { return $this->belongsToMany(Transaction::class, 'budget_transaction', 'budget_id'); } diff --git a/app/Models/BudgetLimit.php b/app/Models/BudgetLimit.php index 15f11689a2..f0d975d3c8 100644 --- a/app/Models/BudgetLimit.php +++ b/app/Models/BudgetLimit.php @@ -48,19 +48,23 @@ class BudgetLimit extends Model * @var array */ protected $casts - = [ + = [ 'created_at' => 'datetime', 'updated_at' => 'datetime', 'start_date' => 'date', 'end_date' => 'date', ]; + + /** @var array Fields that can be filled */ protected $fillable = ['budget_id', 'start_date', 'end_date', 'amount']; /** + * Route binder. Converts the key in the URL to the specified object (or throw 404). + * * @param string $value * * @return mixed - * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * @throws NotFoundHttpException */ public static function routeBinder(string $value): BudgetLimit { diff --git a/app/Models/Category.php b/app/Models/Category.php index 359dd039e3..67459e3a24 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -18,6 +18,7 @@ * You should have received a copy of the GNU General Public License * along with Firefly III. If not, see . */ + declare(strict_types=1); namespace FireflyIII\Models; @@ -27,6 +28,7 @@ use Crypt; use FireflyIII\User; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\SoftDeletes; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -37,7 +39,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; * @property int $id * @property float $spent // used in category reports * @property Carbon|null lastActivity - * @property bool encrypted + * @property bool encrypted */ class Category extends Model { @@ -55,16 +57,18 @@ class Category extends Model 'deleted_at' => 'datetime', 'encrypted' => 'boolean', ]; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['user_id', 'name']; - /** @var array */ + /** @var array Hidden from view */ protected $hidden = ['encrypted']; /** + * Route binder. Converts the key in the URL to the specified object (or throw 404). + * * @param string $value * * @return Category - * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * @throws NotFoundHttpException */ public static function routeBinder(string $value): Category { @@ -73,7 +77,7 @@ class Category extends Model /** @var User $user */ $user = auth()->user(); /** @var Category $category */ - $category = $user->categories()->find($categoryId); + $category = $user->categories()->find($categoryId); if (null !== $category) { return $category; } @@ -114,18 +118,18 @@ class Category extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + * @return BelongsToMany */ - public function transactionJournals(): \Illuminate\Database\Eloquent\Relations\BelongsToMany + public function transactionJournals(): BelongsToMany { return $this->belongsToMany(TransactionJournal::class, 'category_transaction_journal', 'category_id'); } /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + * @return BelongsToMany */ - public function transactions(): \Illuminate\Database\Eloquent\Relations\BelongsToMany + public function transactions(): BelongsToMany { return $this->belongsToMany(Transaction::class, 'category_transaction', 'category_id'); } diff --git a/app/Models/Configuration.php b/app/Models/Configuration.php index 76eae0f9ed..771f9a86b2 100644 --- a/app/Models/Configuration.php +++ b/app/Models/Configuration.php @@ -44,10 +44,9 @@ class Configuration extends Model = [ 'created_at' => 'datetime', 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', ]; - /** - * @var string - */ + /** @var string The table to store the data in */ protected $table = 'configuration'; /** diff --git a/app/Models/CurrencyExchangeRate.php b/app/Models/CurrencyExchangeRate.php index e77bb2484c..6a50c0d874 100644 --- a/app/Models/CurrencyExchangeRate.php +++ b/app/Models/CurrencyExchangeRate.php @@ -43,8 +43,16 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; */ class CurrencyExchangeRate extends Model { - /** @var array */ - protected $dates = ['date']; + /** @var array Convert these fields to other data types */ + protected $casts + = [ + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'user_id' => 'int', + 'from_currency_id' => 'int', + 'to_currency_id' => 'int', + 'date' => 'datetime', + ]; /** * @codeCoverageIgnore diff --git a/app/Models/ExportJob.php b/app/Models/ExportJob.php index 593b3ebc64..474d29f9da 100644 --- a/app/Models/ExportJob.php +++ b/app/Models/ExportJob.php @@ -24,6 +24,7 @@ namespace FireflyIII\Models; use FireflyIII\User; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** @@ -37,7 +38,11 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; */ class ExportJob extends Model { - /** @var array */ + /** + * The attributes that should be casted to native types. + * + * @var array + */ protected $casts = [ 'created_at' => 'datetime', @@ -45,6 +50,8 @@ class ExportJob extends Model ]; /** + * Route binder. Converts the key in the URL to the specified object (or throw 404). + * * @param string $value * * @return ExportJob @@ -67,9 +74,12 @@ class ExportJob extends Model } /** - * @codeCoverageIgnore + * Change the status of this export job. * * @param $status + * + * @deprecated + * @codeCoverageIgnore */ public function change($status): void { @@ -78,10 +88,13 @@ class ExportJob extends Model } /** + * Returns the user this objects belongs to. + * + * + * @return BelongsTo * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ - public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo + public function user(): BelongsTo { return $this->belongsTo(User::class); } diff --git a/app/Models/ImportJob.php b/app/Models/ImportJob.php index 6957faaf2e..aebbf35b45 100644 --- a/app/Models/ImportJob.php +++ b/app/Models/ImportJob.php @@ -24,6 +24,8 @@ namespace FireflyIII\Models; use FireflyIII\User; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** @@ -42,7 +44,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; * @property Tag $tag * @property array $errors * @property array extended_status - * @property int id + * @property int id */ class ImportJob extends Model { @@ -61,10 +63,12 @@ class ImportJob extends Model 'transactions' => 'array', 'errors' => 'array', ]; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['key', 'user_id', 'file_type', 'provider', 'status', 'stage', 'configuration', 'extended_status', 'transactions', 'errors']; /** + * Route binder. Converts the key in the URL to the specified object (or throw 404). + * * @param $value * * @return mixed @@ -74,7 +78,7 @@ class ImportJob extends Model public static function routeBinder(string $value): ImportJob { if (auth()->check()) { - $key = trim($value); + $key = trim($value); /** @var User $user */ $user = auth()->user(); /** @var ImportJob $importJob */ @@ -88,27 +92,27 @@ class ImportJob extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\MorphMany + * @return MorphMany */ - public function attachments(): \Illuminate\Database\Eloquent\Relations\MorphMany + public function attachments(): MorphMany { return $this->morphMany(Attachment::class, 'attachable'); } /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ - public function tag(): \Illuminate\Database\Eloquent\Relations\BelongsTo + public function tag(): BelongsTo { return $this->belongsTo(Tag::class); } /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ - public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo + public function user(): BelongsTo { return $this->belongsTo(User::class); } diff --git a/app/Models/LinkType.php b/app/Models/LinkType.php index f5f1bd9e86..0e44074d61 100644 --- a/app/Models/LinkType.php +++ b/app/Models/LinkType.php @@ -24,6 +24,7 @@ namespace FireflyIII\Models; use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -55,10 +56,12 @@ class LinkType extends Model 'editable' => 'boolean', ]; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['name', 'inward', 'outward', 'editable']; /** + * Route binder. Converts the key in the URL to the specified object (or throw 404). + * * @param $value * * @return LinkType @@ -79,9 +82,9 @@ class LinkType extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ - public function transactionJournalLinks(): \Illuminate\Database\Eloquent\Relations\HasMany + public function transactionJournalLinks(): HasMany { return $this->hasMany(TransactionJournalLink::class); } diff --git a/app/Models/Note.php b/app/Models/Note.php index ee4accbce0..d67b00d82b 100644 --- a/app/Models/Note.php +++ b/app/Models/Note.php @@ -51,7 +51,7 @@ class Note extends Model 'updated_at' => 'datetime', 'deleted_at' => 'datetime', ]; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['title', 'text', 'noteable_id', 'noteable_type']; /** diff --git a/app/Models/PiggyBank.php b/app/Models/PiggyBank.php index 7436b5c35c..289e7f79b3 100644 --- a/app/Models/PiggyBank.php +++ b/app/Models/PiggyBank.php @@ -26,6 +26,7 @@ use Carbon\Carbon; use Crypt; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\SoftDeletes; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -44,7 +45,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; * @property int $order * @property bool $active * @property int $account_id - * @property bool encrypted + * @property bool encrypted * */ class PiggyBank extends Model @@ -67,18 +68,18 @@ class PiggyBank extends Model 'active' => 'boolean', 'encrypted' => 'boolean', ]; - /** @var array */ - protected $dates = ['startdate', 'targetdate']; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['name', 'account_id', 'order', 'targetamount', 'startdate', 'targetdate', 'active']; - /** @var array */ + /** @var array Hidden from view */ protected $hidden = ['targetamount_encrypted', 'encrypted']; /** + * Route binder. Converts the key in the URL to the specified object (or throw 404). + * * @param string $value * * @return PiggyBank - * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * @throws NotFoundHttpException */ public static function routeBinder(string $value): PiggyBank { @@ -96,7 +97,7 @@ class PiggyBank extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ public function account(): BelongsTo { @@ -131,18 +132,18 @@ class PiggyBank extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ - public function piggyBankEvents(): \Illuminate\Database\Eloquent\Relations\HasMany + public function piggyBankEvents(): HasMany { return $this->hasMany(PiggyBankEvent::class); } /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ - public function piggyBankRepetitions(): \Illuminate\Database\Eloquent\Relations\HasMany + public function piggyBankRepetitions(): HasMany { return $this->hasMany(PiggyBankRepetition::class); } diff --git a/app/Models/PiggyBankEvent.php b/app/Models/PiggyBankEvent.php index e01ed0a677..6cc3042832 100644 --- a/app/Models/PiggyBankEvent.php +++ b/app/Models/PiggyBankEvent.php @@ -24,6 +24,7 @@ namespace FireflyIII\Models; use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * Class PiggyBankEvent. @@ -32,7 +33,7 @@ use Illuminate\Database\Eloquent\Model; * @property int $transaction_journal_id * @property int $piggy_bank_id * @property int $id - * @property Carbon date + * @property Carbon date */ class PiggyBankEvent extends Model { @@ -47,22 +48,16 @@ class PiggyBankEvent extends Model 'updated_at' => 'datetime', 'date' => 'date', ]; - /** @var array */ - protected $dates = ['date']; - /** - * @var array - */ + /** @var array Fields that can be filled */ protected $fillable = ['piggy_bank_id', 'transaction_journal_id', 'date', 'amount']; - /** - * @var array - */ + /** @var array Hidden from view */ protected $hidden = ['amount_encrypted']; /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ - public function piggyBank(): \Illuminate\Database\Eloquent\Relations\BelongsTo + public function piggyBank(): BelongsTo { return $this->belongsTo(PiggyBank::class); } @@ -79,9 +74,9 @@ class PiggyBankEvent extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ - public function transactionJournal(): \Illuminate\Database\Eloquent\Relations\BelongsTo + public function transactionJournal(): BelongsTo { return $this->belongsTo(TransactionJournal::class); } diff --git a/app/Models/PiggyBankRepetition.php b/app/Models/PiggyBankRepetition.php index a021ce61b6..450b91addc 100644 --- a/app/Models/PiggyBankRepetition.php +++ b/app/Models/PiggyBankRepetition.php @@ -25,6 +25,7 @@ namespace FireflyIII\Models; use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * Class PiggyBankRepetition. @@ -44,20 +45,17 @@ class PiggyBankRepetition extends Model = [ 'created_at' => 'datetime', 'updated_at' => 'datetime', - 'deleted_at' => 'datetime', 'startdate' => 'date', 'targetdate' => 'date', ]; - /** @var array */ - protected $dates = ['startdate', 'targetdate']; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['piggy_bank_id', 'startdate', 'targetdate', 'currentamount']; /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ - public function piggyBank(): \Illuminate\Database\Eloquent\Relations\BelongsTo + public function piggyBank(): BelongsTo { return $this->belongsTo(PiggyBank::class); } diff --git a/app/Models/Preference.php b/app/Models/Preference.php index cc9ccc0b15..f24effcfe1 100644 --- a/app/Models/Preference.php +++ b/app/Models/Preference.php @@ -29,6 +29,7 @@ use FireflyIII\Exceptions\FireflyException; use FireflyIII\User; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Log; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -40,7 +41,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; * @property Carbon $updated_at * @property Carbon $created_at * @property int $id - * @property User user + * @property User user */ class Preference extends Model { @@ -55,14 +56,16 @@ class Preference extends Model 'updated_at' => 'datetime', ]; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['user_id', 'data', 'name']; /** + * Route binder. Converts the key in the URL to the specified object (or throw 404). + * * @param string $value * * @return Preference - * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * @throws NotFoundHttpException */ public static function routeBinder(string $value): Preference { @@ -71,7 +74,7 @@ class Preference extends Model /** @var User $user */ $user = auth()->user(); /** @var Preference $preference */ - $preference = $user->preferences()->find($preferenceId); + $preference = $user->preferences()->find($preferenceId); if (null !== $preference) { return $preference; } @@ -129,9 +132,9 @@ class Preference extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ - public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo + public function user(): BelongsTo { return $this->belongsTo(User::class); } diff --git a/app/Models/Recurrence.php b/app/Models/Recurrence.php index 243eece7b6..3bdcc2c58d 100644 --- a/app/Models/Recurrence.php +++ b/app/Models/Recurrence.php @@ -82,17 +82,19 @@ class Recurrence extends Model 'active' => 'bool', 'apply_rules' => 'bool', ]; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['user_id', 'transaction_type_id', 'title', 'description', 'first_date', 'repeat_until', 'latest_date', 'repetitions', 'apply_rules', 'active']; - /** @var string */ + /** @var string The table to store the data in */ protected $table = 'recurrences'; /** + * Route binder. Converts the key in the URL to the specified object (or throw 404). + * * @param string $value * * @return Recurrence - * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * @throws NotFoundHttpException */ public static function routeBinder(string $value): Recurrence { @@ -101,7 +103,7 @@ class Recurrence extends Model /** @var User $user */ $user = auth()->user(); /** @var Recurrence $recurrence */ - $recurrence = $user->recurrences()->find($recurrenceId); + $recurrence = $user->recurrences()->find($recurrenceId); if (null !== $recurrence) { return $recurrence; } diff --git a/app/Models/RecurrenceMeta.php b/app/Models/RecurrenceMeta.php index dbd7424917..2dd414c907 100644 --- a/app/Models/RecurrenceMeta.php +++ b/app/Models/RecurrenceMeta.php @@ -37,7 +37,11 @@ use Illuminate\Database\Eloquent\SoftDeletes; class RecurrenceMeta extends Model { use SoftDeletes; - /** @var array */ + /** + * The attributes that should be casted to native types. + * + * @var array + */ protected $casts = [ 'created_at' => 'datetime', @@ -46,9 +50,9 @@ class RecurrenceMeta extends Model 'name' => 'string', 'value' => 'string', ]; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['recurrence_id', 'name', 'value']; - /** @var string */ + /** @var string The table to store the data in */ protected $table = 'recurrences_meta'; /** diff --git a/app/Models/RecurrenceRepetition.php b/app/Models/RecurrenceRepetition.php index 89c54def54..fc72034c92 100644 --- a/app/Models/RecurrenceRepetition.php +++ b/app/Models/RecurrenceRepetition.php @@ -51,9 +51,13 @@ class RecurrenceRepetition extends Model /** @var int */ public const WEEKEND_TO_MONDAY = 4; use SoftDeletes; - /** @var array */ + /** + * The attributes that should be casted to native types. + * + * @var array + */ protected $casts - = [ + = [ 'created_at' => 'datetime', 'updated_at' => 'datetime', 'deleted_at' => 'datetime', @@ -62,8 +66,9 @@ class RecurrenceRepetition extends Model 'repetition_skip' => 'int', 'weekend' => 'int', ]; + /** @var array Fields that can be filled */ protected $fillable = ['recurrence_id', 'weekend', 'repetition_type', 'repetition_moment', 'repetition_skip']; - /** @var string */ + /** @var string The table to store the data in */ protected $table = 'recurrences_repetitions'; /** diff --git a/app/Models/RecurrenceTransaction.php b/app/Models/RecurrenceTransaction.php index 62ffd49d10..14812b9068 100644 --- a/app/Models/RecurrenceTransaction.php +++ b/app/Models/RecurrenceTransaction.php @@ -51,7 +51,11 @@ use Illuminate\Database\Eloquent\SoftDeletes; class RecurrenceTransaction extends Model { use SoftDeletes; - /** @var array */ + /** + * The attributes that should be casted to native types. + * + * @var array + */ protected $casts = [ 'created_at' => 'datetime', @@ -61,16 +65,16 @@ class RecurrenceTransaction extends Model 'foreign_amount' => 'string', 'description' => 'string', ]; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['recurrence_id', 'transaction_currency_id', 'foreign_currency_id', 'source_id', 'destination_id', 'amount', 'foreign_amount', 'description']; - /** @var string */ + /** @var string The table to store the data in */ protected $table = 'recurrences_transactions'; /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ public function destinationAccount(): BelongsTo { @@ -106,7 +110,7 @@ class RecurrenceTransaction extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ public function sourceAccount(): BelongsTo { diff --git a/app/Models/RecurrenceTransactionMeta.php b/app/Models/RecurrenceTransactionMeta.php index e39aff977e..9f96b0fb5b 100644 --- a/app/Models/RecurrenceTransactionMeta.php +++ b/app/Models/RecurrenceTransactionMeta.php @@ -37,17 +37,22 @@ use Illuminate\Database\Eloquent\SoftDeletes; class RecurrenceTransactionMeta extends Model { use SoftDeletes; - /** @var array */ + /** + * The attributes that should be casted to native types. + * + * @var array + */ protected $casts - = [ + = [ 'created_at' => 'datetime', 'updated_at' => 'datetime', 'deleted_at' => 'datetime', 'name' => 'string', 'value' => 'string', ]; + /** @var array Fields that can be filled */ protected $fillable = ['rt_id', 'name', 'value']; - /** @var string */ + /** @var string The table to store the data in */ protected $table = 'rt_meta'; /** diff --git a/app/Models/Role.php b/app/Models/Role.php index c262631e60..319629e02f 100644 --- a/app/Models/Role.php +++ b/app/Models/Role.php @@ -45,14 +45,12 @@ class Role extends Model 'updated_at' => 'datetime', ]; - /** - * @var array - */ + /** @var array Fields that can be filled */ protected $fillable = ['name', 'display_name', 'description']; /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + * @return BelongsToMany */ public function users(): BelongsToMany { diff --git a/app/Models/Rule.php b/app/Models/Rule.php index d7fbc0ba00..58fe37ac10 100644 --- a/app/Models/Rule.php +++ b/app/Models/Rule.php @@ -70,14 +70,16 @@ class Rule extends Model 'id' => 'int', 'strict' => 'boolean', ]; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['rule_group_id', 'order', 'active', 'title', 'description', 'user_id', 'strict']; /** + * Route binder. Converts the key in the URL to the specified object (or throw 404). + * * @param string $value * * @return Rule - * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * @throws NotFoundHttpException */ public static function routeBinder(string $value): Rule { @@ -86,7 +88,7 @@ class Rule extends Model /** @var User $user */ $user = auth()->user(); /** @var Rule $rule */ - $rule = $user->rules()->find($ruleId); + $rule = $user->rules()->find($ruleId); if (null !== $rule) { return $rule; } diff --git a/app/Models/RuleAction.php b/app/Models/RuleAction.php index b0b7e9dcd3..0488e15806 100644 --- a/app/Models/RuleAction.php +++ b/app/Models/RuleAction.php @@ -24,6 +24,7 @@ namespace FireflyIII\Models; use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * Class RuleAction. @@ -54,14 +55,14 @@ class RuleAction extends Model 'stop_processing' => 'boolean', ]; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['rule_id', 'action_type', 'action_value', 'order', 'active', 'stop_processing']; /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ - public function rule(): \Illuminate\Database\Eloquent\Relations\BelongsTo + public function rule(): BelongsTo { return $this->belongsTo(Rule::class); } diff --git a/app/Models/RuleGroup.php b/app/Models/RuleGroup.php index bc2840175b..14f85f5491 100644 --- a/app/Models/RuleGroup.php +++ b/app/Models/RuleGroup.php @@ -25,6 +25,8 @@ namespace FireflyIII\Models; use Carbon\Carbon; use FireflyIII\User; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Collection; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -41,7 +43,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; * @property int $id * @property int $order * @property Collection $rules - * @property string description + * @property string description */ class RuleGroup extends Model { @@ -60,16 +62,16 @@ class RuleGroup extends Model 'order' => 'int', ]; - /** - * @var array - */ + /** @var array Fields that can be filled */ protected $fillable = ['user_id', 'order', 'title', 'description', 'active']; /** + * Route binder. Converts the key in the URL to the specified object (or throw 404). + * * @param string $value * * @return RuleGroup - * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * @throws NotFoundHttpException */ public static function routeBinder(string $value): RuleGroup { @@ -78,7 +80,7 @@ class RuleGroup extends Model /** @var User $user */ $user = auth()->user(); /** @var RuleGroup $ruleGroup */ - $ruleGroup = $user->ruleGroups()->find($ruleGroupId); + $ruleGroup = $user->ruleGroups()->find($ruleGroupId); if (null !== $ruleGroup) { return $ruleGroup; } @@ -88,18 +90,18 @@ class RuleGroup extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ - public function rules(): \Illuminate\Database\Eloquent\Relations\HasMany + public function rules(): HasMany { return $this->hasMany(Rule::class); } /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ - public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo + public function user(): BelongsTo { return $this->belongsTo(User::class); } diff --git a/app/Models/RuleTrigger.php b/app/Models/RuleTrigger.php index 7c8da2f6e2..e2b8422251 100644 --- a/app/Models/RuleTrigger.php +++ b/app/Models/RuleTrigger.php @@ -24,6 +24,7 @@ namespace FireflyIII\Models; use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * Class RuleTrigger. @@ -53,14 +54,14 @@ class RuleTrigger extends Model 'stop_processing' => 'boolean', ]; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['rule_id', 'trigger_type', 'trigger_value', 'order', 'active', 'stop_processing']; /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ - public function rule(): \Illuminate\Database\Eloquent\Relations\BelongsTo + public function rule(): BelongsTo { return $this->belongsTo(Rule::class); } diff --git a/app/Models/Tag.php b/app/Models/Tag.php index f0e5c900dc..699c8991ee 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -25,6 +25,8 @@ namespace FireflyIII\Models; use Crypt; use FireflyIII\User; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Collection; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -60,16 +62,16 @@ class Tag extends Model 'date' => 'date', 'zoomLevel' => 'int', ]; - /** @var array */ - protected $dates = ['date']; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['user_id', 'tag', 'date', 'description', 'longitude', 'latitude', 'zoomLevel', 'tagMode']; /** + * Route binder. Converts the key in the URL to the specified object (or throw 404). + * * @param string $value * * @return Tag - * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * @throws NotFoundHttpException */ public static function routeBinder(string $value): Tag { @@ -146,18 +148,18 @@ class Tag extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + * @return BelongsToMany */ - public function transactionJournals(): \Illuminate\Database\Eloquent\Relations\BelongsToMany + public function transactionJournals(): BelongsToMany { return $this->belongsToMany(TransactionJournal::class); } /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ - public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo + public function user(): BelongsTo { return $this->belongsTo(User::class); } diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 2098c6788b..98d8915bff 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -27,6 +27,7 @@ use FireflyIII\User; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\SoftDeletes; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -68,6 +69,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; * @property string $bill_name_encrypted * @property string $notes * @property string $tags + * @property string $transaction_currency_name * @property string $transaction_currency_symbol * @property int $transaction_currency_dp * @property string $transaction_currency_code @@ -100,6 +102,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; */ class Transaction extends Model { + use SoftDeletes; /** * The attributes that should be casted to native types. * @@ -115,24 +118,23 @@ class Transaction extends Model 'bill_name_encrypted' => 'boolean', 'reconciled' => 'boolean', ]; - /** - * @var array - */ + /** @var array Fields that can be filled */ protected $fillable = ['account_id', 'transaction_journal_id', 'description', 'amount', 'identifier', 'transaction_currency_id', 'foreign_currency_id', 'foreign_amount', 'reconciled']; - /** - * @var array - */ + /** @var array Hidden from view */ protected $hidden = ['encrypted']; /** - * @codeCoverageIgnore + * Check if a table is joined. + * + * * * @param Builder $query * @param string $table * * @return bool + * @codeCoverageIgnore */ public static function isJoined(Builder $query, string $table): bool { @@ -150,10 +152,12 @@ class Transaction extends Model } /** + * Route binder. Converts the key in the URL to the specified object (or throw 404). + * * @param string $value * * @return Transaction - * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * @throws NotFoundHttpException */ public static function routeBinder(string $value): Transaction { @@ -171,11 +175,12 @@ class Transaction extends Model throw new NotFoundHttpException; } - use SoftDeletes; /** + * Get the account this object belongs to. + * * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ public function account(): BelongsTo { @@ -183,26 +188,32 @@ class Transaction extends Model } /** + * Get the budget(s) this object belongs to. + * * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + * @return BelongsToMany */ - public function budgets(): \Illuminate\Database\Eloquent\Relations\BelongsToMany + public function budgets(): BelongsToMany { return $this->belongsToMany(Budget::class); } /** + * Get the category(ies) this object belongs to. + * * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + * @return BelongsToMany */ - public function categories(): \Illuminate\Database\Eloquent\Relations\BelongsToMany + public function categories(): BelongsToMany { return $this->belongsToMany(Category::class); } /** + * Get the currency this object belongs to. + * * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ public function foreignCurrency(): BelongsTo { @@ -210,6 +221,8 @@ class Transaction extends Model } /** + * Check for transactions AFTER a specified date. + * * @codeCoverageIgnore * * @param Builder $query @@ -224,6 +237,8 @@ class Transaction extends Model } /** + * Check for transactions BEFORE the specified date. + * * @codeCoverageIgnore * * @param Builder $query diff --git a/app/Models/TransactionCurrency.php b/app/Models/TransactionCurrency.php index c44d6d350b..5f85ace223 100644 --- a/app/Models/TransactionCurrency.php +++ b/app/Models/TransactionCurrency.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace FireflyIII\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -33,7 +34,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; * @property string $symbol * @property int $decimal_places * @property int $id - * @property string name + * @property string name * */ class TransactionCurrency extends Model @@ -52,16 +53,16 @@ class TransactionCurrency extends Model 'deleted_at' => 'datetime', 'decimal_places' => 'int', ]; - /** @var array */ - protected $dates = ['date']; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['name', 'code', 'symbol', 'decimal_places']; /** + * Route binder. Converts the key in the URL to the specified object (or throw 404). + * * @param string $value * * @return TransactionCurrency - * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * @throws NotFoundHttpException */ public static function routeBinder(string $value): TransactionCurrency { @@ -77,9 +78,9 @@ class TransactionCurrency extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ - public function transactionJournals(): \Illuminate\Database\Eloquent\Relations\HasMany + public function transactionJournals(): HasMany { return $this->hasMany(TransactionJournal::class); } diff --git a/app/Models/TransactionJournal.php b/app/Models/TransactionJournal.php index 7eae36c4d3..266974a4b5 100644 --- a/app/Models/TransactionJournal.php +++ b/app/Models/TransactionJournal.php @@ -28,6 +28,7 @@ use FireflyIII\User; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphMany; @@ -91,15 +92,17 @@ class TransactionJournal extends Model 'completed' => 'boolean', ]; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['user_id', 'transaction_type_id', 'bill_id', 'interest_date', 'book_date', 'process_date', 'transaction_currency_id', 'description', 'completed', 'date', 'rent_date', 'encrypted', 'tag_count',]; - /** @var array */ + /** @var array Hidden from view */ protected $hidden = ['encrypted']; /** + * Checks if tables are joined. + * * @param Builder $query * @param string $table * @@ -121,10 +124,12 @@ class TransactionJournal extends Model } /** + * Route binder. Converts the key in the URL to the specified object (or throw 404). + * * @param string $value * * @return TransactionJournal - * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * @throws NotFoundHttpException */ public static function routeBinder(string $value): TransactionJournal { @@ -145,25 +150,25 @@ class TransactionJournal extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\MorphMany + * @return MorphMany */ - public function attachments(): \Illuminate\Database\Eloquent\Relations\MorphMany + public function attachments(): MorphMany { return $this->morphMany(Attachment::class, 'attachable'); } /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ - public function bill(): \Illuminate\Database\Eloquent\Relations\BelongsTo + public function bill(): BelongsTo { return $this->belongsTo(Bill::class); } /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + * @return BelongsToMany */ public function budgets(): BelongsToMany { @@ -172,7 +177,7 @@ class TransactionJournal extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + * @return BelongsToMany */ public function categories(): BelongsToMany { @@ -259,7 +264,7 @@ class TransactionJournal extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ public function piggyBankEvents(): HasMany { @@ -333,7 +338,7 @@ class TransactionJournal extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + * @return BelongsToMany */ public function tags(): BelongsToMany { @@ -342,9 +347,9 @@ class TransactionJournal extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ - public function transactionCurrency(): \Illuminate\Database\Eloquent\Relations\BelongsTo + public function transactionCurrency(): BelongsTo { return $this->belongsTo(TransactionCurrency::class); } @@ -360,9 +365,9 @@ class TransactionJournal extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ - public function transactionType(): \Illuminate\Database\Eloquent\Relations\BelongsTo + public function transactionType(): BelongsTo { return $this->belongsTo(TransactionType::class); } @@ -378,9 +383,9 @@ class TransactionJournal extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ - public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo + public function user(): BelongsTo { return $this->belongsTo(User::class); } diff --git a/app/Models/TransactionJournalLink.php b/app/Models/TransactionJournalLink.php index 38de517ea4..8107ce07a0 100644 --- a/app/Models/TransactionJournalLink.php +++ b/app/Models/TransactionJournalLink.php @@ -46,11 +46,21 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class TransactionJournalLink extends Model { /** - * @var string + * The attributes that should be casted to native types. + * + * @var array */ + protected $casts + = [ + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + /** @var string The table to store the data in */ protected $table = 'journal_links'; /** + * Route binder. Converts the key in the URL to the specified object (or throw 404). + * * @param string $value * * @return mixed @@ -76,7 +86,7 @@ class TransactionJournalLink extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ public function destination(): BelongsTo { @@ -101,7 +111,7 @@ class TransactionJournalLink extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ public function linkType(): BelongsTo { @@ -136,7 +146,7 @@ class TransactionJournalLink extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ public function source(): BelongsTo { diff --git a/app/Models/TransactionJournalMeta.php b/app/Models/TransactionJournalMeta.php index d27a7a5996..0eb222f6c3 100644 --- a/app/Models/TransactionJournalMeta.php +++ b/app/Models/TransactionJournalMeta.php @@ -49,9 +49,9 @@ class TransactionJournalMeta extends Model 'updated_at' => 'datetime', 'deleted_at' => 'datetime', ]; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['transaction_journal_id', 'name', 'data', 'hash']; - /** @var string */ + /** @var string The table to store the data in */ protected $table = 'journal_meta'; /** @@ -80,7 +80,7 @@ class TransactionJournalMeta extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ public function transactionJournal(): BelongsTo { diff --git a/app/Models/TransactionType.php b/app/Models/TransactionType.php index f82f24769a..75c0f35b3d 100644 --- a/app/Models/TransactionType.php +++ b/app/Models/TransactionType.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace FireflyIII\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -67,14 +68,16 @@ class TransactionType extends Model 'updated_at' => 'datetime', 'deleted_at' => 'datetime', ]; - /** @var array */ + /** @var array Fields that can be filled */ protected $fillable = ['type']; /** + * Route binder. Converts the key in the URL to the specified object (or throw 404). + * * @param string $type * * @return Model|null|static - * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * @throws NotFoundHttpException */ public static function routeBinder(string $type): TransactionType { @@ -126,9 +129,9 @@ class TransactionType extends Model /** * @codeCoverageIgnore - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ - public function transactionJournals(): \Illuminate\Database\Eloquent\Relations\HasMany + public function transactionJournals(): HasMany { return $this->hasMany(TransactionJournal::class); } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 30247d8ee0..f426485c0f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -25,6 +25,7 @@ namespace FireflyIII\Providers; use Illuminate\Support\Facades\Schema; use Illuminate\Support\ServiceProvider; use Laravel\Passport\Passport; +use URL; /** * @codeCoverageIgnore @@ -38,6 +39,9 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { Schema::defaultStringLength(191); + if ('heroku' === env('APP_ENV')) { + URL::forceScheme('https'); + } } /** diff --git a/app/Providers/FireflyServiceProvider.php b/app/Providers/FireflyServiceProvider.php index 4cef20c2bf..92de23b578 100644 --- a/app/Providers/FireflyServiceProvider.php +++ b/app/Providers/FireflyServiceProvider.php @@ -38,6 +38,8 @@ use FireflyIII\Helpers\Report\BalanceReportHelper; use FireflyIII\Helpers\Report\BalanceReportHelperInterface; use FireflyIII\Helpers\Report\BudgetReportHelper; use FireflyIII\Helpers\Report\BudgetReportHelperInterface; +use FireflyIII\Helpers\Report\NetWorth; +use FireflyIII\Helpers\Report\NetWorthInterface; use FireflyIII\Helpers\Report\PopupReport; use FireflyIII\Helpers\Report\PopupReportInterface; use FireflyIII\Helpers\Report\ReportHelper; @@ -188,5 +190,8 @@ class FireflyServiceProvider extends ServiceProvider // IP thing: $this->app->bind(IPRetrievalInterface::class, IpifyOrg::class); + + // net worth thing. + $this->app->bind(NetWorthInterface::class, NetWorth::class); } } diff --git a/app/Providers/JournalServiceProvider.php b/app/Providers/JournalServiceProvider.php index e9f47fb414..89bec86527 100644 --- a/app/Providers/JournalServiceProvider.php +++ b/app/Providers/JournalServiceProvider.php @@ -22,8 +22,8 @@ declare(strict_types=1); namespace FireflyIII\Providers; -use FireflyIII\Helpers\Collector\JournalCollector; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollector; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Repositories\Journal\JournalRepository; use FireflyIII\Repositories\Journal\JournalRepositoryInterface; use Illuminate\Foundation\Application; @@ -57,10 +57,10 @@ class JournalServiceProvider extends ServiceProvider private function registerCollector(): void { $this->app->bind( - JournalCollectorInterface::class, + TransactionCollectorInterface::class, function (Application $app) { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollector::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollector::class); if ($app->auth->check()) { $collector->setUser(auth()->user()); } diff --git a/app/Repositories/Account/AccountRepository.php b/app/Repositories/Account/AccountRepository.php index e865b10dbc..d70c7d665a 100644 --- a/app/Repositories/Account/AccountRepository.php +++ b/app/Repositories/Account/AccountRepository.php @@ -168,6 +168,18 @@ class AccountRepository implements AccountRepositoryInterface return $this->user->accounts()->find($accountId); } + /** + * Return account type or null if not found. + * + * @param string $type + * + * @return AccountType|null + */ + public function getAccountTypeByType(string $type): ?AccountType + { + return AccountType::whereType($type)->first(); + } + /** * @param array $accountIds * @@ -235,7 +247,7 @@ class AccountRepository implements AccountRepositoryInterface $result = $query->get(['accounts.*']); $result = $result->sortBy( function (Account $account) { - return strtolower($account->name); + return sprintf('%02d', $account->account_type_id) . strtolower($account->name); } ); @@ -258,6 +270,30 @@ class AccountRepository implements AccountRepositoryInterface return $factory->findOrCreate('Cash account', $type->type); } + /** + * @param $account + * + * @return string + */ + public function getInterestPerDay(Account $account): string + { + $interest = $this->getMetaValue($account, 'interest'); + $interestPeriod = $this->getMetaValue($account, 'interest_period'); + Log::debug(sprintf('Start with interest of %s percent', $interest)); + + // calculate + if ('monthly' === $interestPeriod) { + $interest = bcdiv(bcmul($interest, '12'), '365'); // per year + Log::debug(sprintf('Interest is now (monthly to daily) %s percent', $interest)); + } + if ('yearly' === $interestPeriod) { + $interest = bcdiv($interest, '365'); // per year + Log::debug(sprintf('Interest is now (yearly to daily) %s percent', $interest)); + } + + return $interest; + } + /** * Return meta value for account. Null if not found. * @@ -287,6 +323,7 @@ class AccountRepository implements AccountRepositoryInterface public function getNoteText(Account $account): ?string { $note = $account->notes()->first(); + if (null === $note) { return null; } @@ -369,10 +406,62 @@ class AccountRepository implements AccountRepositoryInterface return $account; } + /** + * @param Account $account + * + * @return bool + */ + public function isLiability(Account $account): bool + { + return \in_array($account->accountType->type, [AccountType::CREDITCARD, AccountType::LOAN, AccountType::DEBT, AccountType::MORTGAGE], true); + } + + /** + * Returns the date of the very last transaction in this account. + * + * @param Account $account + * + * @return TransactionJournal|null + */ + public function latestJournal(Account $account): ?TransactionJournal + { + $first = $account->transactions() + ->leftJoin('transaction_journals', 'transaction_journals.id', '=', 'transactions.transaction_journal_id') + ->orderBy('transaction_journals.date', 'DESC') + ->orderBy('transaction_journals.order', 'ASC') + ->where('transaction_journals.user_id', $this->user->id) + ->orderBy('transaction_journals.id', 'DESC') + ->first(['transaction_journals.id']); + if (null !== $first) { + return TransactionJournal::find((int)$first->id); + } + + return null; + } + + /** + * Returns the date of the very last transaction in this account. + * + * @param Account $account + * + * @return Carbon|null + */ + public function latestJournalDate(Account $account): ?Carbon + { + $result = null; + $journal = $this->latestJournal($account); + if (null !== $journal) { + $result = $journal->date; + } + + return $result; + } + /** * Returns the date of the very first transaction in this account. * * @param Account $account + * * @return TransactionJournal|null */ public function oldestJournal(Account $account): ?TransactionJournal @@ -396,11 +485,11 @@ class AccountRepository implements AccountRepositoryInterface * * @param Account $account * - * @return Carbon + * @return Carbon|null */ - public function oldestJournalDate(Account $account): Carbon + public function oldestJournalDate(Account $account): ?Carbon { - $result = new Carbon; + $result = null; $journal = $this->oldestJournal($account); if (null !== $journal) { $result = $journal->date; @@ -437,6 +526,9 @@ class AccountRepository implements AccountRepositoryInterface * @param array $data * * @return Account + * @throws \FireflyIII\Exceptions\FireflyException + * @throws FireflyException + * @throws FireflyException */ public function update(Account $account, array $data): Account { diff --git a/app/Repositories/Account/AccountRepositoryInterface.php b/app/Repositories/Account/AccountRepositoryInterface.php index 09a0981209..77099390d3 100644 --- a/app/Repositories/Account/AccountRepositoryInterface.php +++ b/app/Repositories/Account/AccountRepositoryInterface.php @@ -24,17 +24,18 @@ namespace FireflyIII\Repositories\Account; use Carbon\Carbon; use FireflyIII\Models\Account; - - +use FireflyIII\Models\AccountType; use FireflyIII\Models\TransactionJournal; use FireflyIII\User; use Illuminate\Support\Collection; + /** * Interface AccountRepositoryInterface. */ interface AccountRepositoryInterface { + /** * Moved here from account CRUD. * @@ -87,6 +88,15 @@ interface AccountRepositoryInterface */ public function findNull(int $accountId): ?Account; + /** + * Return account type or null if not found. + * + * @param string $type + * + * @return AccountType|null + */ + public function getAccountTypeByType(string $type): ?AccountType; + /** * @param array $accountIds * @@ -113,6 +123,13 @@ interface AccountRepositoryInterface */ public function getCashAccount(): Account; + /** + * @param $account + * + * @return string + */ + public function getInterestPerDay(Account $account): string; + /** * Return meta value for account. Null if not found. * @@ -141,7 +158,6 @@ interface AccountRepositoryInterface */ public function getOpeningBalanceAmount(Account $account): ?string; - /** * Return date of opening balance as string or null. * @@ -160,10 +176,36 @@ interface AccountRepositoryInterface */ public function getReconciliation(Account $account): ?Account; + /** + * @param Account $account + * + * @return bool + */ + public function isLiability(Account $account): bool; + /** * Returns the date of the very first transaction in this account. * * @param Account $account + * + * @return TransactionJournal|null + */ + public function latestJournal(Account $account): ?TransactionJournal; + + /** + * Returns the date of the very last transaction in this account. + * + * @param Account $account + * + * @return Carbon|null + */ + public function latestJournalDate(Account $account): ?Carbon; + + /** + * Returns the date of the very first transaction in this account. + * + * @param Account $account + * * @return TransactionJournal|null */ public function oldestJournal(Account $account): ?TransactionJournal; @@ -173,9 +215,9 @@ interface AccountRepositoryInterface * * @param Account $account * - * @return Carbon + * @return Carbon|null */ - public function oldestJournalDate(Account $account): Carbon; + public function oldestJournalDate(Account $account): ?Carbon; /** * @param User $user diff --git a/app/Repositories/Account/AccountTasker.php b/app/Repositories/Account/AccountTasker.php index 931b46747e..679369d7d7 100644 --- a/app/Repositories/Account/AccountTasker.php +++ b/app/Repositories/Account/AccountTasker.php @@ -23,7 +23,7 @@ declare(strict_types=1); namespace FireflyIII\Repositories\Account; use Carbon\Carbon; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Models\Transaction; use FireflyIII\Models\TransactionType; use FireflyIII\User; @@ -108,12 +108,12 @@ class AccountTasker implements AccountTaskerInterface // get all expenses for the given accounts in the given period! // also transfers! // get all transactions: - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts($accounts)->setRange($start, $end); $collector->setTypes([TransactionType::WITHDRAWAL, TransactionType::TRANSFER]) ->withOpposingAccount(); - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); $transactions = $transactions->filter( function (Transaction $transaction) { // return negative amounts only. @@ -150,12 +150,12 @@ class AccountTasker implements AccountTaskerInterface // get all expenses for the given accounts in the given period! // also transfers! // get all transactions: - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts($accounts)->setRange($start, $end); $collector->setTypes([TransactionType::DEPOSIT, TransactionType::TRANSFER]) ->withOpposingAccount(); - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); $transactions = $transactions->filter( function (Transaction $transaction) { // return positive amounts only. diff --git a/app/Repositories/Attachment/AttachmentRepositoryInterface.php b/app/Repositories/Attachment/AttachmentRepositoryInterface.php index 527d00f88e..b59378c58d 100644 --- a/app/Repositories/Attachment/AttachmentRepositoryInterface.php +++ b/app/Repositories/Attachment/AttachmentRepositoryInterface.php @@ -50,6 +50,7 @@ interface AttachmentRepositoryInterface /** * @param int $attachmentId + * * @return Attachment|null */ public function findWithoutUser(int $attachmentId): ?Attachment; diff --git a/app/Repositories/Bill/BillRepository.php b/app/Repositories/Bill/BillRepository.php index 390916ce24..9ede87e449 100644 --- a/app/Repositories/Bill/BillRepository.php +++ b/app/Repositories/Bill/BillRepository.php @@ -199,6 +199,36 @@ class BillRepository implements BillRepositoryInterface return $sum; } + /** + * Get the total amount of money paid for the users active bills in the date range given, + * grouped per currency. + * + * @param Carbon $start + * @param Carbon $end + * + * @return array + */ + public function getBillsPaidInRangePerCurrency(Carbon $start, Carbon $end): array + { + $bills = $this->getActiveBills(); + $return = []; + /** @var Bill $bill */ + foreach ($bills as $bill) { + /** @var Collection $set */ + $set = $bill->transactionJournals()->after($start)->before($end)->get(['transaction_journals.*']); + $currencyId = (int)$bill->transaction_currency_id; + if ($set->count() > 0) { + $journalIds = $set->pluck('id')->toArray(); + $amount = (string)Transaction::whereIn('transaction_journal_id', $journalIds)->where('amount', '<', 0)->sum('amount'); + $return[$currencyId] = $return[$currencyId] ?? '0'; + $return[$currencyId] = bcadd($amount, $return[$currencyId]); + Log::debug(sprintf('Total > 0, so add to sum %f, which becomes %f (currency %d)', $amount, $return[$currencyId], $currencyId)); + } + } + + return $return; + } + /** * Get the total amount of money due for the users active bills in the date range given. This amount will be positive. * @@ -231,6 +261,40 @@ class BillRepository implements BillRepositoryInterface return $sum; } + /** + * Get the total amount of money due for the users active bills in the date range given. + * + * @param Carbon $start + * @param Carbon $end + * + * @return array + */ + public function getBillsUnpaidInRangePerCurrency(Carbon $start, Carbon $end): array + { + $bills = $this->getActiveBills(); + $return = []; + /** @var Bill $bill */ + foreach ($bills as $bill) { + Log::debug(sprintf('Now at bill #%d (%s)', $bill->id, $bill->name)); + $dates = $this->getPayDatesInRange($bill, $start, $end); + $count = $bill->transactionJournals()->after($start)->before($end)->count(); + $total = $dates->count() - $count; + $currencyId = (int)$bill->transaction_currency_id; + + Log::debug(sprintf('Dates = %d, journalCount = %d, total = %d', $dates->count(), $count, $total)); + + if ($total > 0) { + $average = bcdiv(bcadd($bill->amount_max, $bill->amount_min), '2'); + $multi = bcmul($average, (string)$total); + $return[$currencyId] = $return[$currencyId] ?? '0'; + $return[$currencyId] = bcadd($return[$currencyId], $multi); + Log::debug(sprintf('Total > 0, so add to sum %f, which becomes %f (for currency %d)', $multi, $return[$currencyId], $currencyId)); + } + } + + return $return; + } + /** * Get all bills with these ID's. * @@ -323,7 +387,7 @@ class BillRepository implements BillRepositoryInterface */ public function getPayDatesInRange(Bill $bill, Carbon $start, Carbon $end): Collection { - $set = new Collection; + $set = new Collection; $currentStart = clone $start; Log::debug(sprintf('Now at bill "%s" (%s)', $bill->name, $bill->repeat_freq)); Log::debug(sprintf('First currentstart is %s', $currentStart->format('Y-m-d'))); diff --git a/app/Repositories/Bill/BillRepositoryInterface.php b/app/Repositories/Bill/BillRepositoryInterface.php index 3c9e1fa8f9..a1a8e6dfcb 100644 --- a/app/Repositories/Bill/BillRepositoryInterface.php +++ b/app/Repositories/Bill/BillRepositoryInterface.php @@ -88,6 +88,17 @@ interface BillRepositoryInterface */ public function getBillsPaidInRange(Carbon $start, Carbon $end): string; + /** + * Get the total amount of money paid for the users active bills in the date range given, + * grouped per currency. + * + * @param Carbon $start + * @param Carbon $end + * + * @return array + */ + public function getBillsPaidInRangePerCurrency(Carbon $start, Carbon $end): array; + /** * Get the total amount of money due for the users active bills in the date range given. * @@ -98,6 +109,16 @@ interface BillRepositoryInterface */ public function getBillsUnpaidInRange(Carbon $start, Carbon $end): string; + /** + * Get the total amount of money due for the users active bills in the date range given. + * + * @param Carbon $start + * @param Carbon $end + * + * @return array + */ + public function getBillsUnpaidInRangePerCurrency(Carbon $start, Carbon $end): array; + /** * Get all bills with these ID's. * diff --git a/app/Repositories/Budget/BudgetRepository.php b/app/Repositories/Budget/BudgetRepository.php index 2bbb0fd169..4b3da1a5b4 100644 --- a/app/Repositories/Budget/BudgetRepository.php +++ b/app/Repositories/Budget/BudgetRepository.php @@ -25,7 +25,7 @@ namespace FireflyIII\Repositories\Budget; use Carbon\Carbon; use Exception; use FireflyIII\Exceptions\FireflyException; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Models\AccountType; use FireflyIII\Models\AvailableBudget; use FireflyIII\Models\Budget; @@ -514,11 +514,11 @@ class BudgetRepository implements BudgetRepositoryInterface } // get all transactions: - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts($accounts)->setRange($start, $end); $collector->setBudgets($budgets); - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); // loop transactions: /** @var Transaction $transaction */ @@ -587,12 +587,12 @@ class BudgetRepository implements BudgetRepositoryInterface public function getNoBudgetPeriodReport(Collection $accounts, Carbon $start, Carbon $end): array { $carbonFormat = Navigation::preferredCarbonFormat($start, $end); - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts($accounts)->setRange($start, $end); $collector->setTypes([TransactionType::WITHDRAWAL]); $collector->withoutBudget(); - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); $result = [ 'entries' => [], 'name' => (string)trans('firefly.no_budget'), @@ -658,8 +658,8 @@ class BudgetRepository implements BudgetRepositoryInterface */ public function spentInPeriod(Collection $budgets, Collection $accounts, Carbon $start, Carbon $end): string { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setUser($this->user); $collector->setRange($start, $end)->setBudgets($budgets)->withBudgetInformation(); @@ -670,7 +670,7 @@ class BudgetRepository implements BudgetRepositoryInterface $collector->setAllAssetAccounts(); } - $set = $collector->getJournals(); + $set = $collector->getTransactions(); return (string)$set->sum('transaction_amount'); } @@ -684,8 +684,8 @@ class BudgetRepository implements BudgetRepositoryInterface */ public function spentInPeriodWoBudget(Collection $accounts, Carbon $start, Carbon $end): string { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setUser($this->user); $collector->setRange($start, $end)->setTypes([TransactionType::WITHDRAWAL])->withoutBudget(); @@ -696,7 +696,7 @@ class BudgetRepository implements BudgetRepositoryInterface $collector->setAllAssetAccounts(); } - $set = $collector->getJournals(); + $set = $collector->getTransactions(); $set = $set->filter( function (Transaction $transaction) { if (bccomp($transaction->transaction_amount, '0') === -1) { diff --git a/app/Repositories/Category/CategoryRepository.php b/app/Repositories/Category/CategoryRepository.php index f35b77f0e4..2a78a7b8ca 100644 --- a/app/Repositories/Category/CategoryRepository.php +++ b/app/Repositories/Category/CategoryRepository.php @@ -24,7 +24,7 @@ namespace FireflyIII\Repositories\Category; use Carbon\Carbon; use FireflyIII\Factory\CategoryFactory; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Models\Category; use FireflyIII\Models\Transaction; use FireflyIII\Models\TransactionType; @@ -74,11 +74,11 @@ class CategoryRepository implements CategoryRepositoryInterface */ public function earnedInPeriod(Collection $categories, Collection $accounts, Carbon $start, Carbon $end): string { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setUser($this->user); $collector->setRange($start, $end)->setTypes([TransactionType::DEPOSIT])->setAccounts($accounts)->setCategories($categories); - $set = $collector->getJournals(); + $set = $collector->getTransactions(); return (string)$set->sum('transaction_amount'); } @@ -226,12 +226,12 @@ class CategoryRepository implements CategoryRepositoryInterface } // get all transactions: - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts($accounts)->setRange($start, $end); $collector->setCategories($categories)->setTypes([TransactionType::WITHDRAWAL, TransactionType::TRANSFER]) ->withOpposingAccount(); - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); // loop transactions: /** @var Transaction $transaction */ @@ -258,12 +258,12 @@ class CategoryRepository implements CategoryRepositoryInterface public function periodExpensesNoCategory(Collection $accounts, Carbon $start, Carbon $end): array { $carbonFormat = Navigation::preferredCarbonFormat($start, $end); - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts($accounts)->setRange($start, $end)->withOpposingAccount(); $collector->setTypes([TransactionType::WITHDRAWAL, TransactionType::TRANSFER]); $collector->withoutCategory(); - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); $result = [ 'entries' => [], 'name' => (string)trans('firefly.no_category'), @@ -310,12 +310,12 @@ class CategoryRepository implements CategoryRepositoryInterface } // get all transactions: - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts($accounts)->setRange($start, $end); $collector->setCategories($categories)->setTypes([TransactionType::DEPOSIT, TransactionType::TRANSFER]) ->withOpposingAccount(); - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); // loop transactions: /** @var Transaction $transaction */ @@ -343,12 +343,12 @@ class CategoryRepository implements CategoryRepositoryInterface { Log::debug('Now in periodIncomeNoCategory()'); $carbonFormat = Navigation::preferredCarbonFormat($start, $end); - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAccounts($accounts)->setRange($start, $end)->withOpposingAccount(); $collector->setTypes([TransactionType::DEPOSIT, TransactionType::TRANSFER]); $collector->withoutCategory(); - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); $result = [ 'entries' => [], 'name' => (string)trans('firefly.no_category'), @@ -392,8 +392,8 @@ class CategoryRepository implements CategoryRepositoryInterface */ public function spentInPeriod(Collection $categories, Collection $accounts, Carbon $start, Carbon $end): string { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setUser($this->user); $collector->setRange($start, $end)->setTypes([TransactionType::WITHDRAWAL])->setCategories($categories); @@ -404,11 +404,94 @@ class CategoryRepository implements CategoryRepositoryInterface $collector->setAllAssetAccounts(); } - $set = $collector->getJournals(); + $set = $collector->getTransactions(); return (string)$set->sum('transaction_amount'); } + /** @noinspection MoreThanThreeArgumentsInspection */ + + /** + * A very cryptic method name that means: + * + * Get me the amount spent in this period, grouped per currency, where no category was set. + * + * @param Collection $accounts + * @param Carbon $start + * @param Carbon $end + * + * @return array + */ + public function spentInPeriodPcWoCategory(Collection $accounts, Carbon $start, Carbon $end): array + { + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); + $collector->setUser($this->user); + $collector->setRange($start, $end)->setTypes([TransactionType::WITHDRAWAL])->withoutCategory(); + + if ($accounts->count() > 0) { + $collector->setAccounts($accounts); + } + if (0 === $accounts->count()) { + $collector->setAllAssetAccounts(); + } + + $set = $collector->getTransactions(); + $set = $set->filter( + function (Transaction $transaction) { + if (bccomp($transaction->transaction_amount, '0') === -1) { + return $transaction; + } + + return null; + } + ); + + $return = []; + /** @var Transaction $transaction */ + foreach ($set as $transaction) { + $currencyId = $transaction->transaction_currency_id; + $return[$currencyId] = $return[$currencyId] ?? '0'; + $return[$currencyId] = bcadd($return[$currencyId], $transaction->transaction_amount); + } + + return $return; + } + + /** + * @param Collection $categories + * @param Collection $accounts + * @param Carbon $start + * @param Carbon $end + * + * @return array + */ + public function spentInPeriodPerCurrency(Collection $categories, Collection $accounts, Carbon $start, Carbon $end): array + { + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); + $collector->setUser($this->user); + $collector->setRange($start, $end)->setTypes([TransactionType::WITHDRAWAL])->setCategories($categories); + + if ($accounts->count() > 0) { + $collector->setAccounts($accounts); + } + if (0 === $accounts->count()) { + $collector->setAllAssetAccounts(); + } + + $set = $collector->getTransactions(); + $return = []; + /** @var Transaction $transaction */ + foreach ($set as $transaction) { + $currencyId = $transaction->transaction_currency_id; + $return[$currencyId] = $return[$currencyId] ?? '0'; + $return[$currencyId] = bcadd($return[$currencyId], $transaction->transaction_amount); + } + + return $return; + } + /** * @param Collection $accounts * @param Carbon $start @@ -418,8 +501,8 @@ class CategoryRepository implements CategoryRepositoryInterface */ public function spentInPeriodWithoutCategory(Collection $accounts, Carbon $start, Carbon $end): string { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setUser($this->user); $collector->setRange($start, $end)->setTypes([TransactionType::WITHDRAWAL])->withoutCategory(); @@ -430,7 +513,7 @@ class CategoryRepository implements CategoryRepositoryInterface $collector->setAllAssetAccounts(); } - $set = $collector->getJournals(); + $set = $collector->getTransactions(); $set = $set->filter( function (Transaction $transaction) { if (bccomp($transaction->transaction_amount, '0') === -1) { diff --git a/app/Repositories/Category/CategoryRepositoryInterface.php b/app/Repositories/Category/CategoryRepositoryInterface.php index 0ec74ea07a..5431df23c7 100644 --- a/app/Repositories/Category/CategoryRepositoryInterface.php +++ b/app/Repositories/Category/CategoryRepositoryInterface.php @@ -158,6 +158,31 @@ interface CategoryRepositoryInterface */ public function spentInPeriod(Collection $categories, Collection $accounts, Carbon $start, Carbon $end): string; + /** @noinspection MoreThanThreeArgumentsInspection */ + + /** + * A very cryptic method name that means: + * + * Get me the amount spent in this period, grouped per currency, where no category was set. + * + * @param Collection $accounts + * @param Carbon $start + * @param Carbon $end + * + * @return string + */ + public function spentInPeriodPcWoCategory(Collection $accounts, Carbon $start, Carbon $end): array; + + /** + * @param Collection $categories + * @param Collection $accounts + * @param Carbon $start + * @param Carbon $end + * + * @return array + */ + public function spentInPeriodPerCurrency(Collection $categories, Collection $accounts, Carbon $start, Carbon $end): array; + /** * @param Collection $accounts * @param Carbon $start diff --git a/app/Repositories/ExportJob/ExportJobRepository.php b/app/Repositories/ExportJob/ExportJobRepository.php index c8d50516a5..756af68450 100644 --- a/app/Repositories/ExportJob/ExportJobRepository.php +++ b/app/Repositories/ExportJob/ExportJobRepository.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace FireflyIII\Repositories\ExportJob; use Carbon\Carbon; +use Exception; use FireflyIII\Models\ExportJob; use FireflyIII\User; use Illuminate\Contracts\Filesystem\FileNotFoundException; @@ -47,18 +48,18 @@ class ExportJobRepository implements ExportJobRepositoryInterface public function changeStatus(ExportJob $job, string $status): bool { Log::debug(sprintf('Change status of job #%d to "%s"', $job->id, $status)); - $job->change($status); + $job->status = $status; + $job->save(); return true; } /** * @return bool - * @throws \Exception */ public function cleanup(): bool { - $dayAgo = Carbon::create()->subDay(); + $dayAgo = Carbon::now()->subDay(); $set = ExportJob::where('created_at', '<', $dayAgo->format('Y-m-d H:i:s')) ->whereIn('status', ['never_started', 'export_status_finished', 'export_downloaded']) ->get(); @@ -74,7 +75,11 @@ class ExportJobRepository implements ExportJobRepositoryInterface unlink(storage_path('export') . DIRECTORY_SEPARATOR . $file); } } - $entry->delete(); + try { + $entry->delete(); + } catch (Exception $e) { + Log::debug(sprintf('Could not delete object: %s', $e->getMessage())); + } } return true; @@ -142,8 +147,8 @@ class ExportJobRepository implements ExportJobRepositoryInterface */ public function getContent(ExportJob $job): string { - $disk = Storage::disk('export'); - $file = $job->key . '.zip'; + $disk = Storage::disk('export'); + $file = $job->key . '.zip'; try { $content = $disk->get($file); diff --git a/app/Repositories/ExportJob/ExportJobRepositoryInterface.php b/app/Repositories/ExportJob/ExportJobRepositoryInterface.php index 65d51523a4..d1a35f47a9 100644 --- a/app/Repositories/ExportJob/ExportJobRepositoryInterface.php +++ b/app/Repositories/ExportJob/ExportJobRepositoryInterface.php @@ -57,6 +57,7 @@ interface ExportJobRepositoryInterface /** * @param string $key + * * @return ExportJob|null */ public function findByKey(string $key): ?ExportJob; diff --git a/app/Repositories/ImportJob/ImportJobRepository.php b/app/Repositories/ImportJob/ImportJobRepository.php index 120e612f40..f325f85166 100644 --- a/app/Repositories/ImportJob/ImportJobRepository.php +++ b/app/Repositories/ImportJob/ImportJobRepository.php @@ -73,6 +73,33 @@ class ImportJobRepository implements ImportJobRepositoryInterface return $job; } + /** + * Append transactions to array instead of replacing them. + * + * @param ImportJob $job + * @param array $transactions + * + * @return ImportJob + */ + public function appendTransactions(ImportJob $job, array $transactions): ImportJob + { + Log::debug(sprintf('Now in appendTransactions(%s)', $job->key)); + $existingTransactions = $job->transactions; + if (!\is_array($existingTransactions)) { + $existingTransactions = []; + } + $new = array_merge($existingTransactions, $transactions); + Log::debug(sprintf('Old transaction count: %d', \count($existingTransactions))); + Log::debug(sprintf('To be added transaction count: %d', \count($transactions))); + Log::debug(sprintf('New count: %d', \count($new))); + $job->transactions = $new; + + + $job->save(); + + return $job; + } + /** * @param string $importProvider * @@ -352,19 +379,7 @@ class ImportJobRepository implements ImportJobRepositoryInterface return new MessageBag; } - /** - * @param ImportJob $job - * @param string $status - * - * @return ImportJob - */ - public function updateStatus(ImportJob $job, string $status): ImportJob - { - $job->status = $status; - $job->save(); - return $job; - } /** * @codeCoverageIgnore diff --git a/app/Repositories/ImportJob/ImportJobRepositoryInterface.php b/app/Repositories/ImportJob/ImportJobRepositoryInterface.php index 6d151a4588..f63fd236bc 100644 --- a/app/Repositories/ImportJob/ImportJobRepositoryInterface.php +++ b/app/Repositories/ImportJob/ImportJobRepositoryInterface.php @@ -45,6 +45,16 @@ interface ImportJobRepositoryInterface */ public function addErrorMessage(ImportJob $job, string $error): ImportJob; + /** + * Append transactions to array instead of replacing them. + * + * @param ImportJob $job + * @param array $transactions + * + * @return ImportJob + */ + public function appendTransactions(ImportJob $job, array $transactions): ImportJob; + /** * @param string $importProvider * @@ -54,6 +64,7 @@ interface ImportJobRepositoryInterface /** * @param string $key + * * @return ImportJob|null */ public function findByKey(string $key): ?ImportJob; @@ -153,12 +164,5 @@ interface ImportJobRepositoryInterface */ public function storeFileUpload(ImportJob $job, string $name, UploadedFile $file): MessageBag; - /** - * @param ImportJob $job - * @param string $status - * - * @return ImportJob - */ - public function updateStatus(ImportJob $job, string $status): ImportJob; } diff --git a/app/Repositories/Journal/JournalRepository.php b/app/Repositories/Journal/JournalRepository.php index 64715c42af..c89d31e2fd 100644 --- a/app/Repositories/Journal/JournalRepository.php +++ b/app/Repositories/Journal/JournalRepository.php @@ -707,6 +707,8 @@ class JournalRepository implements JournalRepositoryInterface * * @return TransactionJournal * + * @throws FireflyException + * @throws FireflyException */ public function update(TransactionJournal $journal, array $data): TransactionJournal { diff --git a/app/Repositories/Journal/JournalRepositoryInterface.php b/app/Repositories/Journal/JournalRepositoryInterface.php index 6b26aaa30a..0b38323d41 100644 --- a/app/Repositories/Journal/JournalRepositoryInterface.php +++ b/app/Repositories/Journal/JournalRepositoryInterface.php @@ -25,7 +25,6 @@ namespace FireflyIII\Repositories\Journal; use Carbon\Carbon; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Models\Account; - use FireflyIII\Models\Transaction; use FireflyIII\Models\TransactionJournal; use FireflyIII\Models\TransactionJournalMeta; diff --git a/app/Repositories/LinkType/LinkTypeRepository.php b/app/Repositories/LinkType/LinkTypeRepository.php index 10d53f37af..9006f4c459 100644 --- a/app/Repositories/LinkType/LinkTypeRepository.php +++ b/app/Repositories/LinkType/LinkTypeRepository.php @@ -230,6 +230,11 @@ class LinkTypeRepository implements LinkTypeRepositoryInterface public function storeLink(array $information, TransactionJournal $inward, TransactionJournal $outward): ?TransactionJournalLink { $linkType = $this->findNull((int)($information['link_type_id'] ?? 0)); + + if (null === $linkType) { + $linkType = $this->findByName($information['link_type_name']); + } + if (null === $linkType) { return null; } diff --git a/app/Repositories/PiggyBank/PiggyBankRepository.php b/app/Repositories/PiggyBank/PiggyBankRepository.php index ad4c3b51a0..a06aabe5c6 100644 --- a/app/Repositories/PiggyBank/PiggyBankRepository.php +++ b/app/Repositories/PiggyBank/PiggyBankRepository.php @@ -443,15 +443,15 @@ class PiggyBankRepository implements PiggyBankRepositoryInterface /** * @param array $data * - * @return PiggyBank + * @return PiggyBank|null */ - public function store(array $data): PiggyBank + public function store(array $data): ?PiggyBank { $data['order'] = $this->getMaxOrder() + 1; /** @var PiggyBank $piggyBank */ $piggyBank = PiggyBank::create($data); - $this->updateNote($piggyBank, $data['note']); + $this->updateNote($piggyBank, $data['notes']); // repetition is auto created. $repetition = $this->getRepetition($piggyBank); @@ -479,7 +479,7 @@ class PiggyBankRepository implements PiggyBankRepositoryInterface $piggyBank->save(); - $this->updateNote($piggyBank, $data['note']); + $this->updateNote($piggyBank, $data['notes']); // if the piggy bank is now smaller than the current relevant rep, // remove money from the rep. diff --git a/app/Repositories/PiggyBank/PiggyBankRepositoryInterface.php b/app/Repositories/PiggyBank/PiggyBankRepositoryInterface.php index dd606c48ba..d6af71404f 100644 --- a/app/Repositories/PiggyBank/PiggyBankRepositoryInterface.php +++ b/app/Repositories/PiggyBank/PiggyBankRepositoryInterface.php @@ -220,9 +220,9 @@ interface PiggyBankRepositoryInterface * * @param array $data * - * @return PiggyBank + * @return PiggyBank|null */ - public function store(array $data): PiggyBank; + public function store(array $data): ?PiggyBank; /** * Update existing piggy bank. diff --git a/app/Repositories/Recurring/RecurringRepository.php b/app/Repositories/Recurring/RecurringRepository.php index 61ccbd4a79..86157afce5 100644 --- a/app/Repositories/Recurring/RecurringRepository.php +++ b/app/Repositories/Recurring/RecurringRepository.php @@ -26,7 +26,7 @@ namespace FireflyIII\Repositories\Recurring; use Carbon\Carbon; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Factory\RecurrenceFactory; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Helpers\Filter\InternalTransferFilter; use FireflyIII\Models\Note; use FireflyIII\Models\Preference; @@ -145,9 +145,9 @@ class RecurringRepository implements RecurringRepositoryInterface * @param Carbon|null $start * @param Carbon|null $end * - * @return Collection + * @return int */ - public function getJournals(Recurrence $recurrence, Carbon $start = null, Carbon $end = null): Collection + public function getJournalCount(Recurrence $recurrence, Carbon $start = null, Carbon $end = null): int { $query = TransactionJournal ::leftJoin('journal_meta', 'journal_meta.transaction_journal_id', '=', 'transaction_journals.id') @@ -162,7 +162,7 @@ class RecurringRepository implements RecurringRepositoryInterface $query->where('transaction_journals.date', '<=', $end->format('Y-m-d 00:00:00')); } - return $query->get(['transaction_journals.*']); + return $query->get(['transaction_journals.*'])->count(); } /** @@ -266,15 +266,15 @@ class RecurringRepository implements RecurringRepositoryInterface foreach ($journalMeta as $journalId) { $search[] = ['id' => (int)$journalId]; } - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setUser($recurrence->user); $collector->withOpposingAccount()->setAllAssetAccounts()->withCategoryInformation()->withBudgetInformation()->setLimit($pageSize)->setPage($page); // filter on specific journals. $collector->removeFilter(InternalTransferFilter::class); $collector->setJournals(new Collection($search)); - return $collector->getPaginatedJournals(); + return $collector->getPaginatedTransactions(); } /** @@ -295,15 +295,15 @@ class RecurringRepository implements RecurringRepositoryInterface foreach ($journalMeta as $journalId) { $search[] = ['id' => (int)$journalId]; } - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setUser($recurrence->user); $collector->withOpposingAccount()->setAllAssetAccounts()->withCategoryInformation()->withBudgetInformation(); // filter on specific journals. $collector->removeFilter(InternalTransferFilter::class); $collector->setJournals(new Collection($search)); - return $collector->getJournals(); + return $collector->getTransactions(); } /** @@ -375,7 +375,7 @@ class RecurringRepository implements RecurringRepositoryInterface } if ('yearly' === $repetition->repetition_type) { // - $today = Carbon::create()->endOfYear(); + $today = Carbon::now()->endOfYear(); $repDate = Carbon::createFromFormat('Y-m-d', $repetition->repetition_moment); $diffInYears = $today->diffInYears($repDate); $repDate->addYears($diffInYears); // technically not necessary. diff --git a/app/Repositories/Recurring/RecurringRepositoryInterface.php b/app/Repositories/Recurring/RecurringRepositoryInterface.php index 73e3e28d0c..b0a6cee56d 100644 --- a/app/Repositories/Recurring/RecurringRepositoryInterface.php +++ b/app/Repositories/Recurring/RecurringRepositoryInterface.php @@ -79,15 +79,15 @@ interface RecurringRepositoryInterface public function getCategory(RecurrenceTransaction $recTransaction): ?string; /** - * Returns the journals created for this recurrence, possibly limited by time. + * Returns the count of journals created for this recurrence, possibly limited by time. * * @param Recurrence $recurrence * @param Carbon|null $start * @param Carbon|null $end * - * @return Collection + * @return int */ - public function getJournals(Recurrence $recurrence, Carbon $start = null, Carbon $end = null): Collection; + public function getJournalCount(Recurrence $recurrence, Carbon $start = null, Carbon $end = null): int; /** * Get the notes. diff --git a/app/Repositories/Rule/RuleRepository.php b/app/Repositories/Rule/RuleRepository.php index a0b4ae4b3b..a17f1ff9c9 100644 --- a/app/Repositories/Rule/RuleRepository.php +++ b/app/Repositories/Rule/RuleRepository.php @@ -292,7 +292,7 @@ class RuleRepository implements RuleRepositoryInterface $rule->order = ($order + 1); $rule->active = true; $rule->strict = $data['strict'] ?? false; - $rule->stop_processing = 1 === (int)$data['stop-processing']; + $rule->stop_processing = 1 === (int)$data['stop_processing']; $rule->title = $data['title']; $rule->description = \strlen($data['description']) > 0 ? $data['description'] : null; @@ -319,7 +319,7 @@ class RuleRepository implements RuleRepositoryInterface $ruleAction->rule()->associate($rule); $ruleAction->order = $values['order']; $ruleAction->active = true; - $ruleAction->stop_processing = $values['stopProcessing']; + $ruleAction->stop_processing = $values['stop_processing']; $ruleAction->action_type = $values['action']; $ruleAction->action_value = $values['value'] ?? ''; $ruleAction->save(); @@ -339,7 +339,7 @@ class RuleRepository implements RuleRepositoryInterface $ruleTrigger->rule()->associate($rule); $ruleTrigger->order = $values['order']; $ruleTrigger->active = true; - $ruleTrigger->stop_processing = $values['stopProcessing']; + $ruleTrigger->stop_processing = $values['stop_processing']; $ruleTrigger->trigger_type = $values['action']; $ruleTrigger->trigger_value = $values['value'] ?? ''; $ruleTrigger->save(); @@ -358,7 +358,7 @@ class RuleRepository implements RuleRepositoryInterface // update rule: $rule->rule_group_id = $data['rule_group_id']; $rule->active = $data['active']; - $rule->stop_processing = $data['stop-processing']; + $rule->stop_processing = $data['stop_processing']; $rule->title = $data['title']; $rule->strict = $data['strict'] ?? false; $rule->description = $data['description']; @@ -388,15 +388,15 @@ class RuleRepository implements RuleRepositoryInterface private function storeActions(Rule $rule, array $data): bool { $order = 1; - foreach ($data['rule-actions'] as $action) { + foreach ($data['rule_actions'] as $action) { $value = $action['value'] ?? ''; - $stopProcessing = $action['stop-processing'] ?? false; + $stopProcessing = $action['stop_processing'] ?? false; $actionValues = [ - 'action' => $action['name'], - 'value' => $value, - 'stopProcessing' => $stopProcessing, - 'order' => $order, + 'action' => $action['name'], + 'value' => $value, + 'stop_processing' => $stopProcessing, + 'order' => $order, ]; $this->storeAction($rule, $actionValues); @@ -417,22 +417,22 @@ class RuleRepository implements RuleRepositoryInterface $stopProcessing = false; $triggerValues = [ - 'action' => 'user_action', - 'value' => $data['trigger'], - 'stopProcessing' => $stopProcessing, - 'order' => $order, + 'action' => 'user_action', + 'value' => $data['trigger'], + 'stop_processing' => $stopProcessing, + 'order' => $order, ]; $this->storeTrigger($rule, $triggerValues); - foreach ($data['rule-triggers'] as $trigger) { + foreach ($data['rule_triggers'] as $trigger) { $value = $trigger['value'] ?? ''; - $stopProcessing = $trigger['stop-processing'] ?? false; + $stopProcessing = $trigger['stop_processing'] ?? false; $triggerValues = [ - 'action' => $trigger['name'], - 'value' => $value, - 'stopProcessing' => $stopProcessing, - 'order' => $order, + 'action' => $trigger['name'], + 'value' => $value, + 'stop_processing' => $stopProcessing, + 'order' => $order, ]; $this->storeTrigger($rule, $triggerValues); diff --git a/app/Repositories/Tag/TagRepository.php b/app/Repositories/Tag/TagRepository.php index 60b2989fcd..d6a5d7076d 100644 --- a/app/Repositories/Tag/TagRepository.php +++ b/app/Repositories/Tag/TagRepository.php @@ -24,7 +24,7 @@ namespace FireflyIII\Repositories\Tag; use Carbon\Carbon; use DB; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Helpers\Filter\InternalTransferFilter; use FireflyIII\Models\Tag; use FireflyIII\Models\TransactionType; @@ -73,11 +73,11 @@ class TagRepository implements TagRepositoryInterface */ public function earnedInPeriod(Tag $tag, Carbon $start, Carbon $end): string { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setUser($this->user); $collector->setRange($start, $end)->setTypes([TransactionType::DEPOSIT])->setAllAssetAccounts()->setTag($tag); - $set = $collector->getJournals(); + $set = $collector->getTransactions(); return (string)$set->sum('transaction_amount'); } @@ -191,11 +191,11 @@ class TagRepository implements TagRepositoryInterface */ public function spentInPeriod(Tag $tag, Carbon $start, Carbon $end): string { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setUser($this->user); $collector->setRange($start, $end)->setTypes([TransactionType::WITHDRAWAL])->setAllAssetAccounts()->setTag($tag); - $set = $collector->getJournals(); + $set = $collector->getTransactions(); return (string)$set->sum('transaction_amount'); } @@ -231,8 +231,8 @@ class TagRepository implements TagRepositoryInterface */ public function sumsOfTag(Tag $tag, ?Carbon $start, ?Carbon $end): array { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); if (null !== $start && null !== $end) { $collector->setRange($start, $end); @@ -240,7 +240,7 @@ class TagRepository implements TagRepositoryInterface $collector->setAllAssetAccounts()->setTag($tag)->withOpposingAccount(); $collector->removeFilter(InternalTransferFilter::class); - $journals = $collector->getJournals(); + $transactions = $collector->getTransactions(); $sums = [ TransactionType::WITHDRAWAL => '0', @@ -248,9 +248,9 @@ class TagRepository implements TagRepositoryInterface TransactionType::TRANSFER => '0', ]; - foreach ($journals as $journal) { - $amount = app('steam')->positive((string)$journal->transaction_amount); - $type = $journal->transaction_type_type; + foreach ($transactions as $transaction) { + $amount = app('steam')->positive((string)$transaction->transaction_amount); + $type = $transaction->transaction_type_type; if (TransactionType::WITHDRAWAL === $type) { $amount = bcmul($amount, '-1'); } diff --git a/app/Repositories/Tag/TagRepositoryInterface.php b/app/Repositories/Tag/TagRepositoryInterface.php index c85b819de3..bedfef3cb7 100644 --- a/app/Repositories/Tag/TagRepositoryInterface.php +++ b/app/Repositories/Tag/TagRepositoryInterface.php @@ -60,7 +60,7 @@ interface TagRepositoryInterface * * @return Tag|null */ - public function findByTag(string $tag):?Tag; + public function findByTag(string $tag): ?Tag; /** * @param int $tagId diff --git a/app/Repositories/User/UserRepository.php b/app/Repositories/User/UserRepository.php index c9d3ef52ff..35fe26d618 100644 --- a/app/Repositories/User/UserRepository.php +++ b/app/Repositories/User/UserRepository.php @@ -54,14 +54,15 @@ class UserRepository implements UserRepositoryInterface { $roleObject = Role::where('name', $role)->first(); if (null === $roleObject) { + Log::error(sprintf('Could not find role "%s" in attachRole()', $role)); return false; } try { - $user->roles()->attach($role); + $user->roles()->attach($roleObject); } catch (QueryException $e) { // don't care - Log::info(sprintf('Query exception when giving user a role: %s', $e->getMessage())); + Log::error(sprintf('Query exception when giving user a role: %s', $e->getMessage())); } return true; @@ -77,6 +78,7 @@ class UserRepository implements UserRepositoryInterface * @see updateEmail * * @return bool + * @throws \Exception */ public function changeEmail(User $user, string $newEmail): bool { diff --git a/app/Services/Currency/FixerIOv2.php b/app/Services/Currency/FixerIOv2.php index 2d5f250f8a..0bbfbc556d 100644 --- a/app/Services/Currency/FixerIOv2.php +++ b/app/Services/Currency/FixerIOv2.php @@ -68,7 +68,7 @@ class FixerIOv2 implements ExchangeRateInterface } // build URI - $uri = sprintf( + $uri = sprintf( 'http://data.fixer.io/api/%s?access_key=%s&base=%s&symbols=%s', $date->format('Y-m-d'), $apiKey, $fromCurrency->code, $toCurrency->code ); diff --git a/app/Services/Github/Request/UpdateRequest.php b/app/Services/Github/Request/UpdateRequest.php index 44a0bc17b7..1616db8701 100644 --- a/app/Services/Github/Request/UpdateRequest.php +++ b/app/Services/Github/Request/UpdateRequest.php @@ -60,7 +60,7 @@ class UpdateRequest implements GithubRequest } try { $releaseXml = new SimpleXMLElement($res->getBody()->getContents(), LIBXML_NOCDATA); - } catch (RunTimeException $e) { + } catch (RuntimeException $e) { Log::error(sprintf('Could not get body from github updat result: %s', $e->getMessage())); $releaseXml = new SimpleXMLElement(''); } diff --git a/app/Services/IP/IpifyOrg.php b/app/Services/IP/IpifyOrg.php index a171b3f8eb..f5abb40d5e 100644 --- a/app/Services/IP/IpifyOrg.php +++ b/app/Services/IP/IpifyOrg.php @@ -27,7 +27,7 @@ use Exception; use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; use Log; -use RunTimeException; +use RuntimeException; /** * Class IpifyOrg @@ -58,7 +58,7 @@ class IpifyOrg implements IPRetrievalInterface } try { $body = (string)$res->getBody()->getContents(); - } catch (RunTimeException $e) { + } catch (RuntimeException $e) { Log::error(sprintf('Could not get body from ipify.org result: %s', $e->getMessage())); $body = null; } diff --git a/app/Services/Internal/Destroy/AccountDestroyService.php b/app/Services/Internal/Destroy/AccountDestroyService.php index 352b5cb48b..daf3546323 100644 --- a/app/Services/Internal/Destroy/AccountDestroyService.php +++ b/app/Services/Internal/Destroy/AccountDestroyService.php @@ -26,8 +26,10 @@ namespace FireflyIII\Services\Internal\Destroy; use DB; use Exception; use FireflyIII\Models\Account; +use FireflyIII\Models\RecurrenceTransaction; use FireflyIII\Models\Transaction; use FireflyIII\Models\TransactionJournal; +use Illuminate\Database\Eloquent\Builder; use Log; /** @@ -44,8 +46,13 @@ class AccountDestroyService */ public function destroy(Account $account, ?Account $moveTo): void { + if (null !== $moveTo) { DB::table('transactions')->where('account_id', $account->id)->update(['account_id' => $moveTo->id]); + + // also update recurring transactions: + DB::table('recurrences_transactions')->where('source_id', $account->id)->update(['source_id' => $moveTo->id]); + DB::table('recurrences_transactions')->where('destination_id', $account->id)->update(['destination_id' => $moveTo->id]); } $service = app(JournalDestroyService::class); @@ -62,6 +69,24 @@ class AccountDestroyService $service->destroy($journal); } } + + // delete recurring transactions with this account: + if (null === $moveTo) { + $recurrences = RecurrenceTransaction:: + where( + function (Builder $q) use ($account) { + $q->where('source_id', $account->id); + $q->orWhere('destination_id', $account->id); + } + )->get(['recurrence_id'])->pluck('recurrence_id')->toArray(); + + + $destroyService = new RecurrenceDestroyService(); + foreach ($recurrences as $recurrenceId) { + $destroyService->destroyById((int)$recurrenceId); + } + } + try { $account->delete(); } catch (Exception $e) { // @codeCoverageIgnore diff --git a/app/Services/Internal/Destroy/RecurrenceDestroyService.php b/app/Services/Internal/Destroy/RecurrenceDestroyService.php index 6dd1bb9ab2..06ab42204b 100644 --- a/app/Services/Internal/Destroy/RecurrenceDestroyService.php +++ b/app/Services/Internal/Destroy/RecurrenceDestroyService.php @@ -69,4 +69,19 @@ class RecurrenceDestroyService } } + /** + * Delete recurrence by ID + * + * @param int $recurrenceId + */ + public function destroyById(int $recurrenceId): void + { + $recurrence = Recurrence::find($recurrenceId); + if (null === $recurrence) { + return; + } + $this->destroy($recurrence); + + } + } diff --git a/app/Services/Internal/Support/AccountServiceTrait.php b/app/Services/Internal/Support/AccountServiceTrait.php index a5bf55b646..8d0c087605 100644 --- a/app/Services/Internal/Support/AccountServiceTrait.php +++ b/app/Services/Internal/Support/AccountServiceTrait.php @@ -45,11 +45,11 @@ use Validator; trait AccountServiceTrait { /** @var array */ - public $validAssetFields = ['accountRole', 'accountNumber', 'currency_id', 'BIC']; + public $validAssetFields = ['accountRole', 'accountNumber', 'currency_id', 'BIC', 'include_net_worth']; /** @var array */ - public $validCCFields = ['accountRole', 'ccMonthlyPaymentDate', 'ccType', 'accountNumber', 'currency_id', 'BIC']; + public $validCCFields = ['accountRole', 'ccMonthlyPaymentDate', 'ccType', 'accountNumber', 'currency_id', 'BIC', 'include_net_worth']; /** @var array */ - public $validFields = ['accountNumber', 'currency_id', 'BIC']; + public $validFields = ['accountNumber', 'currency_id', 'BIC', 'interest', 'interest_period', 'include_net_worth']; /** * @param Account $account @@ -332,6 +332,7 @@ trait AccountServiceTrait * @param string $note * * @return bool + * @throws \Exception */ public function updateNote(Account $account, string $note): bool { diff --git a/app/Services/Internal/Support/JournalServiceTrait.php b/app/Services/Internal/Support/JournalServiceTrait.php index 3b97e9d375..61f30ef5c1 100644 --- a/app/Services/Internal/Support/JournalServiceTrait.php +++ b/app/Services/Internal/Support/JournalServiceTrait.php @@ -75,7 +75,7 @@ trait JournalServiceTrait /** @var BillFactory $factory */ $factory = app(BillFactory::class); $factory->setUser($journal->user); - $bill = $factory->find($data['bill_id'], $data['bill_name']); + $bill = $factory->find((int)$data['bill_id'], $data['bill_name']); if (null !== $bill) { $journal->bill_id = $bill->id; @@ -95,19 +95,13 @@ trait JournalServiceTrait */ protected function storeMeta(TransactionJournal $journal, array $data, string $field): void { - - if (!isset($data[$field])) { - Log::debug(sprintf('Want to store meta-field "%s", but no value.', $field)); - - return; - } $set = [ 'journal' => $journal, 'name' => $field, - 'data' => (string)$data[$field], + 'data' => (string)($data[$field] ?? ''), ]; - Log::debug(sprintf('Going to store meta-field "%s", with value "%s".', $field, (string)$data[$field])); + Log::debug(sprintf('Going to store meta-field "%s", with value "%s".', $set['name'], $set['data'])); /** @var TransactionJournalMetaFactory $factory */ $factory = app(TransactionJournalMetaFactory::class); diff --git a/app/Services/Internal/Support/TransactionTypeTrait.php b/app/Services/Internal/Support/TransactionTypeTrait.php index 4372c736d8..e877e3be76 100644 --- a/app/Services/Internal/Support/TransactionTypeTrait.php +++ b/app/Services/Internal/Support/TransactionTypeTrait.php @@ -22,6 +22,7 @@ declare(strict_types=1); namespace FireflyIII\Services\Internal\Support; + use FireflyIII\Exceptions\FireflyException; use FireflyIII\Factory\TransactionTypeFactory; use FireflyIII\Models\TransactionType; diff --git a/app/Services/Internal/Update/BillUpdateService.php b/app/Services/Internal/Update/BillUpdateService.php index 72c811bee2..0450e6e90c 100644 --- a/app/Services/Internal/Update/BillUpdateService.php +++ b/app/Services/Internal/Update/BillUpdateService.php @@ -47,15 +47,15 @@ class BillUpdateService $bill->amount_min = $data['amount_min']; $bill->amount_max = $data['amount_max']; $bill->date = $data['date']; - $bill->transaction_currency_id = $data['transaction_currency_id']; + $bill->transaction_currency_id = $data['currency_id']; $bill->repeat_freq = $data['repeat_freq']; $bill->skip = $data['skip']; $bill->automatch = true; - $bill->active = $data['active']??true; + $bill->active = $data['active'] ?? true; $bill->save(); // update note: - if (isset($data['notes']) && null !== $data['notes']) { + if (isset($data['notes'])) { $this->updateNote($bill, (string)$data['notes']); } diff --git a/app/Services/Internal/Update/JournalUpdateService.php b/app/Services/Internal/Update/JournalUpdateService.php index df2162636f..99dae64026 100644 --- a/app/Services/Internal/Update/JournalUpdateService.php +++ b/app/Services/Internal/Update/JournalUpdateService.php @@ -26,6 +26,7 @@ namespace FireflyIII\Services\Internal\Update; use FireflyIII\Factory\TransactionFactory; use FireflyIII\Models\Transaction; use FireflyIII\Models\TransactionJournal; +use FireflyIII\Models\TransactionType; use FireflyIII\Services\Internal\Support\JournalServiceTrait; use Illuminate\Support\Collection; use Log; @@ -144,11 +145,20 @@ class JournalUpdateService /** @var TransactionUpdateService $service */ $service = app(TransactionUpdateService::class); $service->setUser($journal->user); - + if (TransactionType::WITHDRAWAL === $journal->transactionType->type) { + /** @var Transaction $transaction */ + foreach ($journal->transactions as $transaction) { + $service->updateBudget($transaction, $budgetId); + } + return $journal; + } + // clear budget. /** @var Transaction $transaction */ foreach ($journal->transactions as $transaction) { - $service->updateBudget($transaction, $budgetId); + $transaction->budgets()->sync([]); } + // remove budgets from journal: + $journal->budgets()->sync([]); return $journal; } diff --git a/app/Services/Internal/Update/RecurrenceUpdateService.php b/app/Services/Internal/Update/RecurrenceUpdateService.php index b33a8b2297..7205b4c9ff 100644 --- a/app/Services/Internal/Update/RecurrenceUpdateService.php +++ b/app/Services/Internal/Update/RecurrenceUpdateService.php @@ -25,7 +25,6 @@ namespace FireflyIII\Services\Internal\Update; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Models\Recurrence; - use FireflyIII\Services\Internal\Support\RecurringTransactionTrait; use FireflyIII\Services\Internal\Support\TransactionServiceTrait; use FireflyIII\Services\Internal\Support\TransactionTypeTrait; @@ -65,7 +64,7 @@ class RecurrenceUpdateService $recurrence->apply_rules = $data['recurrence']['apply_rules'] ?? $recurrence->apply_rules; $recurrence->active = $data['recurrence']['active'] ?? $recurrence->active; - if(isset($data['recurrence']['repetition_end'])) { + if (isset($data['recurrence']['repetition_end'])) { if (\in_array($data['recurrence']['repetition_end'], ['forever ', 'until_date'])) { $recurrence->repetitions = 0; } diff --git a/app/Services/Password/PwndVerifierV2.php b/app/Services/Password/PwndVerifierV2.php index 87e936b846..b78fdeb67b 100644 --- a/app/Services/Password/PwndVerifierV2.php +++ b/app/Services/Password/PwndVerifierV2.php @@ -67,7 +67,7 @@ class PwndVerifierV2 implements Verifier } try { $strpos = stripos($res->getBody()->getContents(), $rest); - } catch (RunTimeException $e) { + } catch (RuntimeException $e) { Log::error(sprintf('Could not get body from Pwnd result: %s', $e->getMessage())); $strpos = false; } diff --git a/app/Services/Spectre/Object/Account.php b/app/Services/Spectre/Object/Account.php index d3cfceb689..e8069f99f1 100644 --- a/app/Services/Spectre/Object/Account.php +++ b/app/Services/Spectre/Object/Account.php @@ -67,8 +67,8 @@ class Account extends SpectreObject $this->nature = $data['nature']; $this->createdAt = new Carbon($data['created_at']); $this->updatedAt = new Carbon($data['updated_at']); - - foreach ($data['extra'] as $key => $value) { + $extraArray = \is_array($data['extra']) ? $data['extra'] : []; + foreach ($extraArray as $key => $value) { $this->extra[$key] = $value; } } diff --git a/app/Services/Spectre/Object/Attempt.php b/app/Services/Spectre/Object/Attempt.php index 53aff5124a..210ad00088 100644 --- a/app/Services/Spectre/Object/Attempt.php +++ b/app/Services/Spectre/Object/Attempt.php @@ -28,6 +28,7 @@ use Carbon\Carbon; /** * * Class Attempt + * * @codeCoverageIgnore * @SuppressWarnings(PHPMD.ShortVariable) * @SuppressWarnings(PHPMD.TooManyFields) diff --git a/app/Services/Spectre/Object/Login.php b/app/Services/Spectre/Object/Login.php index 6d536ac7ce..23b120e0a9 100644 --- a/app/Services/Spectre/Object/Login.php +++ b/app/Services/Spectre/Object/Login.php @@ -28,6 +28,7 @@ use Carbon\Carbon; /** * Class Login + * * @codeCoverageIgnore * @SuppressWarnings(PHPMD.ShortVariable) * @SuppressWarnings(PHPMD.TooManyFields) diff --git a/app/Services/Spectre/Object/Transaction.php b/app/Services/Spectre/Object/Transaction.php index 866ffddbb2..974bf01cc6 100644 --- a/app/Services/Spectre/Object/Transaction.php +++ b/app/Services/Spectre/Object/Transaction.php @@ -27,6 +27,7 @@ use Carbon\Carbon; /** * Class Transaction + * * @codeCoverageIgnore * * @SuppressWarnings(PHPMD.ShortVariable) diff --git a/app/Services/Spectre/Request/NewCustomerRequest.php b/app/Services/Spectre/Request/NewCustomerRequest.php index 099a40cfd9..36e4946387 100644 --- a/app/Services/Spectre/Request/NewCustomerRequest.php +++ b/app/Services/Spectre/Request/NewCustomerRequest.php @@ -24,6 +24,7 @@ namespace FireflyIII\Services\Spectre\Request; use FireflyIII\Services\Spectre\Object\Customer; use Log; + /** * Class NewCustomerRequest */ @@ -37,12 +38,12 @@ class NewCustomerRequest extends SpectreRequest */ public function call(): void { - $data = [ + $data = [ 'data' => [ 'identifier' => 'default_ff3_customer', ], ]; - $uri = '/api/v4/customers/'; + $uri = '/api/v4/customers/'; Log::debug(sprintf('Going to call %s with info:', $uri), $data); $response = $this->sendSignedSpectrePost($uri, $data); // create customer: diff --git a/app/Services/Spectre/Request/SpectreRequest.php b/app/Services/Spectre/Request/SpectreRequest.php index cbf62ebe71..d392fad037 100644 --- a/app/Services/Spectre/Request/SpectreRequest.php +++ b/app/Services/Spectre/Request/SpectreRequest.php @@ -211,7 +211,7 @@ abstract class SpectreRequest $statusCode = $res->getStatusCode(); try { $returnBody = $res->getBody()->getContents(); - } catch (RunTimeException $e) { + } catch (RuntimeException $e) { Log::error(sprintf('Could not get body from SpectreRequest::GET result: %s', $e->getMessage())); $returnBody = ''; } @@ -261,7 +261,7 @@ abstract class SpectreRequest try { $body = $res->getBody()->getContents(); - } catch (RunTimeException $e) { + } catch (RuntimeException $e) { Log::error(sprintf('Could not get body from SpectreRequest::POST result: %s', $e->getMessage())); $body = ''; } diff --git a/app/Services/Ynab/Request/GetAccountsRequest.php b/app/Services/Ynab/Request/GetAccountsRequest.php new file mode 100644 index 0000000000..c6530c7c18 --- /dev/null +++ b/app/Services/Ynab/Request/GetAccountsRequest.php @@ -0,0 +1,54 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Services\Ynab\Request; + +use Log; + +/** + * Class GetAccountsRequest + */ +class GetAccountsRequest extends YnabRequest +{ + /** @var array */ + public $accounts; + /** @var string */ + public $budgetId; + + /** + * + */ + public function call(): void + { + Log::debug('Now in GetAccountsRequest::call()'); + $uri = $this->api . sprintf('/budgets/%s/accounts', $this->budgetId); + + Log::debug(sprintf('URI is %s', $uri)); + + $result = $this->authenticatedGetRequest($uri, []); + //Log::debug('Raw GetAccountsRequest result', $result); + + // expect data in [data][accounts] + $this->accounts = $result['data']['accounts'] ?? []; + } +} \ No newline at end of file diff --git a/app/Services/Ynab/Request/GetBudgetsRequest.php b/app/Services/Ynab/Request/GetBudgetsRequest.php new file mode 100644 index 0000000000..7ea211c042 --- /dev/null +++ b/app/Services/Ynab/Request/GetBudgetsRequest.php @@ -0,0 +1,67 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Services\Ynab\Request; + +use Log; + +/** + * Class GetBudgetsRequest + */ +class GetBudgetsRequest extends YnabRequest +{ + /** @var array */ + public $budgets; + + public function __construct() + { + parent::__construct(); + $this->budgets = []; + } + + /** + * + */ + public function call(): void + { + Log::debug('Now in GetBudgetsRequest::call()'); + $uri = $this->api . '/budgets'; + + Log::debug(sprintf('URI is %s', $uri)); + + $result = $this->authenticatedGetRequest($uri, []); + //Log::debug('Raw GetBudgetsRequest result', $result); + + // expect data in [data][budgets] + $rawBudgets = $result['data']['budgets'] ?? []; + $freshBudgets = []; + foreach ($rawBudgets as $rawBudget) { + $freshBudgets[] = [ + 'id' => $rawBudget['id'], + 'name' => $rawBudget['name'], + 'currency_code' => $rawBudget['currency_format']['iso_code'], + ]; + } + $this->budgets = $freshBudgets; + } +} \ No newline at end of file diff --git a/app/Services/Ynab/Request/GetTransactionsRequest.php b/app/Services/Ynab/Request/GetTransactionsRequest.php new file mode 100644 index 0000000000..8e8c9c5b55 --- /dev/null +++ b/app/Services/Ynab/Request/GetTransactionsRequest.php @@ -0,0 +1,58 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Services\Ynab\Request; + +use Log; + +/** + * Class GetTransactionsRequest + */ +class GetTransactionsRequest extends YnabRequest +{ + /** @var string */ + public $accountId; + /** @var array */ + public $accounts; + /** @var string */ + public $budgetId; + /** @var array */ + public $transactions; + + /** + * + */ + public function call(): void + { + Log::debug('Now in GetTransactionsRequest::call()'); + $uri = $this->api . sprintf('/budgets/%s/accounts/%s/transactions', $this->budgetId, $this->accountId); + + Log::debug(sprintf('URI is %s', $uri)); + + $result = $this->authenticatedGetRequest($uri, []); + //Log::debug('Raw GetTransactionsRequest result', $result); + + // expect data in [data][transactions] + $this->transactions = $result['data']['transactions'] ?? []; + } +} \ No newline at end of file diff --git a/app/Services/Ynab/Request/YnabRequest.php b/app/Services/Ynab/Request/YnabRequest.php new file mode 100644 index 0000000000..3e6a00a185 --- /dev/null +++ b/app/Services/Ynab/Request/YnabRequest.php @@ -0,0 +1,100 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Services\Ynab\Request; + +use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; +use Log; +use RuntimeException; + +/** + * Class YnabRequest + */ +abstract class YnabRequest +{ + /** @var string */ + protected $api; + + /** @var string */ + protected $token; + + public function __construct() + { + $this->api = 'https://' . config('import.options.ynab.live') . '/' . config('import.options.ynab.version'); + } + + /** + * @param string $uri + * @param array|null $params + * + * @return array + */ + public function authenticatedGetRequest(string $uri, array $params = null): array + { + Log::debug(sprintf('Now in YnabRequest::authenticatedGetRequest(%s)', $uri), $params); + $client = new Client; + $params = $params ?? []; + $options = [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $this->token, + ], + ]; + if (\count($params) > 0) { + $uri = $uri . '?' . http_build_query($params); + } + Log::debug(sprintf('Going to call YNAB on URI: %s', $uri), $options); + try { + $res = $client->request('get', $uri, $options); + } catch (GuzzleException $e) { + Log::error($e->getMessage()); + Log::error($e->getTraceAsString()); + + return []; + } + try { + $content = trim($res->getBody()->getContents()); + } catch (RuntimeException $e) { + Log::error($e->getMessage()); + Log::error($e->getTraceAsString()); + + return []; + } + + return json_decode($content, true) ?? []; + } + + /** + * + */ + abstract public function call(): void; + + /** + * @param string $token + */ + public function setAccessToken(string $token): void + { + $this->token = $token; + } + +} \ No newline at end of file diff --git a/app/Support/Amount.php b/app/Support/Amount.php index 48c98cc177..e2fa4c17c0 100644 --- a/app/Support/Amount.php +++ b/app/Support/Amount.php @@ -56,7 +56,7 @@ class Amount $space = ' '; // require space between symbol and amount? - if ($sepBySpace === false) { + if (false === $sepBySpace) { $space = ''; // no } @@ -134,8 +134,8 @@ class Amount // some complicated switches to format the amount correctly: $precedes = $amount < 0 ? $info['n_cs_precedes'] : $info['p_cs_precedes']; $separated = $amount < 0 ? $info['n_sep_by_space'] : $info['p_sep_by_space']; - $space = $separated === true ? ' ' : ''; - $result = $precedes === false ? $formatted . $space . $format->symbol : $format->symbol . $space . $formatted; + $space = true === $separated ? ' ' : ''; + $result = false === $precedes ? $formatted . $space . $format->symbol : $format->symbol . $space . $formatted; if (true === $coloured) { if ($amount > 0) { diff --git a/app/Support/Binder/AccountList.php b/app/Support/Binder/AccountList.php index f2b6497213..3c0cca2618 100644 --- a/app/Support/Binder/AccountList.php +++ b/app/Support/Binder/AccountList.php @@ -74,7 +74,7 @@ class AccountList implements BinderInterface return $collection; } } - Log::error('User is not logged in.'); + Log::error(sprintf('Trying to show account list (%s), but user is not logged in or list is empty.', $route->uri)); throw new NotFoundHttpException; } } diff --git a/app/Support/Binder/CLIToken.php b/app/Support/Binder/CLIToken.php new file mode 100644 index 0000000000..faeb5aef34 --- /dev/null +++ b/app/Support/Binder/CLIToken.php @@ -0,0 +1,62 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Binder; + +use FireflyIII\Repositories\User\UserRepositoryInterface; +use Illuminate\Routing\Route; +use Illuminate\Support\Collection; +use Log; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +/** + * Class CLIToken + */ +class CLIToken implements BinderInterface +{ + + /** + * @param string $value + * @param Route $route + * + * @return mixed + * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + */ + public static function routeBinder(string $value, Route $route) + { + /** @var UserRepositoryInterface $repository */ + $repository = app(UserRepositoryInterface::class); + /** @var Collection $users */ + $users = $repository->all(); + + foreach ($users as $user) { + $accessToken = app('preferences')->getForUser($user, 'access_token', null); + if ($accessToken->data === $value) { + Log::info(sprintf('Recognized user #%d (%s) from his acccess token.', $user->id, $user->email)); + + return $value; + } + } + throw new NotFoundHttpException; + } +} \ No newline at end of file diff --git a/app/Support/Binder/ImportProvider.php b/app/Support/Binder/ImportProvider.php index 989a616936..926696b8ab 100644 --- a/app/Support/Binder/ImportProvider.php +++ b/app/Support/Binder/ImportProvider.php @@ -54,21 +54,18 @@ class ImportProvider implements BinderInterface foreach ($providerNames as $providerName) { // only consider enabled providers $enabled = (bool)config(sprintf('import.enabled.%s', $providerName)); - $allowedForDemo = (bool)config(sprintf('import.allowed_for_demo.%s', $providerName)); $allowedForUser = (bool)config(sprintf('import.allowed_for_user.%s', $providerName)); if (false === $enabled) { continue; } - if (true === $isDemoUser && false === $allowedForDemo) { - continue; - } if (false === $isDemoUser && false === $allowedForUser && false === $isDebug) { continue; // @codeCoverageIgnore } $providers[$providerName] = [ - 'has_prereq' => (bool)config('import.has_prereq.' . $providerName), + 'has_prereq' => (bool)config('import.has_prereq.' . $providerName), + 'allowed_for_demo' => (bool)config(sprintf('import.allowed_for_demo.%s', $providerName)), ]; $class = (string)config(sprintf('import.prerequisites.%s', $providerName)); $result = false; diff --git a/app/Support/Binder/TagList.php b/app/Support/Binder/TagList.php index 2201eb09b2..6919034293 100644 --- a/app/Support/Binder/TagList.php +++ b/app/Support/Binder/TagList.php @@ -45,6 +45,7 @@ class TagList implements BinderInterface { if (auth()->check()) { $list = array_unique(array_map('\strtolower', explode(',', $value))); + Log::debug('List of tags is', $list); if (0 === \count($list)) { Log::error('Tag list is empty.'); throw new NotFoundHttpException; // @codeCoverageIgnore @@ -56,7 +57,14 @@ class TagList implements BinderInterface $collection = $allTags->filter( function (Tag $tag) use ($list) { - return \in_array(strtolower($tag->tag), $list, true); + if(\in_array(strtolower($tag->tag), $list, true)) { + return true; + } + if(\in_array((string)$tag->id, $list, true)) { + return true; + } + + return false; } ); diff --git a/resources/assets/js/lang.js b/app/Support/Cronjobs/AbstractCronjob.php similarity index 71% rename from resources/assets/js/lang.js rename to app/Support/Cronjobs/AbstractCronjob.php index c924ac42c1..3eebb176be 100644 --- a/resources/assets/js/lang.js +++ b/app/Support/Cronjobs/AbstractCronjob.php @@ -1,5 +1,6 @@ -/* - * lang.js +. */ -// Inside my /resources/assets/js/lang.js -import lang from 'lang.js'; -import messages from './messages'; +declare(strict_types=1); -const Lang = new lang({messages}); +namespace FireflyIII\Support\Cronjobs; -Lang.setLocale(window.language); -Lang.setFallback('en_US'); +/** + * Class AbstractCronjob + */ +abstract class AbstractCronjob +{ + /** @var int */ + public $timeBetweenRuns = 43200; -export default Lang; + /** + * @return bool + */ + abstract public function fire(): bool; + +} \ No newline at end of file diff --git a/app/Support/Cronjobs/RecurringCronjob.php b/app/Support/Cronjobs/RecurringCronjob.php new file mode 100644 index 0000000000..c75e11ad29 --- /dev/null +++ b/app/Support/Cronjobs/RecurringCronjob.php @@ -0,0 +1,84 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Cronjobs; + +use Carbon\Carbon; +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Jobs\CreateRecurringTransactions; +use FireflyIII\Models\Configuration; +use Log; + +/** + * Class RecurringCronjob + */ +class RecurringCronjob extends AbstractCronjob +{ + + /** + * @return bool + * @throws FireflyException + */ + public function fire(): bool + { + /** @var Configuration $config */ + $config = app('fireflyconfig')->get('last_rt_job', 0); + $lastTime = (int)$config->data; + $diff = time() - $lastTime; + $diffForHumans = Carbon::now()->diffForHumans(Carbon::createFromTimestamp($lastTime), true); + if (0 === $lastTime) { + Log::info('Recurring transactions cronjob has never fired before.'); + } + // less than half a day ago: + if ($lastTime > 0 && $diff <= 43200) { + Log::info(sprintf('It has been %s since the recurring transactions cronjob has fired. It will not fire now.', $diffForHumans)); + + return false; + } + + if ($lastTime > 0 && $diff > 43200) { + Log::info(sprintf('It has been %s since the recurring transactions cronjob has fired. It will fire now!', $diffForHumans)); + } + + try { + $this->fireRecurring(); + } catch (FireflyException $e) { + Log::error($e->getMessage()); + Log::error($e->getTraceAsString()); + throw new FireflyException(sprintf('Could not run recurring transaction cron job: %s', $e->getMessage())); + } + + return true; + } + + /** + * + * @throws FireflyException + */ + private function fireRecurring(): void + { + $job = new CreateRecurringTransactions(new Carbon); + $job->handle(); + app('fireflyconfig')->set('last_rt_job', time()); + } +} \ No newline at end of file diff --git a/app/Support/ExpandedForm.php b/app/Support/ExpandedForm.php index 3f7e706f9e..cfae9d8b1d 100644 --- a/app/Support/ExpandedForm.php +++ b/app/Support/ExpandedForm.php @@ -41,7 +41,6 @@ use Illuminate\Support\HtmlString; use Illuminate\Support\MessageBag; use Log; use RuntimeException; -use Session; use Throwable; /** @@ -67,12 +66,12 @@ class ExpandedForm /** @var CurrencyRepositoryInterface $currencyRepos */ $currencyRepos = app(CurrencyRepositoryInterface::class); - $assetAccounts = $repository->getActiveAccountsByType([AccountType::ASSET, AccountType::DEFAULT]); + $accountList = $repository->getActiveAccountsByType([AccountType::ASSET, AccountType::DEFAULT]); $defaultCurrency = app('amount')->getDefaultCurrency(); $grouped = []; // group accounts: /** @var Account $account */ - foreach ($assetAccounts as $account) { + foreach ($accountList as $account) { $balance = app('steam')->balance($account, new Carbon); $currencyId = (int)$repository->getMetaValue($account, 'currency_id'); $currency = $currencyRepos->findNull($currencyId); @@ -80,6 +79,54 @@ class ExpandedForm if ('' === $role) { $role = 'no_account_type'; // @codeCoverageIgnore } + + if (null === $currency) { + $currency = $defaultCurrency; + } + + $key = (string)trans('firefly.opt_group_' . $role); + $grouped[$key][$account->id] = $account->name . ' (' . app('amount')->formatAnything($currency, $balance, false) . ')'; + } + + return $this->select($name, $grouped, $value, $options); + } + + /** + * @param string $name + * @param mixed $value + * @param array $options + * + * @return string + */ + public function activeLongAccountList(string $name, $value = null, array $options = null): string + { + // make repositories + /** @var AccountRepositoryInterface $repository */ + $repository = app(AccountRepositoryInterface::class); + /** @var CurrencyRepositoryInterface $currencyRepos */ + $currencyRepos = app(CurrencyRepositoryInterface::class); + + $accountList = $repository->getActiveAccountsByType( + [AccountType::ASSET, AccountType::DEFAULT, AccountType::MORTGAGE, AccountType::DEBT, AccountType::CREDITCARD, AccountType::LOAN,] + ); + $liabilityTypes = [AccountType::MORTGAGE, AccountType::DEBT, AccountType::CREDITCARD, AccountType::LOAN]; + $defaultCurrency = app('amount')->getDefaultCurrency(); + $grouped = []; + // group accounts: + /** @var Account $account */ + foreach ($accountList as $account) { + $balance = app('steam')->balance($account, new Carbon); + $currencyId = (int)$repository->getMetaValue($account, 'currency_id'); + $currency = $currencyRepos->findNull($currencyId); + $role = $repository->getMetaValue($account, 'accountRole'); + if ('' === $role && !\in_array($account->accountType->type, $liabilityTypes, true)) { + $role = 'no_account_type'; // @codeCoverageIgnore + } + + if (\in_array($account->accountType->type, $liabilityTypes, true)) { + $role = 'l_' . $account->accountType->type; // @codeCoverageIgnore + } + if (null === $currency) { $currency = $defaultCurrency; } @@ -193,12 +240,12 @@ class ExpandedForm /** @var CurrencyRepositoryInterface $currencyRepos */ $currencyRepos = app(CurrencyRepositoryInterface::class); - $assetAccounts = $repository->getAccountsByType([AccountType::ASSET, AccountType::DEFAULT]); + $accountList = $repository->getAccountsByType([AccountType::ASSET, AccountType::DEFAULT]); $defaultCurrency = app('amount')->getDefaultCurrency(); $grouped = []; // group accounts: /** @var Account $account */ - foreach ($assetAccounts as $account) { + foreach ($accountList as $account) { $balance = app('steam')->balance($account, new Carbon); $currencyId = (int)$repository->getMetaValue($account, 'currency_id'); $currency = $currencyRepos->findNull($currencyId); @@ -206,6 +253,7 @@ class ExpandedForm if ('' === $role) { $role = 'no_account_type'; // @codeCoverageIgnore } + if (null === $currency) { $currency = $defaultCurrency; } @@ -230,7 +278,6 @@ class ExpandedForm return $this->currencyField($name, 'balance', $value, $options); } - /** @noinspection MoreThanThreeArgumentsInspection */ /** * @param string $name * @param int $value @@ -246,7 +293,7 @@ class ExpandedForm $value = $value ?? 1; $options['checked'] = true === $checked; - if (Session::has('preFilled')) { + if (app('session')->has('preFilled')) { $preFilled = session('preFilled'); $options['checked'] = $preFilled[$name] ?? $options['checked']; } @@ -267,6 +314,8 @@ class ExpandedForm return $html; } + /** @noinspection MoreThanThreeArgumentsInspection */ + /** * @param string $name * @param mixed $value @@ -414,6 +463,53 @@ class ExpandedForm return $html; } + /** + * @param string $name + * @param mixed $value + * @param array $options + * + * @return string + */ + public function longAccountList(string $name, $value = null, array $options = null): string + { + // make repositories + /** @var AccountRepositoryInterface $repository */ + $repository = app(AccountRepositoryInterface::class); + /** @var CurrencyRepositoryInterface $currencyRepos */ + $currencyRepos = app(CurrencyRepositoryInterface::class); + + $accountList = $repository->getAccountsByType( + [AccountType::ASSET, AccountType::DEFAULT, AccountType::MORTGAGE, AccountType::DEBT, AccountType::CREDITCARD, AccountType::LOAN,] + ); + $liabilityTypes = [AccountType::MORTGAGE, AccountType::DEBT, AccountType::CREDITCARD, AccountType::LOAN]; + $defaultCurrency = app('amount')->getDefaultCurrency(); + $grouped = []; + // group accounts: + /** @var Account $account */ + foreach ($accountList as $account) { + $balance = app('steam')->balance($account, new Carbon); + $currencyId = (int)$repository->getMetaValue($account, 'currency_id'); + $currency = $currencyRepos->findNull($currencyId); + $role = (string)$repository->getMetaValue($account, 'accountRole'); + if ('' === $role) { + $role = 'no_account_type'; // @codeCoverageIgnore + } + + if (\in_array($account->accountType->type, $liabilityTypes, true)) { + $role = 'l_' . $account->accountType->type; // @codeCoverageIgnore + } + + if (null === $currency) { + $currency = $defaultCurrency; + } + + $key = (string)trans('firefly.opt_group_' . $role); + $grouped[$key][$account->id] = $account->name . ' (' . app('amount')->formatAnything($currency, $balance, false) . ')'; + } + + return $this->select($name, $grouped, $value, $options); + } + /** * Takes any collection and tries to make a sensible select list compatible array of it. * @@ -570,6 +666,34 @@ class ExpandedForm return $html; } + /** + * Function to render a percentage. + * + * @param string $name + * @param mixed $value + * @param array $options + * + * @return string + * + */ + public function percentage(string $name, $value = null, array $options = null): string + { + $label = $this->label($name, $options); + $options = $this->expandOptionArray($name, $label, $options); + $classes = $this->getHolderClasses($name); + $value = $this->fillFieldValue($name, $value); + $options['step'] = 'any'; + unset($options['placeholder']); + try { + $html = view('form.percentage', compact('classes', 'name', 'label', 'value', 'options'))->render(); + } catch (Throwable $e) { + Log::debug(sprintf('Could not render percentage(): %s', $e->getMessage())); + $html = 'Could not render percentage.'; + } + + return $html; + } + /** * @param string $name * @param mixed $value @@ -761,6 +885,11 @@ class ExpandedForm $classes = $this->getHolderClasses($name); $value = $this->fillFieldValue($name, $value); $options['rows'] = 4; + + if (null === $value) { + $value = ''; + } + try { $html = view('form.textarea', compact('classes', 'name', 'label', 'value', 'options'))->render(); } catch (Throwable $e) { @@ -771,6 +900,7 @@ class ExpandedForm return $html; } + /** @noinspection MoreThanThreeArgumentsInspection */ /** * @param string $name * @param string $view @@ -791,7 +921,7 @@ class ExpandedForm $options['step'] = 'any'; $defaultCurrency = $options['currency'] ?? Amt::getDefaultCurrency(); /** @var Collection $currencies */ - $currencies = app('amount')->getAllCurrencies(); + $currencies = app('amount')->getAllCurrencies(); unset($options['currency'], $options['placeholder']); // perhaps the currency has been sent to us in the field $amount_currency_id_$name (amount_currency_id_amount) @@ -850,12 +980,13 @@ class ExpandedForm * @return mixed * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ - protected function fillFieldValue(string $name, $value) + protected function fillFieldValue(string $name, $value = null) { - if (Session::has('preFilled')) { + if (app('session')->has('preFilled')) { $preFilled = session('preFilled'); $value = isset($preFilled[$name]) && null === $value ? $preFilled[$name] : $value; } + try { if (null !== request()->old($name)) { $value = request()->old($name); @@ -864,6 +995,7 @@ class ExpandedForm // don't care about session errors. Log::debug(sprintf('Run time: %s', $e->getMessage())); } + if ($value instanceof Carbon) { $value = $value->format('Y-m-d'); } diff --git a/app/Support/FireflyConfig.php b/app/Support/FireflyConfig.php index 1c9a1c9d3c..debd591f77 100644 --- a/app/Support/FireflyConfig.php +++ b/app/Support/FireflyConfig.php @@ -118,9 +118,11 @@ class FireflyConfig public function set(string $name, $value): Configuration { Log::debug('Set new value for ', ['name' => $name]); + /** @var Configuration $config */ $config = Configuration::whereName($name)->first(); if (null === $config) { Log::debug('Does not exist yet ', ['name' => $name]); + /** @var Configuration $item */ $item = new Configuration; $item->name = $name; $item->data = $value; @@ -130,7 +132,7 @@ class FireflyConfig return $item; } - Log::debug('Exists already ', ['name' => $name]); + Log::debug('Exists already, overwrite value.', ['name' => $name]); $config->data = $value; $config->save(); Cache::forget('ff-config-' . $name); diff --git a/app/Support/Http/Controllers/AugumentData.php b/app/Support/Http/Controllers/AugumentData.php new file mode 100644 index 0000000000..bd014b77c1 --- /dev/null +++ b/app/Support/Http/Controllers/AugumentData.php @@ -0,0 +1,263 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Http\Controllers; + +use Carbon\Carbon; +use FireflyIII\Models\Account; +use FireflyIII\Models\AccountType; +use FireflyIII\Models\Budget; +use FireflyIII\Models\BudgetLimit; +use FireflyIII\Models\Tag; +use FireflyIII\Models\Transaction; +use FireflyIII\Repositories\Account\AccountRepositoryInterface; +use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; +use FireflyIII\Repositories\Category\CategoryRepositoryInterface; +use Illuminate\Support\Collection; + +/** + * Trait AugumentData + * + */ +trait AugumentData +{ + /** + * Searches for the opposing account. + * + * @param Collection $accounts + * + * @return array + */ + protected function combineAccounts(Collection $accounts): array // filter + group data + { + /** @var AccountRepositoryInterface $repository */ + $repository = app(AccountRepositoryInterface::class); + $combined = []; + /** @var Account $expenseAccount */ + foreach ($accounts as $expenseAccount) { + $collection = new Collection; + $collection->push($expenseAccount); + + $revenue = $repository->findByName($expenseAccount->name, [AccountType::REVENUE]); + if (null !== $revenue) { + $collection->push($revenue); + } + $combined[$expenseAccount->name] = $collection; + } + + return $combined; + } + + /** + * Returns the budget limits belonging to the given budget and valid on the given day. + * + * @param Collection $budgetLimits + * @param Budget $budget + * @param Carbon $start + * @param Carbon $end + * + * @return Collection + */ + protected function filterBudgetLimits(Collection $budgetLimits, Budget $budget, Carbon $start, Carbon $end): Collection // filter data + { + $set = $budgetLimits->filter( + function (BudgetLimit $budgetLimit) use ($budget, $start, $end) { + if ($budgetLimit->budget_id === $budget->id + && $budgetLimit->start_date->lte($start) // start of budget limit is on or before start + && $budgetLimit->end_date->gte($end) // end of budget limit is on or after end + ) { + return $budgetLimit; + } + + return false; + } + ); + + return $set; + } + + /** + * Get the account names belonging to a bunch of account ID's. + * + * @param array $accountIds + * + * @return array + */ + protected function getAccountNames(array $accountIds): array // extract info from array. + { + /** @var AccountRepositoryInterface $repository */ + $repository = app(AccountRepositoryInterface::class); + $accounts = $repository->getAccountsByType([AccountType::ASSET, AccountType::DEFAULT, AccountType::EXPENSE, AccountType::CASH]); + $grouped = $accounts->groupBy('id')->toArray(); + $return = []; + foreach ($accountIds as $accountId) { + if (isset($grouped[$accountId])) { + $return[$accountId] = $grouped[$accountId][0]['name']; + } + } + $return[0] = '(no name)'; + + return $return; + } + + /** + * Get the budget names from a set of budget ID's. + * + * @param array $budgetIds + * + * @return array + */ + protected function getBudgetNames(array $budgetIds): array // extract info from array. + { + /** @var BudgetRepositoryInterface $repository */ + $repository = app(BudgetRepositoryInterface::class); + $budgets = $repository->getBudgets(); + $grouped = $budgets->groupBy('id')->toArray(); + $return = []; + foreach ($budgetIds as $budgetId) { + if (isset($grouped[$budgetId])) { + $return[$budgetId] = $grouped[$budgetId][0]['name']; + } + } + $return[0] = (string)trans('firefly.no_budget'); + + return $return; + } + + /** + * Get the category names from a set of category ID's. Small helper function for some of the charts. + * + * @param array $categoryIds + * + * @return array + */ + protected function getCategoryNames(array $categoryIds): array // extract info from array. + { + /** @var CategoryRepositoryInterface $repository */ + $repository = app(CategoryRepositoryInterface::class); + $categories = $repository->getCategories(); + $grouped = $categories->groupBy('id')->toArray(); + $return = []; + foreach ($categoryIds as $categoryId) { + if (isset($grouped[$categoryId])) { + $return[$categoryId] = $grouped[$categoryId][0]['name']; + } + } + $return[0] = (string)trans('firefly.no_category'); + + return $return; + } + + /** + * Helper function that groups expenses. + * + * @param Collection $set + * + * @return array + */ + protected function groupByBudget(Collection $set): array // filter + group data + { + // group by category ID: + $grouped = []; + /** @var Transaction $transaction */ + foreach ($set as $transaction) { + $jrnlBudId = (int)$transaction->transaction_journal_budget_id; + $transBudId = (int)$transaction->transaction_budget_id; + $budgetId = max($jrnlBudId, $transBudId); + $grouped[$budgetId] = $grouped[$budgetId] ?? '0'; + $grouped[$budgetId] = bcadd($transaction->transaction_amount, $grouped[$budgetId]); + } + + return $grouped; + } + + /** + * Group transactions by category. + * + * @param Collection $set + * + * @return array + */ + protected function groupByCategory(Collection $set): array // filter + group data + { + // group by category ID: + $grouped = []; + /** @var Transaction $transaction */ + foreach ($set as $transaction) { + $jrnlCatId = (int)$transaction->transaction_journal_category_id; + $transCatId = (int)$transaction->transaction_category_id; + $categoryId = max($jrnlCatId, $transCatId); + $grouped[$categoryId] = $grouped[$categoryId] ?? '0'; + $grouped[$categoryId] = bcadd($transaction->transaction_amount, $grouped[$categoryId]); + } + + return $grouped; + } + + /** + * Group set of transactions by name of opposing account. + * + * @param Collection $set + * + * @return array + */ + protected function groupByName(Collection $set): array // filter + group data + { + // group by opposing account name. + $grouped = []; + /** @var Transaction $transaction */ + foreach ($set as $transaction) { + $name = $transaction->opposing_account_name; + $grouped[$name] = $grouped[$name] ?? '0'; + $grouped[$name] = bcadd($transaction->transaction_amount, $grouped[$name]); + } + + return $grouped; + } + + /** + * Group transactions by tag. + * + * @param Collection $set + * + * @return array + */ + protected function groupByTag(Collection $set): array // filter + group data + { + // group by category ID: + $grouped = []; + /** @var Transaction $transaction */ + foreach ($set as $transaction) { + $journal = $transaction->transactionJournal; + $journalTags = $journal->tags; + /** @var Tag $journalTag */ + foreach ($journalTags as $journalTag) { + $journalTagId = $journalTag->id; + $grouped[$journalTagId] = $grouped[$journalTagId] ?? '0'; + $grouped[$journalTagId] = bcadd($transaction->transaction_amount, $grouped[$journalTagId]); + } + } + + return $grouped; + } +} \ No newline at end of file diff --git a/app/Support/Http/Controllers/BasicDataSupport.php b/app/Support/Http/Controllers/BasicDataSupport.php new file mode 100644 index 0000000000..0a1809542b --- /dev/null +++ b/app/Support/Http/Controllers/BasicDataSupport.php @@ -0,0 +1,93 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Http\Controllers; + +/** + * Trait BasicDataSupport + * + */ +trait BasicDataSupport +{ + + /** + * Filters empty results from getBudgetPeriodReport. + * + * @param array $data + * + * @return array + */ + protected function filterPeriodReport(array $data): array // helper function for period overview. + { + /** + * @var int $entryId + * @var array $set + */ + foreach ($data as $entryId => $set) { + $sum = '0'; + foreach ($set['entries'] as $amount) { + $sum = bcadd($amount, $sum); + } + $data[$entryId]['sum'] = $sum; + if (0 === bccomp('0', $sum)) { + unset($data[$entryId]); + } + } + + return $data; + } + /** + * Sum up an array. + * + * @param array $array + * + * @return string + */ + protected function arraySum(array $array): string // filter + group data + { + $sum = '0'; + foreach ($array as $entry) { + $sum = bcadd($sum, $entry); + } + + return $sum; + } + + /** + * Find the ID in a given array. Return '0' of not there (amount). + * + * @param array $array + * @param int $entryId + * + * @return null|mixed + */ + protected function isInArray(array $array, int $entryId) // helper for data (math, calculations) + { + $result = '0'; + if (isset($array[$entryId])) { + $result = $array[$entryId]; + } + + return $result; + } +} \ No newline at end of file diff --git a/app/Support/Http/Controllers/CreateStuff.php b/app/Support/Http/Controllers/CreateStuff.php new file mode 100644 index 0000000000..6fc4a3e798 --- /dev/null +++ b/app/Support/Http/Controllers/CreateStuff.php @@ -0,0 +1,219 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Http\Controllers; + +use Carbon\Carbon; +use Exception; +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Http\Requests\NewUserFormRequest; +use FireflyIII\Import\JobConfiguration\JobConfigurationInterface; +use FireflyIII\Import\Storage\ImportArrayStorage; +use FireflyIII\Models\ImportJob; +use FireflyIII\Models\TransactionCurrency; +use FireflyIII\Repositories\Account\AccountRepositoryInterface; +use FireflyIII\User; +use Laravel\Passport\Passport; +use Log; +use phpseclib\Crypt\RSA; + +/** + * Trait CreateStuff + * + */ +trait CreateStuff +{ + + /** + * Creates an asset account. + * + * @param NewUserFormRequest $request + * @param TransactionCurrency $currency + * + * @return bool + */ + protected function createAssetAccount(NewUserFormRequest $request, TransactionCurrency $currency): bool // create stuff + { + /** @var AccountRepositoryInterface $repository */ + $repository = app(AccountRepositoryInterface::class); + $assetAccount = [ + 'name' => $request->get('bank_name'), + 'iban' => null, + 'accountType' => 'asset', + 'virtualBalance' => 0, + 'account_type_id' => null, + 'active' => true, + 'accountRole' => 'defaultAsset', + 'openingBalance' => $request->input('bank_balance'), + 'openingBalanceDate' => new Carbon, + 'currency_id' => $currency->id, + ]; + + $repository->store($assetAccount); + + return true; + } + + /** + * Creates a cash wallet. + * + * @param TransactionCurrency $currency + * @param string $language + * + * @return bool + */ + protected function createCashWalletAccount(TransactionCurrency $currency, string $language): bool // create stuff + { + /** @var AccountRepositoryInterface $repository */ + $repository = app(AccountRepositoryInterface::class); + $assetAccount = [ + 'name' => (string)trans('firefly.cash_wallet', [], $language), + 'iban' => null, + 'accountType' => 'asset', + 'virtualBalance' => 0, + 'account_type_id' => null, + 'active' => true, + 'accountRole' => 'cashWalletAsset', + 'openingBalance' => null, + 'openingBalanceDate' => null, + 'currency_id' => $currency->id, + ]; + + $repository->store($assetAccount); + + return true; + } + + /** + * Create new RSA keys. + */ + protected function createOAuthKeys(): void // create stuff + { + $rsa = new RSA(); + $keys = $rsa->createKey(4096); + + [$publicKey, $privateKey] = [ + Passport::keyPath('oauth-public.key'), + Passport::keyPath('oauth-private.key'), + ]; + + if (file_exists($publicKey) || file_exists($privateKey)) { + return; + } + // @codeCoverageIgnoreStart + Log::alert('NO OAuth keys were found. They have been created.'); + + file_put_contents($publicKey, array_get($keys, 'publickey')); + file_put_contents($privateKey, array_get($keys, 'privatekey')); + } + + /** + * Create a savings account. + * + * @param NewUserFormRequest $request + * @param TransactionCurrency $currency + * @param string $language + * + * @return bool + */ + protected function createSavingsAccount(NewUserFormRequest $request, TransactionCurrency $currency, string $language): bool // create stuff + { + /** @var AccountRepositoryInterface $repository */ + $repository = app(AccountRepositoryInterface::class); + $savingsAccount = [ + 'name' => (string)trans('firefly.new_savings_account', ['bank_name' => $request->get('bank_name')], $language), + 'iban' => null, + 'accountType' => 'asset', + 'account_type_id' => null, + 'virtualBalance' => 0, + 'active' => true, + 'accountRole' => 'savingAsset', + 'openingBalance' => $request->input('savings_balance'), + 'openingBalanceDate' => new Carbon, + 'currency_id' => $currency->id, + ]; + $repository->store($savingsAccount); + + return true; + } + + /** + * Create a new user instance after a valid registration. + * + * @param array $data + * + * @return \FireflyIII\User + */ + protected function createUser(array $data): User // create object + { + return User::create( + [ + 'email' => $data['email'], + 'password' => bcrypt($data['password']), + ] + ); + } + + /** + * Make a configurator object. + * + * @param ImportJob $importJob + * + * @return JobConfigurationInterface + * + * @throws FireflyException + */ + protected function makeConfigurator(ImportJob $importJob): JobConfigurationInterface // make object + { + $key = sprintf('import.configuration.%s', $importJob->provider); + $className = (string)config($key); + if (null === $className || !class_exists($className)) { + throw new FireflyException(sprintf('Cannot find configurator class for job with provider "%s".', $importJob->provider)); // @codeCoverageIgnore + } + Log::debug(sprintf('Going to create class "%s"', $className)); + /** @var JobConfigurationInterface $configurator */ + $configurator = app($className); + $configurator->setImportJob($importJob); + + return $configurator; + } + + /** + * Store the transactions. + * + * @param ImportJob $importJob + * + * @throws FireflyException + */ + protected function storeTransactions(ImportJob $importJob): void // make object + execute + { + /** @var ImportArrayStorage $storage */ + $storage = app(ImportArrayStorage::class); + $storage->setImportJob($importJob); + try { + $storage->store(); + } catch (FireflyException|Exception $e) { + throw new FireflyException($e->getMessage()); + } + } +} \ No newline at end of file diff --git a/app/Support/Http/Controllers/DateCalculation.php b/app/Support/Http/Controllers/DateCalculation.php index 03c9df546a..fb2c5938fd 100644 --- a/app/Support/Http/Controllers/DateCalculation.php +++ b/app/Support/Http/Controllers/DateCalculation.php @@ -24,7 +24,6 @@ declare(strict_types=1); namespace FireflyIII\Support\Http\Controllers; use Carbon\Carbon; -use Log; /** * Trait DateCalculation @@ -32,6 +31,52 @@ use Log; */ trait DateCalculation { + /** + * Calculate the number of days passed left until end date, as seen from start date. + * If today is between start and end, today will be used instead of end. + * + * If both are in the past OR both are in the future, simply return the number of days in the period with a minimum of 1 + * + * @param Carbon $start + * @param Carbon $end + * + * @return int + */ + public function activeDaysLeft(Carbon $start, Carbon $end): int + { + $difference = $start->diffInDays($end) + 1; + $today = Carbon::now()->startOfDay(); + + if ($start->lte($today) && $end->gte($today)) { + $difference = $today->diffInDays($end); + } + $difference = 0 === $difference ? 1 : $difference; + + return $difference; + } + + /** + * Calculate the number of days passed between two dates. Will take the current moment into consideration. + * + * If both are in the past OR both are in the future, simply return the period between them with a minimum of 1 + * + * @param Carbon $start + * @param Carbon $end + * + * @return int + */ + protected function activeDaysPassed(Carbon $start, Carbon $end): int + { + $difference = $start->diffInDays($end) + 1; + $today = Carbon::now()->startOfDay(); + + if ($start->lte($today) && $end->gte($today)) { + $difference = $start->diffInDays($today) + 1; + } + + return $difference; + } + /** * @param Carbon $start * @param Carbon $end @@ -56,62 +101,6 @@ trait DateCalculation return $step; } - /** - * Returns the number of days between the two given dates. - * - If today is within the two dates, give the number of days between today and the end date. - * - If they are the same, return 1. - * - * @param Carbon $start - * @param Carbon $end - * - * @return int - */ - protected function getDayDifference(Carbon $start, Carbon $end): int - { - $dayDifference = 0; - - // if today is between start and end, use the diff in days between end and today (days left) - // otherwise, use diff between start and end. - $today = new Carbon; - Log::debug(sprintf('Start is %s, end is %s, today is %s', $start->format('Y-m-d'), $end->format('Y-m-d'), $today->format('Y-m-d'))); - if ($today->gte($start) && $today->lte($end)) { - $dayDifference = $end->diffInDays($today); - } - if ($today->lte($start) || $today->gte($end)) { - $dayDifference = $start->diffInDays($end); - } - $dayDifference = 0 === $dayDifference ? 1 : $dayDifference; - - return $dayDifference; - } - - /** - * Returns the number of days that have passed in this period. If it is zero (start of period) - * then return 1. - * - * @param Carbon $start - * @param Carbon $end - * - * @return int - */ - protected function getDaysPassedInPeriod(Carbon $start, Carbon $end): int - { - // if today is between start and end, use the diff in days between end and today (days left) - // otherwise, use diff between start and end. - $today = new Carbon; - $daysPassed = 0; - Log::debug(sprintf('Start is %s, end is %s, today is %s', $start->format('Y-m-d'), $end->format('Y-m-d'), $today->format('Y-m-d'))); - if ($today->gte($start) && $today->lte($end)) { - $daysPassed = $start->diffInDays($today); - } - if ($today->lte($start) || $today->gte($end)) { - $daysPassed = $start->diffInDays($end); - } - $daysPassed = 0 === $daysPassed ? 1 : $daysPassed; - - return $daysPassed; - - } /** * Get a list of the periods that will occur after this date. For example, @@ -127,7 +116,8 @@ trait DateCalculation // select thing for next 12 periods: $loop = []; /** @var Carbon $current */ - $current = clone $date; + $current = app('navigation')->startOfPeriod($date, $range); + $current = app('navigation')->endOfPeriod($current, $range); $current->addDay(); $count = 0; @@ -156,7 +146,7 @@ trait DateCalculation // select thing for last 12 periods: $loop = []; /** @var Carbon $current */ - $current = clone $date; + $current = app('navigation')->startOfPeriod($date, $range); $count = 0; while ($count < 12) { $current->subDay(); diff --git a/app/Support/Http/Controllers/GetConfigurationData.php b/app/Support/Http/Controllers/GetConfigurationData.php new file mode 100644 index 0000000000..2c46da121c --- /dev/null +++ b/app/Support/Http/Controllers/GetConfigurationData.php @@ -0,0 +1,268 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Http\Controllers; + + +use Carbon\Carbon; +use Log; + +/** + * Trait GetConfigurationData + * + */ +trait GetConfigurationData +{ + + /** + * All packages that are installed. + * + * @return array + */ + protected function collectPackages(): array // get configuration + { + $packages = []; + $file = \dirname(__DIR__, 4) . '/vendor/composer/installed.json'; + if (file_exists($file)) { + // file exists! + $content = file_get_contents($file); + $json = json_decode($content, true); + foreach ($json as $package) { + $packages[] + = [ + 'name' => $package['name'], + 'version' => $package['version'], + ]; + } + } + + return $packages; + } + + /** + * Some common combinations. + * + * @param int $value + * + * @return string + */ + protected function errorReporting(int $value): string // get configuration + { + $array = [ + -1 => 'ALL errors', + E_ALL & ~E_NOTICE & ~E_STRICT & ~E_DEPRECATED => 'E_ALL & ~E_NOTICE & ~E_STRICT & ~E_DEPRECATED', + E_ALL => 'E_ALL', + E_ALL & ~E_DEPRECATED & ~E_STRICT => 'E_ALL & ~E_DEPRECATED & ~E_STRICT', + E_ALL & ~E_NOTICE => 'E_ALL & ~E_NOTICE', + E_ALL & ~E_NOTICE & ~E_STRICT => 'E_ALL & ~E_NOTICE & ~E_STRICT', + E_COMPILE_ERROR | E_RECOVERABLE_ERROR | E_ERROR | E_CORE_ERROR => 'E_COMPILE_ERROR|E_RECOVERABLE_ERROR|E_ERROR|E_CORE_ERROR', + ]; + $result = (string)$value; + if (isset($array[$value])) { + $result = $array[$value]; + } + + return $result; + } + + /** + * Get the basic steps from config. + * + * @param string $route + * + * @return array + */ + protected function getBasicSteps(string $route): array // get config values + { + $routeKey = str_replace('.', '_', $route); + $elements = config(sprintf('intro.%s', $routeKey)); + $steps = []; + if (\is_array($elements) && \count($elements) > 0) { + foreach ($elements as $key => $options) { + $currentStep = $options; + + // get the text: + $currentStep['intro'] = (string)trans('intro.' . $route . '_' . $key); + + // save in array: + $steps[] = $currentStep; + } + } + Log::debug(sprintf('Total basic steps for %s is %d', $routeKey, \count($steps))); + + return $steps; + } + + /** + * Get config for date range. + * + * @return array + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + protected function getDateRangeConfig(): array // get configuration + get preferences. + { + $viewRange = app('preferences')->get('viewRange', '1M')->data; + /** @var Carbon $start */ + $start = session('start'); + /** @var Carbon $end */ + $end = session('end'); + /** @var Carbon $first */ + $first = session('first'); + $title = sprintf('%s - %s', $start->formatLocalized($this->monthAndDayFormat), $end->formatLocalized($this->monthAndDayFormat)); + $isCustom = true === session('is_custom_range', false); + $today = new Carbon; + $ranges = [ + // first range is the current range: + $title => [$start, $end], + ]; + Log::debug(sprintf('viewRange is %s', $viewRange)); + Log::debug(sprintf('isCustom is %s', var_export($isCustom, true))); + + // when current range is a custom range, add the current period as the next range. + if ($isCustom) { + Log::debug('Custom is true.'); + $index = app('navigation')->periodShow($start, $viewRange); + $customPeriodStart = app('navigation')->startOfPeriod($start, $viewRange); + $customPeriodEnd = app('navigation')->endOfPeriod($customPeriodStart, $viewRange); + $ranges[$index] = [$customPeriodStart, $customPeriodEnd]; + } + // then add previous range and next range + $previousDate = app('navigation')->subtractPeriod($start, $viewRange); + $index = app('navigation')->periodShow($previousDate, $viewRange); + $previousStart = app('navigation')->startOfPeriod($previousDate, $viewRange); + $previousEnd = app('navigation')->endOfPeriod($previousStart, $viewRange); + $ranges[$index] = [$previousStart, $previousEnd]; + + $nextDate = app('navigation')->addPeriod($start, $viewRange, 0); + $index = app('navigation')->periodShow($nextDate, $viewRange); + $nextStart = app('navigation')->startOfPeriod($nextDate, $viewRange); + $nextEnd = app('navigation')->endOfPeriod($nextStart, $viewRange); + $ranges[$index] = [$nextStart, $nextEnd]; + + // today: + /** @var Carbon $todayStart */ + $todayStart = app('navigation')->startOfPeriod($today, $viewRange); + /** @var Carbon $todayEnd */ + $todayEnd = app('navigation')->endOfPeriod($todayStart, $viewRange); + if ($todayStart->ne($start) || $todayEnd->ne($end)) { + $ranges[ucfirst((string)trans('firefly.today'))] = [$todayStart, $todayEnd]; + } + + // everything + $index = (string)trans('firefly.everything'); + $ranges[$index] = [$first, new Carbon]; + + $return = [ + 'title' => $title, + 'configuration' => [ + 'apply' => (string)trans('firefly.apply'), + 'cancel' => (string)trans('firefly.cancel'), + 'from' => (string)trans('firefly.from'), + 'to' => (string)trans('firefly.to'), + 'customRange' => (string)trans('firefly.customRange'), + 'start' => $start->format('Y-m-d'), + 'end' => $end->format('Y-m-d'), + 'ranges' => $ranges, + ], + ]; + + return $return; + } + + /** + * Get specific info for special routes. + * + * @param string $route + * @param string $specificPage + * + * @return array + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + protected function getSpecificSteps(string $route, string $specificPage): array // get config values + { + $steps = []; + $routeKey = ''; + + // user is on page with specific instructions: + if (\strlen($specificPage) > 0) { + $routeKey = str_replace('.', '_', $route); + $elements = config(sprintf('intro.%s', $routeKey . '_' . $specificPage)); + if (\is_array($elements) && \count($elements) > 0) { + foreach ($elements as $key => $options) { + $currentStep = $options; + + // get the text: + $currentStep['intro'] = (string)trans('intro.' . $route . '_' . $specificPage . '_' . $key); + + // save in array: + $steps[] = $currentStep; + } + } + } + Log::debug(sprintf('Total specific steps for route "%s" and page "%s" (routeKey is "%s") is %d', $route, $specificPage, $routeKey, \count($steps))); + + return $steps; + } + + /** + * Check if forbidden functions are set. + * + * @return bool + */ + protected function hasForbiddenFunctions(): bool // validate system config + { + $list = ['proc_close']; + $forbidden = explode(',', ini_get('disable_functions')); + $trimmed = array_map( + function (string $value) { + return trim($value); + }, $forbidden + ); + foreach ($list as $entry) { + if (\in_array($entry, $trimmed, true)) { + Log::error('Method "%s" is FORBIDDEN, so the console command cannot be executed.'); + + return true; + } + } + + return false; + } + + /** + * + */ + protected function verifyRecurringCronJob(): void + { + $config = app('fireflyconfig')->get('last_rt_job', 0); + $lastTime = (int)$config->data; + $now = time(); + if (0 === $lastTime) { + request()->session()->flash('info', trans('firefly.recurring_never_cron')); + return; + } + if($now - $lastTime > 129600) { + request()->session()->flash('warning', trans('firefly.recurring_cron_long_ago')); + } + } +} \ No newline at end of file diff --git a/app/Support/Http/Controllers/ModelInformation.php b/app/Support/Http/Controllers/ModelInformation.php new file mode 100644 index 0000000000..688a1b12ca --- /dev/null +++ b/app/Support/Http/Controllers/ModelInformation.php @@ -0,0 +1,65 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Http\Controllers; + +use FireflyIII\Models\TransactionJournal; +use FireflyIII\Models\TransactionType; +use FireflyIII\Repositories\Journal\JournalRepositoryInterface; + +/** + * Trait ModelInformation + * + */ +trait ModelInformation +{ + /** + * Is transaction opening balance? + * + * @param TransactionJournal $journal + * + * @return bool + */ + protected function isOpeningBalance(TransactionJournal $journal): bool + { + return TransactionType::OPENING_BALANCE === $journal->transactionType->type; + } + + /** + * Checks if journal is split. + * + * @param TransactionJournal $journal + * + * @return bool + */ + protected function isSplitJournal(TransactionJournal $journal): bool // validate objects + { + /** @var JournalRepositoryInterface $repository */ + $repository = app(JournalRepositoryInterface::class); + $repository->setUser($journal->user); + $count = $repository->countTransactions($journal); + + return $count > 2; + } + +} \ No newline at end of file diff --git a/app/Support/Http/Controllers/PeriodOverview.php b/app/Support/Http/Controllers/PeriodOverview.php new file mode 100644 index 0000000000..c0b13e0b7b --- /dev/null +++ b/app/Support/Http/Controllers/PeriodOverview.php @@ -0,0 +1,408 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Http\Controllers; + +use Carbon\Carbon; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; +use FireflyIII\Helpers\Filter\InternalTransferFilter; +use FireflyIII\Models\Account; +use FireflyIII\Models\AccountType; +use FireflyIII\Models\Category; +use FireflyIII\Models\Tag; +use FireflyIII\Models\Transaction; +use FireflyIII\Models\TransactionType; +use FireflyIII\Repositories\Account\AccountRepositoryInterface; +use FireflyIII\Repositories\Category\CategoryRepositoryInterface; +use FireflyIII\Repositories\Journal\JournalRepositoryInterface; +use FireflyIII\Repositories\Tag\TagRepositoryInterface; +use FireflyIII\Support\CacheProperties; +use Illuminate\Support\Collection; + +/** + * Trait PeriodOverview. + * + * General working of an overview thing: + * - Take a start date (session or view, depends on argument). + * - Take end date. This is usually the very first object related to the period overview (first transaction in tag, etc). + * - Smart list of period, becoming larger in time: + * -- This year: months + * -- Last year: quarters + * -- Before that: years + * -- Before that: decennia + * + * - Group expenses, income, etc. under this period. + * - Returns collection of arrays. Possible fields are: + * - start (Carbon), end (Carbon), title (string), spent (string), earned (string), transferred (string) + * + * + */ +trait PeriodOverview +{ + /** + * This method returns "period entries", so nov-2015, dec-2015, etc etc (this depends on the users session range) + * and for each period, the amount of money spent and earned. This is a complex operation which is cached for + * performance reasons. + * + * TODO refactor me. + * + * @param Account $account the account involved + * @param Carbon|null $date + * + * @return Collection + */ + protected function getAccountPeriodOverview(Account $account, ?Carbon $date): Collection // period overview + { + /** @var AccountRepositoryInterface $repository */ + $repository = app(AccountRepositoryInterface::class); + $range = app('preferences')->get('viewRange', '1M')->data; + $start = $repository->oldestJournalDate($account) ?? Carbon::now()->startOfMonth(); + $end = $date ?? new Carbon; + if ($end < $start) { + [$start, $end] = [$end, $start]; // @codeCoverageIgnore + } + + // properties for cache + $cache = new CacheProperties; + $cache->addProperty($start); + $cache->addProperty($end); + $cache->addProperty('account-show-period-entries'); + $cache->addProperty($account->id); + if ($cache->has()) { + return $cache->get(); // @codeCoverageIgnore + } + /** @var array $dates */ + $dates = app('navigation')->blockPeriods($start, $end, $range); + $entries = new Collection; + // loop dates + foreach ($dates as $currentDate) { + + // try a collector for income: + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); + $collector->setAccounts(new Collection([$account]))->setRange($currentDate['start'], $currentDate['end'])->setTypes([TransactionType::DEPOSIT]) + ->withOpposingAccount(); + $earned = (string)$collector->getTransactions()->sum('transaction_amount'); + + // try a collector for expenses: + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); + $collector->setAccounts(new Collection([$account]))->setRange($currentDate['start'], $currentDate['end'])->setTypes([TransactionType::WITHDRAWAL]) + ->withOpposingAccount(); + $spent = (string)$collector->getTransactions()->sum('transaction_amount'); + + $dateName = app('navigation')->periodShow($currentDate['start'], $currentDate['period']); + /** @noinspection PhpUndefinedMethodInspection */ + $entries->push( + [ + 'name' => $dateName, + 'spent' => $spent, + 'earned' => $earned, + 'start' => $currentDate['start']->format('Y-m-d'), + 'end' => $currentDate['end']->format('Y-m-d'), + ] + ); + } + + $cache->store($entries); + + return $entries; + } + + /** + * Gets period overview used for budgets. + * + * TODO refactor me. + * + * @return Collection + */ + protected function getBudgetPeriodOverview(): Collection + { + /** @var JournalRepositoryInterface $repository */ + $repository = app(JournalRepositoryInterface::class); + $first = $repository->firstNull(); + $start = null === $first ? new Carbon : $first->date; + $range = app('preferences')->get('viewRange', '1M')->data; + $start = app('navigation')->startOfPeriod($start, $range); + $end = app('navigation')->endOfX(new Carbon, $range, null); + $entries = new Collection; + $cache = new CacheProperties; + $cache->addProperty($start); + $cache->addProperty($end); + $cache->addProperty('no-budget-period-entries'); + + if ($cache->has()) { + return $cache->get(); // @codeCoverageIgnore + } + $dates = app('navigation')->blockPeriods($start, $end, $range); + foreach ($dates as $date) { + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); + $collector->setAllAssetAccounts()->setRange($date['start'], $date['end'])->withoutBudget()->withOpposingAccount()->setTypes( + [TransactionType::WITHDRAWAL] + ); + $set = $collector->getTransactions(); + $sum = (string)($set->sum('transaction_amount') ?? '0'); + $journals = $set->count(); + /** @noinspection PhpUndefinedMethodInspection */ + $dateStr = $date['end']->format('Y-m-d'); + $dateName = app('navigation')->periodShow($date['end'], $date['period']); + $entries->push( + ['string' => $dateStr, 'name' => $dateName, 'count' => $journals, 'sum' => $sum, 'date' => clone $date['end'], + 'start' => $date['start'], + 'end' => $date['end'], + + ] + ); + } + $cache->store($entries); + + return $entries; + } + + /** + * Get a period overview for category. + * + * TODO refactor me. + * + * @param Category $category + * @param Carbon $date + * + * @return Collection + */ + protected function getCategoryPeriodOverview(Category $category, Carbon $date): Collection // periodOverview method + { + /** @var JournalRepositoryInterface $journalRepository */ + $journalRepository = app(JournalRepositoryInterface::class); + /** @var AccountRepositoryInterface $accountRepository */ + $accountRepository = app(AccountRepositoryInterface::class); + /** @var CategoryRepositoryInterface $categoryRepository */ + $categoryRepository = app(CategoryRepositoryInterface::class); + $range = app('preferences')->get('viewRange', '1M')->data; + $first = $journalRepository->firstNull(); + $start = null === $first ? new Carbon : $first->date; + $end = $date ?? new Carbon; + $accounts = $accountRepository->getAccountsByType([AccountType::DEFAULT, AccountType::ASSET]); + + // properties for entries with their amounts. + $cache = new CacheProperties(); + $cache->addProperty($start); + $cache->addProperty($end); + $cache->addProperty($range); + $cache->addProperty('categories.entries'); + $cache->addProperty($category->id); + + if ($cache->has()) { + return $cache->get(); // @codeCoverageIgnore + } + /** @var array $dates */ + $dates = app('navigation')->blockPeriods($start, $end, $range); + $entries = new Collection; + + foreach ($dates as $currentDate) { + $spent = $categoryRepository->spentInPeriod(new Collection([$category]), $accounts, $currentDate['start'], $currentDate['end']); + $earned = $categoryRepository->earnedInPeriod(new Collection([$category]), $accounts, $currentDate['start'], $currentDate['end']); + /** @noinspection PhpUndefinedMethodInspection */ + $dateStr = $currentDate['end']->format('Y-m-d'); + $dateName = app('navigation')->periodShow($currentDate['end'], $currentDate['period']); + + // amount transferred + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); + $collector->setAllAssetAccounts()->setRange($currentDate['start'], $currentDate['end'])->setCategory($category) + ->withOpposingAccount()->setTypes([TransactionType::TRANSFER]); + $collector->removeFilter(InternalTransferFilter::class); + $transferred = app('steam')->positive((string)$collector->getTransactions()->sum('transaction_amount')); + + $entries->push( + [ + 'string' => $dateStr, + 'name' => $dateName, + 'spent' => $spent, + 'earned' => $earned, + 'sum' => bcadd($earned, $spent), + 'transferred' => $transferred, + 'start' => clone $currentDate['start'], + 'end' => clone $currentDate['end'], + ] + ); + } + $cache->store($entries); + + return $entries; + } + + /** + * Get overview of periods for tag. + * + * TODO refactor this. + * + * @param Tag $tag + * + * @return Collection + */ + protected function getTagPeriodOverview(Tag $tag): Collection // period overview for tags. + { + /** @var TagRepositoryInterface $repository */ + $repository = app(TagRepositoryInterface::class); + // get first and last tag date from tag: + $range = app('preferences')->get('viewRange', '1M')->data; + /** @var Carbon $end */ + $end = app('navigation')->endOfX($repository->lastUseDate($tag) ?? new Carbon, $range, null); + $start = $repository->firstUseDate($tag) ?? new Carbon; + + + // properties for entries with their amounts. + $cache = new CacheProperties; + $cache->addProperty($start); + $cache->addProperty($end); + $cache->addProperty('tag.entries'); + $cache->addProperty($tag->id); + + if ($cache->has()) { + return $cache->get(); // @codeCoverageIgnore + } + + $collection = new Collection; + $currentEnd = clone $end; + // while end larger or equal to start + while ($currentEnd >= $start) { + $currentStart = app('navigation')->startOfPeriod($currentEnd, $range); + + // get expenses and what-not in this period and this tag. + $arr = [ + 'string' => $end->format('Y-m-d'), + 'name' => app('navigation')->periodShow($currentEnd, $range), + 'start' => clone $currentStart, + 'end' => clone $currentEnd, + 'date' => clone $end, + 'spent' => $repository->spentInPeriod($tag, $currentStart, $currentEnd), + 'earned' => $repository->earnedInPeriod($tag, $currentStart, $currentEnd), + ]; + $collection->push($arr); + + /** @var Carbon $currentEnd */ + $currentEnd = clone $currentStart; + $currentEnd->subDay(); + } + $cache->store($collection); + + return $collection; + } + + /** + * Get period overview for index. + * + * TODO refactor me. + * + * @param string $what + * @param Carbon $date + * + * @return Collection + */ + protected function getTransactionPeriodOverview(string $what, Carbon $date): Collection // period overview for transactions. + { + /** @var JournalRepositoryInterface $repository */ + $repository = app(JournalRepositoryInterface::class); + $range = app('preferences')->get('viewRange', '1M')->data; + $first = $repository->firstNull(); + $start = Carbon::now()->subYear(); + $types = config('firefly.transactionTypesByWhat.' . $what); + $entries = new Collection; + if (null !== $first) { + $start = $first->date; + } + if ($date < $start) { + [$start, $date] = [$date, $start]; // @codeCoverageIgnore + } + + /** @var array $dates */ + $dates = app('navigation')->blockPeriods($start, $date, $range); + + foreach ($dates as $currentDate) { + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); + $collector->setAllAssetAccounts()->setRange($currentDate['start'], $currentDate['end'])->withOpposingAccount()->setTypes($types); + $collector->removeFilter(InternalTransferFilter::class); + $transactions = $collector->getTransactions(); + + if ($transactions->count() > 0) { + $sums = $this->sumPerCurrency($transactions); + $dateName = app('navigation')->periodShow($currentDate['start'], $currentDate['period']); + $sum = $transactions->sum('transaction_amount'); + /** @noinspection PhpUndefinedMethodInspection */ + $entries->push( + [ + 'name' => $dateName, + 'sums' => $sums, + 'sum' => $sum, + 'start' => $currentDate['start']->format('Y-m-d'), + 'end' => $currentDate['end']->format('Y-m-d'), + ] + ); + } + } + + return $entries; + } + + /** + * Collect the sum per currency. + * + * @param Collection $collection + * + * @return array + */ + protected function sumPerCurrency(Collection $collection): array // helper for transactions (math, calculations) + { + $return = []; + /** @var Transaction $transaction */ + foreach ($collection as $transaction) { + $currencyId = (int)$transaction->transaction_currency_id; + + // save currency information: + if (!isset($return[$currencyId])) { + $currencySymbol = $transaction->transaction_currency_symbol; + $decimalPlaces = $transaction->transaction_currency_dp; + $currencyCode = $transaction->transaction_currency_code; + $return[$currencyId] = [ + 'currency' => [ + 'id' => $currencyId, + 'code' => $currencyCode, + 'symbol' => $currencySymbol, + 'dp' => $decimalPlaces, + ], + 'sum' => '0', + 'count' => 0, + ]; + } + // save amount: + $return[$currencyId]['sum'] = bcadd($return[$currencyId]['sum'], $transaction->transaction_amount); + ++$return[$currencyId]['count']; + } + asort($return); + + return $return; + } + +} \ No newline at end of file diff --git a/app/Support/Http/Controllers/RenderPartialViews.php b/app/Support/Http/Controllers/RenderPartialViews.php new file mode 100644 index 0000000000..85a2ca3568 --- /dev/null +++ b/app/Support/Http/Controllers/RenderPartialViews.php @@ -0,0 +1,327 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Http\Controllers; + + +use FireflyIII\Helpers\Collection\BalanceLine; +use FireflyIII\Helpers\Report\PopupReportInterface; +use FireflyIII\Models\AccountType; +use FireflyIII\Models\Tag; +use FireflyIII\Repositories\Account\AccountRepositoryInterface; +use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; +use FireflyIII\Repositories\Category\CategoryRepositoryInterface; +use FireflyIII\Repositories\Tag\TagRepositoryInterface; +use Illuminate\Support\Collection; +use Log; +use Throwable; + +/** + * Trait RenderPartialViews + * + */ +trait RenderPartialViews +{ + /** + * Get options for account report. + * + * @return string + */ + protected function accountReportOptions(): string // render a view + { + /** @var AccountRepositoryInterface $repository */ + $repository = app(AccountRepositoryInterface::class); + $expense = $repository->getActiveAccountsByType([AccountType::EXPENSE]); + $revenue = $repository->getActiveAccountsByType([AccountType::REVENUE]); + $set = new Collection; + $names = $revenue->pluck('name')->toArray(); + foreach ($expense as $exp) { + if (\in_array($exp->name, $names, true)) { + $set->push($exp); + } + } + try { + $result = view('reports.options.account', compact('set'))->render(); + } catch (Throwable $e) { + Log::error(sprintf('Cannot render reports.options.tag: %s', $e->getMessage())); + $result = 'Could not render view.'; + } + + return $result; + } + + /** + * View for balance row. + * + * @param array $attributes + * + * @return string + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + protected function balanceAmount(array $attributes): string // generate view for report. + { + $role = (int)$attributes['role']; + /** @var BudgetRepositoryInterface $budgetRepository */ + $budgetRepository = app(BudgetRepositoryInterface::class); + + /** @var AccountRepositoryInterface $accountRepository */ + $accountRepository = app(AccountRepositoryInterface::class); + + /** @var PopupReportInterface $popupHelper */ + $popupHelper = app(PopupReportInterface::class); + + $budget = $budgetRepository->findNull((int)$attributes['budgetId']); + $account = $accountRepository->findNull((int)$attributes['accountId']); + + + switch (true) { + case BalanceLine::ROLE_DEFAULTROLE === $role && null !== $budget && null !== $account: + // normal row with a budget: + $journals = $popupHelper->balanceForBudget($budget, $account, $attributes); + break; + case BalanceLine::ROLE_DEFAULTROLE === $role && null === $budget && null !== $account: + // normal row without a budget: + $journals = $popupHelper->balanceForNoBudget($account, $attributes); + $budget->name = (string)trans('firefly.no_budget'); + break; + case BalanceLine::ROLE_TAGROLE === $role: + // row with tag info. + return 'Firefly cannot handle this type of info-button (BalanceLine::TagRole)'; + } + try { + $view = view('popup.report.balance-amount', compact('journals', 'budget', 'account'))->render(); + } catch (Throwable $e) { + Log::error(sprintf('Could not render: %s', $e->getMessage())); + $view = 'Firefly III could not render the view. Please see the log files.'; + } + + return $view; + } + + /** + * Get options for budget report. + * + * @return string + */ + protected function budgetReportOptions(): string // render a view + { + /** @var BudgetRepositoryInterface $repository */ + $repository = app(BudgetRepositoryInterface::class); + $budgets = $repository->getBudgets(); + try { + $result = view('reports.options.budget', compact('budgets'))->render(); + } catch (Throwable $e) { + Log::error(sprintf('Cannot render reports.options.tag: %s', $e->getMessage())); + $result = 'Could not render view.'; + } + + return $result; + } + + /** + * View for spent in a single budget. + * + * @param array $attributes + * + * @return string + */ + protected function budgetSpentAmount(array $attributes): string // generate view for report. + { + /** @var BudgetRepositoryInterface $budgetRepository */ + $budgetRepository = app(BudgetRepositoryInterface::class); + + /** @var PopupReportInterface $popupHelper */ + $popupHelper = app(PopupReportInterface::class); + + $budget = $budgetRepository->findNull((int)$attributes['budgetId']); + if (null === $budget) { + return 'This is an unknown budget. Apologies.'; + } + $journals = $popupHelper->byBudget($budget, $attributes); + try { + $view = view('popup.report.budget-spent-amount', compact('journals', 'budget'))->render(); + } catch (Throwable $e) { + Log::error(sprintf('Could not render: %s', $e->getMessage())); + $view = 'Firefly III could not render the view. Please see the log files.'; + } + + return $view; + } + + /** + * View for transactions in a category. + * + * @param array $attributes + * + * @return string + */ + protected function categoryEntry(array $attributes): string // generate view for report. + { + /** @var PopupReportInterface $popupHelper */ + $popupHelper = app(PopupReportInterface::class); + + /** @var CategoryRepositoryInterface $categoryRepository */ + $categoryRepository = app(CategoryRepositoryInterface::class); + $category = $categoryRepository->findNull((int)$attributes['categoryId']); + + if (null === $category) { + return 'This is an unknown category. Apologies.'; + } + + $journals = $popupHelper->byCategory($category, $attributes); + try { + $view = view('popup.report.category-entry', compact('journals', 'category'))->render(); + } catch (Throwable $e) { + Log::error(sprintf('Could not render: %s', $e->getMessage())); + $view = 'Firefly III could not render the view. Please see the log files.'; + } + + return $view; + } + + /** + * Get options for category report. + * + * @return string + */ + protected function categoryReportOptions(): string // render a view + { + /** @var CategoryRepositoryInterface $repository */ + $repository = app(CategoryRepositoryInterface::class); + $categories = $repository->getCategories(); + try { + $result = view('reports.options.category', compact('categories'))->render(); + } catch (Throwable $e) { + Log::error(sprintf('Cannot render reports.options.category: %s', $e->getMessage())); + $result = 'Could not render view.'; + } + + return $result; + } + + /** + * Returns all the expenses that went to the given expense account. + * + * @param array $attributes + * + * @return string + */ + protected function expenseEntry(array $attributes): string // generate view for report. + { + /** @var AccountRepositoryInterface $accountRepository */ + $accountRepository = app(AccountRepositoryInterface::class); + + /** @var PopupReportInterface $popupHelper */ + $popupHelper = app(PopupReportInterface::class); + + $account = $accountRepository->findNull((int)$attributes['accountId']); + + if (null === $account) { + return 'This is an unknown account. Apologies.'; + } + + $journals = $popupHelper->byExpenses($account, $attributes); + try { + $view = view('popup.report.expense-entry', compact('journals', 'account'))->render(); + } catch (Throwable $e) { + Log::error(sprintf('Could not render: %s', $e->getMessage())); + $view = 'Firefly III could not render the view. Please see the log files.'; + } + + return $view; + } + + /** + * Returns all the incomes that went to the given asset account. + * + * @param array $attributes + * + * @return string + */ + protected function incomeEntry(array $attributes): string // generate view for report. + { + /** @var AccountRepositoryInterface $accountRepository */ + $accountRepository = app(AccountRepositoryInterface::class); + + /** @var PopupReportInterface $popupHelper */ + $popupHelper = app(PopupReportInterface::class); + + $account = $accountRepository->findNull((int)$attributes['accountId']); + + if (null === $account) { + return 'This is an unknown category. Apologies.'; + } + + $journals = $popupHelper->byIncome($account, $attributes); + try { + $view = view('popup.report.income-entry', compact('journals', 'account'))->render(); + } catch (Throwable $e) { + Log::error(sprintf('Could not render: %s', $e->getMessage())); + $view = 'Firefly III could not render the view. Please see the log files.'; + } + + return $view; + } + + /** + * Get options for default report. + * + * @return string + */ + protected function noReportOptions(): string // render a view + { + try { + $result = view('reports.options.no-options')->render(); + } catch (Throwable $e) { + Log::error(sprintf('Cannot render reports.options.no-options: %s', $e->getMessage())); + $result = 'Could not render view.'; + } + + return $result; + } + + /** + * Get options for tag report. + * + * @return string + */ + protected function tagReportOptions(): string // render a view + { + /** @var TagRepositoryInterface $repository */ + $repository = app(TagRepositoryInterface::class); + $tags = $repository->get()->sortBy( + function (Tag $tag) { + return $tag->tag; + } + ); + try { + $result = view('reports.options.tag', compact('tags'))->render(); + } catch (Throwable $e) { + Log::error(sprintf('Cannot render reports.options.tag: %s', $e->getMessage())); + $result = 'Could not render view.'; + } + + return $result; + } +} \ No newline at end of file diff --git a/app/Support/Http/Controllers/RequestInformation.php b/app/Support/Http/Controllers/RequestInformation.php new file mode 100644 index 0000000000..01c561f731 --- /dev/null +++ b/app/Support/Http/Controllers/RequestInformation.php @@ -0,0 +1,402 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Http\Controllers; + +use Carbon\Carbon; +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Exceptions\ValidationException; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; +use FireflyIII\Helpers\Help\HelpInterface; +use FireflyIII\Http\Requests\SplitJournalFormRequest; +use FireflyIII\Http\Requests\TestRuleFormRequest; +use FireflyIII\Models\Transaction; +use FireflyIII\Models\TransactionJournal; +use FireflyIII\Models\TransactionType; +use FireflyIII\Support\Binder\AccountList; +use FireflyIII\Transformers\TransactionTransformer; +use FireflyIII\User; +use Hash; +use Illuminate\Contracts\Validation\Validator as ValidatorContract; +use Illuminate\Http\Request; +use Illuminate\Routing\Route; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Validator; +use InvalidArgumentException; +use Log; +use Route as RouteFacade; +use Symfony\Component\HttpFoundation\ParameterBag; + +/** + * Trait RequestInformation + * + */ +trait RequestInformation +{ + + /** + * Create data-array from a journal. + * + * @param SplitJournalFormRequest|Request $request + * @param TransactionJournal $journal + * + * @return array + * @throws FireflyException + */ + protected function arrayFromJournal(Request $request, TransactionJournal $journal): array // convert user input. + { + $sourceAccounts = $this->repository->getJournalSourceAccounts($journal); + $destinationAccounts = $this->repository->getJournalDestinationAccounts($journal); + $array = [ + 'journal_description' => $request->old('journal_description', $journal->description), + 'journal_amount' => '0', + 'journal_foreign_amount' => '0', + 'sourceAccounts' => $sourceAccounts, + 'journal_source_id' => $request->old('journal_source_id', $sourceAccounts->first()->id), + 'journal_source_name' => $request->old('journal_source_name', $sourceAccounts->first()->name), + 'journal_destination_id' => $request->old('journal_destination_id', $destinationAccounts->first()->id), + 'destinationAccounts' => $destinationAccounts, + 'what' => strtolower($this->repository->getTransactionType($journal)), + 'date' => $request->old('date', $this->repository->getJournalDate($journal, null)), + 'tags' => implode(',', $journal->tags->pluck('tag')->toArray()), + + // all custom fields: + 'interest_date' => $request->old('interest_date', $this->repository->getMetaField($journal, 'interest_date')), + 'book_date' => $request->old('book_date', $this->repository->getMetaField($journal, 'book_date')), + 'process_date' => $request->old('process_date', $this->repository->getMetaField($journal, 'process_date')), + 'due_date' => $request->old('due_date', $this->repository->getMetaField($journal, 'due_date')), + 'payment_date' => $request->old('payment_date', $this->repository->getMetaField($journal, 'payment_date')), + 'invoice_date' => $request->old('invoice_date', $this->repository->getMetaField($journal, 'invoice_date')), + 'internal_reference' => $request->old('internal_reference', $this->repository->getMetaField($journal, 'internal_reference')), + 'notes' => $request->old('notes', $this->repository->getNoteText($journal)), + + // transactions. + 'transactions' => $this->getTransactionDataFromJournal($journal), + ]; + // update transactions array with old request data. + $array['transactions'] = $this->updateWithPrevious($array['transactions'], $request->old()); + + // update journal amount and foreign amount: + $array['journal_amount'] = array_sum(array_column($array['transactions'], 'amount')); + $array['journal_foreign_amount'] = array_sum(array_column($array['transactions'], 'foreign_amount')); + + return $array; + } + + /** + * Get the domain of FF system. + * + * @return string + */ + protected function getDomain(): string // get request info + { + $url = url()->to('/'); + $parts = parse_url($url); + + return $parts['host']; + } + + /** + * Gets the help text. + * + * @param string $route + * @param string $language + * + * @return string + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + protected function getHelpText(string $route, string $language): string // get from internet. + { + $help = app(HelpInterface::class); + // get language and default variables. + $content = '

' . trans('firefly.route_has_no_help') . '

'; + + // if no such route, log error and return default text. + if (!$help->hasRoute($route)) { + Log::error('No such route: ' . $route); + + return $content; + } + + // help content may be cached: + if ($help->inCache($route, $language)) { + $content = $help->getFromCache($route, $language); + Log::debug(sprintf('Help text %s was in cache.', $language)); + + return $content; + } + + // get help content from Github: + $content = $help->getFromGitHub($route, $language); + $originalLanguage = $language; + // content will have 0 length when Github failed. Try en_US when it does: + if ('' === $content) { + $language = 'en_US'; + + // also check cache first: + if ($help->inCache($route, $language)) { + Log::debug(sprintf('Help text %s was in cache.', $language)); + $content = $help->getFromCache($route, $language); + + return $content; + } + $baseHref = route('index'); + $helpString = sprintf( + '

%s

', $baseHref, $originalLanguage, (string)trans('firefly.help_translating') + ); + $content = $helpString . $help->getFromGitHub($route, $language); + } + + // help still empty? + if ('' !== $content) { + $help->putInCache($route, $language, $content); + + return $content; + } + + return '

' . trans('firefly.route_has_no_help') . '

'; + } + + /** + * Get user's language. + * + * @return string + */ + protected function getLanguage(): string // get preference + { + /** @var string $language */ + $language = app('preferences')->get('language', config('firefly.default_language', 'en_US'))->data; + + return $language; + } + + /** + * @return string + */ + protected function getPageName(): string // get request info + { + return str_replace('.', '_', RouteFacade::currentRouteName()); + } + + /** + * Get the specific name of a page for intro. + * + * @return string + */ + protected function getSpecificPageName(): string // get request info + { + return null === RouteFacade::current()->parameter('what') ? '' : '_' . RouteFacade::current()->parameter('what'); + } + + /** + * Get transaction overview from journal. + * + * @param TransactionJournal $journal + * + * @return array + * @throws FireflyException + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + protected function getTransactionDataFromJournal(TransactionJournal $journal): array // convert object + { + // use collector to collect transactions. + $collector = app(TransactionCollectorInterface::class); + $collector->setUser(auth()->user()); + $collector->withOpposingAccount()->withCategoryInformation()->withBudgetInformation(); + // filter on specific journals. + $collector->setJournals(new Collection([$journal])); + $set = $collector->getTransactions(); + $transactions = []; + $transformer = new TransactionTransformer(new ParameterBag); + /** @var Transaction $transaction */ + foreach ($set as $transaction) { + $res = []; + if ((float)$transaction->transaction_amount > 0 && $journal->transactionType->type === TransactionType::DEPOSIT) { + $res = $transformer->transform($transaction); + } + if ((float)$transaction->transaction_amount < 0 && $journal->transactionType->type !== TransactionType::DEPOSIT) { + $res = $transformer->transform($transaction); + } + + if (\count($res) > 0) { + $res['amount'] = app('steam')->positive((string)$res['amount']); + $res['foreign_amount'] = app('steam')->positive((string)$res['foreign_amount']); + $transactions[] = $res; + } + } + + return $transactions; + } + + /** + * Get a list of triggers. + * + * @param TestRuleFormRequest $request + * + * @return array + */ + protected function getValidTriggerList(TestRuleFormRequest $request): array // process input + { + $triggers = []; + $data = $request->get('rule_triggers'); + if (\is_array($data)) { + foreach ($data as $index => $triggerInfo) { + $triggers[] = [ + 'type' => $triggerInfo['name'] ?? '', + 'value' => $triggerInfo['value'] ?? '', + 'stop_processing' => 1 === (int)($triggerInfo['stop_processing'] ?? '0'), + ]; + } + } + + return $triggers; + } + + /** + * Returns if user has seen demo. + * + * @return bool + */ + protected function hasSeenDemo(): bool // get request info + get preference + { + $page = $this->getPageName(); + $specificPage = $this->getSpecificPageName(); + + // indicator if user has seen the help for this page ( + special page): + $key = 'shown_demo_' . $page . $specificPage; + // is there an intro for this route? + $intro = config('intro.' . $page) ?? []; + $specialIntro = config('intro.' . $page . $specificPage) ?? []; + // some routes have a "what" parameter, which indicates a special page: + + $shownDemo = true; + // both must be array and either must be > 0 + if (\count($intro) > 0 || \count($specialIntro) > 0) { + $shownDemo = app('preferences')->get($key, false)->data; + Log::debug(sprintf('Check if user has already seen intro with key "%s". Result is %d', $key, $shownDemo)); + } + + return $shownDemo; + } + + /** + * Check if date is outside session range. + * + * @param Carbon $date + * + * @return bool + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + protected function notInSessionRange(Carbon $date): bool // Validate a preference + { + /** @var Carbon $start */ + $start = session('start', Carbon::now()->startOfMonth()); + /** @var Carbon $end */ + $end = session('end', Carbon::now()->endOfMonth()); + $result = false; + if ($start->greaterThanOrEqualTo($date) && $end->greaterThanOrEqualTo($date)) { + $result = true; + } + // start and end in the past? use $end + if ($start->lessThanOrEqualTo($date) && $end->lessThanOrEqualTo($date)) { + $result = true; + } + + return $result; + } + + /** + * Parses attributes from URI. + * + * @param array $attributes + * + * @return array + */ + protected function parseAttributes(array $attributes): array // parse input + return result + { + $attributes['location'] = $attributes['location'] ?? ''; + $attributes['accounts'] = AccountList::routeBinder($attributes['accounts'] ?? '', new Route('get', '', [])); + try { + $attributes['startDate'] = Carbon::createFromFormat('Ymd', $attributes['startDate']); + } catch (InvalidArgumentException $e) { + Log::debug(sprintf('Not important error message: %s', $e->getMessage())); + $date = Carbon::now()->startOfMonth(); + $attributes['startDate'] = $date; + } + + try { + $attributes['endDate'] = Carbon::createFromFormat('Ymd', $attributes['endDate']); + } catch (InvalidArgumentException $e) { + Log::debug(sprintf('Not important error message: %s', $e->getMessage())); + $date = Carbon::now()->startOfMonth(); + $attributes['endDate'] = $date; + } + + return $attributes; + } + + /** + * Validate users new password. + * + * @param User $user + * @param string $current + * @param string $new + * + * @return bool + * + * @throws ValidationException + */ + protected function validatePassword(User $user, string $current, string $new): bool //get request info + { + if (!Hash::check($current, $user->password)) { + throw new ValidationException((string)trans('firefly.invalid_current_password')); + } + + if ($current === $new) { + throw new ValidationException((string)trans('firefly.should_change')); + } + + return true; + } + + /** + * Get a validator for an incoming registration request. + * + * @param array $data + * + * @return ValidatorContract + */ + protected function validator(array $data): ValidatorContract + { + return Validator::make( + $data, + [ + 'email' => 'required|string|email|max:255|unique:users', + 'password' => 'required|string|secure_password|confirmed', + ] + ); + } + +} \ No newline at end of file diff --git a/app/Support/Http/Controllers/RuleManagement.php b/app/Support/Http/Controllers/RuleManagement.php index 7033ae5e25..ee55a39d7d 100644 --- a/app/Support/Http/Controllers/RuleManagement.php +++ b/app/Support/Http/Controllers/RuleManagement.php @@ -32,7 +32,6 @@ use Throwable; /** * Trait RuleManagement * - * @package FireflyIII\Support\Http\Controllers */ trait RuleManagement { @@ -47,36 +46,36 @@ trait RuleManagement if (0 === $ruleRepository->count()) { $data = [ 'rule_group_id' => $ruleRepository->getFirstRuleGroup()->id, - 'stop-processing' => 0, + 'stop_processing' => 0, 'title' => (string)trans('firefly.default_rule_name'), 'description' => (string)trans('firefly.default_rule_description'), 'trigger' => 'store-journal', 'strict' => true, - 'rule-triggers' => [ + 'rule_triggers' => [ [ 'name' => 'description_is', 'value' => (string)trans('firefly.default_rule_trigger_description'), - 'stop-processing' => false, + 'stop_processing' => false, ], [ 'name' => 'from_account_is', 'value' => (string)trans('firefly.default_rule_trigger_from_account'), - 'stop-processing' => false, + 'stop_processing' => false, ], ], - 'rule-actions' => [ + 'rule_actions' => [ [ 'name' => 'prepend_description', 'value' => (string)trans('firefly.default_rule_action_prepend'), - 'stop-processing' => false, + 'stop_processing' => false, ], [ 'name' => 'set_category', 'value' => (string)trans('firefly.default_rule_action_set_category'), - 'stop-processing' => false, + 'stop_processing' => false, ], ], ]; @@ -93,33 +92,30 @@ trait RuleManagement */ protected function getPreviousActions(Request $request): array { - $newIndex = 0; - $actions = []; - /** @var array $oldActions */ - $oldActions = \is_array($request->old('rule-action')) ? $request->old('rule-action') : []; - foreach ($oldActions as $index => $entry) { - $count = ($newIndex + 1); - $checked = isset($request->old('rule-action-stop')[$index]) ? true : false; - try { - $actions[] = view( - 'rules.partials.action', - [ - 'oldAction' => $entry, - 'oldValue' => $request->old('rule-action-value')[$index], - 'oldChecked' => $checked, - 'count' => $count, - ] - )->render(); - // @codeCoverageIgnoreStart - } catch (Throwable $e) { - Log::debug(sprintf('Throwable was thrown in getPreviousActions(): %s', $e->getMessage())); - Log::error($e->getTraceAsString()); + $index = 0; + $triggers = []; + $oldInput = $request->old('rule_actions'); + if (\is_array($oldInput)) { + foreach ($oldInput as $oldAction) { + try { + $triggers[] = view( + 'rules.partials.action', + [ + 'oldAction' => $oldAction['name'], + 'oldValue' => $oldAction['value'], + 'oldChecked' => 1 === (int)($oldAction['stop_processing'] ?? '0'), + 'count' => $index + 1, + ] + )->render(); + } catch (Throwable $e) { + Log::debug(sprintf('Throwable was thrown in getPreviousActions(): %s', $e->getMessage())); + Log::error($e->getTraceAsString()); + } + $index++; } - // @codeCoverageIgnoreEnd - ++$newIndex; } - return $actions; + return $triggers; } /** @@ -129,30 +125,27 @@ trait RuleManagement */ protected function getPreviousTriggers(Request $request): array { - $newIndex = 0; + $index = 0; $triggers = []; - /** @var array $oldTriggers */ - $oldTriggers = \is_array($request->old('rule-trigger')) ? $request->old('rule-trigger') : []; - foreach ($oldTriggers as $index => $entry) { - $count = ($newIndex + 1); - $oldChecked = isset($request->old('rule-trigger-stop')[$index]) ? true : false; - try { - $triggers[] = view( - 'rules.partials.trigger', - [ - 'oldTrigger' => $entry, - 'oldValue' => $request->old('rule-trigger-value')[$index], - 'oldChecked' => $oldChecked, - 'count' => $count, - ] - )->render(); - // @codeCoverageIgnoreStart - } catch (Throwable $e) { - Log::debug(sprintf('Throwable was thrown in getPreviousTriggers(): %s', $e->getMessage())); - Log::error($e->getTraceAsString()); + $oldInput = $request->old('rule_triggers'); + if (\is_array($oldInput)) { + foreach ($oldInput as $oldTrigger) { + try { + $triggers[] = view( + 'rules.partials.trigger', + [ + 'oldTrigger' => $oldTrigger['name'], + 'oldValue' => $oldTrigger['value'], + 'oldChecked' => 1 === (int)($oldTrigger['stop_processing'] ?? '0'), + 'count' => $index + 1, + ] + )->render(); + } catch (Throwable $e) { + Log::debug(sprintf('Throwable was thrown in getPreviousTriggers(): %s', $e->getMessage())); + Log::error($e->getTraceAsString()); + } + $index++; } - // @codeCoverageIgnoreEnd - ++$newIndex; } return $triggers; diff --git a/app/Support/Http/Controllers/TransactionCalculation.php b/app/Support/Http/Controllers/TransactionCalculation.php new file mode 100644 index 0000000000..6c318fa90b --- /dev/null +++ b/app/Support/Http/Controllers/TransactionCalculation.php @@ -0,0 +1,202 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Http\Controllers; + +use Carbon\Carbon; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; +use FireflyIII\Helpers\Filter\NegativeAmountFilter; +use FireflyIII\Helpers\Filter\OpposingAccountFilter; +use FireflyIII\Helpers\Filter\PositiveAmountFilter; +use FireflyIII\Helpers\Filter\TransferFilter; +use FireflyIII\Models\TransactionType; +use Illuminate\Support\Collection; + +/** + * Trait TransactionCalculation + * + */ +trait TransactionCalculation +{ + /** + * Get all expenses for a set of accounts. + * + * @param Collection $accounts + * @param Collection $opposing + * @param Carbon $start + * @param Carbon $end + * + * @return Collection + */ + protected function getExpensesForOpposing(Collection $accounts, Collection $opposing, Carbon $start, Carbon $end): Collection // get data + augument + { + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); + $collector->setAccounts($accounts)->setRange($start, $end)->setTypes([TransactionType::WITHDRAWAL])->setOpposingAccounts($opposing); + + return $collector->getTransactions(); + } + + /** + * Get all expenses by tags. + * + * @param Collection $accounts + * @param Collection $tags + * @param Carbon $start + * @param Carbon $end + * + * @return Collection + * + */ + protected function getExpensesForTags(Collection $accounts, Collection $tags, Carbon $start, Carbon $end): Collection // get data + augument + { + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); + $collector->setAccounts($accounts)->setRange($start, $end)->setTypes([TransactionType::WITHDRAWAL, TransactionType::TRANSFER]) + ->setTags($tags)->withOpposingAccount(); + $collector->removeFilter(TransferFilter::class); + + $collector->addFilter(OpposingAccountFilter::class); + $collector->addFilter(PositiveAmountFilter::class); + + return $collector->getTransactions(); + } + + /** + * Helper function that collects expenses for the given budgets. + * + * @param Collection $accounts + * @param Collection $budgets + * @param Carbon $start + * @param Carbon $end + * + * @return Collection + */ + protected function getExpensesInBudgets(Collection $accounts, Collection $budgets, Carbon $start, Carbon $end): Collection // get data + augment with info + { + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); + $collector->setAccounts($accounts)->setRange($start, $end)->setTypes([TransactionType::WITHDRAWAL, TransactionType::TRANSFER]) + ->setBudgets($budgets)->withOpposingAccount(); + $collector->removeFilter(TransferFilter::class); + + $collector->addFilter(OpposingAccountFilter::class); + $collector->addFilter(PositiveAmountFilter::class); + + return $collector->getTransactions(); + } + + /** + * Get all expenses in a period for categories. + * + * @param Collection $accounts + * @param Collection $categories + * @param Carbon $start + * @param Carbon $end + * + * @return Collection + * + * + */ + protected function getExpensesInCategories(Collection $accounts, Collection $categories, Carbon $start, Carbon $end): Collection // get data + augument + { + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); + $collector->setAccounts($accounts)->setRange($start, $end)->setTypes([TransactionType::WITHDRAWAL, TransactionType::TRANSFER]) + ->setCategories($categories)->withOpposingAccount(); + $collector->removeFilter(TransferFilter::class); + + $collector->addFilter(OpposingAccountFilter::class); + $collector->addFilter(PositiveAmountFilter::class); + + return $collector->getTransactions(); + } + + /** + * Get all income for a period and a bunch of categories. + * + * @param Collection $accounts + * @param Collection $categories + * @param Carbon $start + * @param Carbon $end + * + * @return Collection + */ + protected function getIncomeForCategories(Collection $accounts, Collection $categories, Carbon $start, Carbon $end): Collection // get data + augument + { + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); + $collector->setAccounts($accounts)->setRange($start, $end)->setTypes([TransactionType::DEPOSIT, TransactionType::TRANSFER]) + ->setCategories($categories)->withOpposingAccount(); + + $collector->addFilter(OpposingAccountFilter::class); + $collector->addFilter(NegativeAmountFilter::class); + + return $collector->getTransactions(); + } + + /** + * Get the income for a set of accounts. + * + * @param Collection $accounts + * @param Collection $opposing + * @param Carbon $start + * @param Carbon $end + * + * @return Collection + */ + protected function getIncomeForOpposing(Collection $accounts, Collection $opposing, Carbon $start, Carbon $end): Collection // get data + augument + { + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); + $collector->setAccounts($accounts)->setRange($start, $end)->setTypes([TransactionType::DEPOSIT])->setOpposingAccounts($opposing); + + return $collector->getTransactions(); + } + + /** + * Get all income by tag. + * + * @param Collection $accounts + * @param Collection $tags + * @param Carbon $start + * @param Carbon $end + * + * @return Collection + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) + */ + protected function getIncomeForTags(Collection $accounts, Collection $tags, Carbon $start, Carbon $end): Collection // get data + augument + { + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); + $collector->setAccounts($accounts)->setRange($start, $end)->setTypes([TransactionType::DEPOSIT, TransactionType::TRANSFER]) + ->setTags($tags)->withOpposingAccount(); + + $collector->addFilter(OpposingAccountFilter::class); + $collector->addFilter(NegativeAmountFilter::class); + + return $collector->getTransactions(); + } + +} \ No newline at end of file diff --git a/app/Support/Http/Controllers/UserNavigation.php b/app/Support/Http/Controllers/UserNavigation.php new file mode 100644 index 0000000000..3d55d27f95 --- /dev/null +++ b/app/Support/Http/Controllers/UserNavigation.php @@ -0,0 +1,126 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Http\Controllers; + +use FireflyIII\Models\Account; +use FireflyIII\Models\AccountType; +use FireflyIII\Models\Transaction; +use FireflyIII\Models\TransactionJournal; +use Illuminate\Http\RedirectResponse; +use Log; +use URL; + +/** + * Trait UserNavigation + * + */ +trait UserNavigation +{ + /** + * Functionality:. + * + * - If the $identifier contains the word "delete" then a remembered uri with the text "/show/" in it will not be returned but instead the index (/) + * will be returned. + * - If the remembered uri contains "javascript/" the remembered uri will not be returned but instead the index (/) will be returned. + * + * @param string $identifier + * + * @return string + */ + protected function getPreviousUri(string $identifier): string + { + $uri = (string)session($identifier); + if (!(false === strpos($identifier, 'delete')) && !(false === strpos($uri, '/show/'))) { + $uri = $this->redirectUri; + } + if (!(false === strpos($uri, 'jscript'))) { + $uri = $this->redirectUri; // @codeCoverageIgnore + } + + return $uri; + } + + /** + * Redirect to asset account that transaction belongs to. + * + * @param TransactionJournal $journal + * + * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector + */ + protected function redirectToAccount(TransactionJournal $journal) + { + $valid = [AccountType::DEFAULT, AccountType::ASSET]; + $transactions = $journal->transactions; + /** @var Transaction $transaction */ + foreach ($transactions as $transaction) { + $account = $transaction->account; + if (\in_array($account->accountType->type, $valid, true)) { + return redirect(route('accounts.show', [$account->id])); + } + } + // @codeCoverageIgnoreStart + session()->flash('error', (string)trans('firefly.cannot_redirect_to_account')); + + return redirect(route('index')); + // @codeCoverageIgnoreEnd + } + + /** + * @param Account $account + * + * @return RedirectResponse|\Illuminate\Routing\Redirector + */ + protected function redirectToOriginalAccount(Account $account) + { + /** @var Transaction $transaction */ + $transaction = $account->transactions()->first(); + if (null === $transaction) { + app('session')->flash('error', trans('firefly.account_missing_transaction', ['name' => $account->name, 'id' => $account->id])); + Log::error(sprintf('Expected a transaction. Account #%d has none. BEEP, error.', $account->id)); + + return redirect(route('index')); + } + + $journal = $transaction->transactionJournal; + /** @var Transaction $opposingTransaction */ + $opposingTransaction = $journal->transactions()->where('transactions.id', '!=', $transaction->id)->first(); + + if (null === $opposingTransaction) { + app('session')->flash('error', trans('firefly.account_missing_transaction', ['name' => $account->name, 'id' => $account->id])); + Log::error(sprintf('Expected an opposing transaction. Account #%d has none. BEEP, error.', $account->id)); + } + + return redirect(route('accounts.show', [$opposingTransaction->account_id])); + } + + /** + * Remember previous URL. + * + * @param string $identifier + */ + protected function rememberPreviousUri(string $identifier): void + { + session()->put($identifier, URL::previous()); + } +} \ No newline at end of file diff --git a/app/Support/Import/JobConfiguration/Bunq/ChooseAccountsHandler.php b/app/Support/Import/JobConfiguration/Bunq/ChooseAccountsHandler.php index 6a801deccc..4a357846e0 100644 --- a/app/Support/Import/JobConfiguration/Bunq/ChooseAccountsHandler.php +++ b/app/Support/Import/JobConfiguration/Bunq/ChooseAccountsHandler.php @@ -81,6 +81,17 @@ class ChooseAccountsHandler implements BunqJobConfigurationInterface $mapping = $data['account_mapping'] ?? []; $applyRules = 1 === (int)$data['apply_rules']; $final = []; + + /* + * $ibanToAsset is used to map bunq IBAN's to Firefly III asset accounts. The array is structured like this: + * 12BUNQ123456.. => 1, + * 12BUNQ928811.. => 4, + * + * And contains the bunq asset account iban (left) and the FF3 asset ID (right). + * + * This is used to properly map transfers. + */ + $ibanToAsset = []; if (0 === \count($accounts)) { throw new FireflyException('No bunq accounts found. Import cannot continue.'); // @codeCoverageIgnore } @@ -95,11 +106,16 @@ class ChooseAccountsHandler implements BunqJobConfigurationInterface $localId = (int)$localId; // validate each - $bunqId = $this->validBunqAccount($bunqId); - $accountId = $this->validLocalAccount($localId); + $bunqId = $this->validBunqAccount($bunqId); + $accountId = $this->validLocalAccount($localId); + $bunqIban = $this->getBunqIban($bunqId); + if (null !== $bunqIban) { + $ibanToAsset[$bunqIban] = $accountId; + } $final[$bunqId] = $accountId; } $config['mapping'] = $final; + $config['bunq-iban'] = $ibanToAsset; $config['apply-rules'] = $applyRules; $this->repository->setConfiguration($this->importJob, $config); @@ -169,6 +185,25 @@ class ChooseAccountsHandler implements BunqJobConfigurationInterface $this->accountRepository->setUser($importJob->user); } + /** + * @param int $bunqId + * + * @return null|string + */ + private function getBunqIban(int $bunqId): ?string + { + $config = $this->repository->getConfiguration($this->importJob); + $accounts = $config['accounts'] ?? []; + /** @var array $bunqAccount */ + foreach ($accounts as $bunqAccount) { + if ((int)$bunqAccount['id'] === $bunqId) { + return $bunqAccount['iban'] ?? null; + } + } + + return null; + } + /** * @param int $currencyId * diff --git a/app/Support/Import/JobConfiguration/File/ConfigureMappingHandler.php b/app/Support/Import/JobConfiguration/File/ConfigureMappingHandler.php index 568ff9d384..e8803b31b0 100644 --- a/app/Support/Import/JobConfiguration/File/ConfigureMappingHandler.php +++ b/app/Support/Import/JobConfiguration/File/ConfigureMappingHandler.php @@ -267,6 +267,7 @@ class ConfigureMappingHandler implements FileConfigurationInterface * - Add the value to the list of "values" that the user must map. * * @param Reader $reader + * @param array $config * @param array $columnConfig * * @return array diff --git a/app/Support/Import/JobConfiguration/File/NewFileJobHandler.php b/app/Support/Import/JobConfiguration/File/NewFileJobHandler.php index 047f8c5d39..6ce648d5aa 100644 --- a/app/Support/Import/JobConfiguration/File/NewFileJobHandler.php +++ b/app/Support/Import/JobConfiguration/File/NewFileJobHandler.php @@ -101,7 +101,7 @@ class NewFileJobHandler implements FileConfigurationInterface } /** - * @param ImportJob $job + * @param ImportJob $importJob */ public function setImportJob(ImportJob $importJob): void { @@ -114,7 +114,6 @@ class NewFileJobHandler implements FileConfigurationInterface /** * Store config from job. * - * @throws FireflyException */ public function storeConfiguration(): void { diff --git a/app/Support/Import/JobConfiguration/Ynab/NewYnabJobHandler.php b/app/Support/Import/JobConfiguration/Ynab/NewYnabJobHandler.php new file mode 100644 index 0000000000..5a9ddaccda --- /dev/null +++ b/app/Support/Import/JobConfiguration/Ynab/NewYnabJobHandler.php @@ -0,0 +1,258 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Import\JobConfiguration\Ynab; + +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Models\ImportJob; +use FireflyIII\Repositories\ImportJob\ImportJobRepositoryInterface; +use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; +use Illuminate\Support\MessageBag; +use Log; +use RuntimeException; + +/** + * Class NewYnabJobHandler + */ +class NewYnabJobHandler implements YnabJobConfigurationInterface +{ + /** @var ImportJob */ + private $importJob; + /** @var ImportJobRepositoryInterface */ + private $repository; + + /** + * Return true when this stage is complete. + * + * @return bool + * @throws FireflyException + */ + public function configurationComplete(): bool + { + if (!$this->hasRefreshToken()) { + Log::debug('YNAB NewYnabJobHandler configurationComplete: stage is new, no refresh token, return false'); + + return false; + } + if ($this->hasRefreshToken() && $this->hasClientId() && $this->hasClientSecret()) { + Log::debug('YNAB NewYnabJobHandler configurationComplete: stage is new, has a refresh token, return true'); + // need to grab access token using refresh token + $this->getAccessToken(); + $this->repository->setStatus($this->importJob, 'ready_to_run'); + $this->repository->setStage($this->importJob, 'get_budgets'); + + return true; + } + Log::error('YNAB NewYnabJobHandler configurationComplete: something broke, return true'); + + return true; + } + + /** + * Store the job configuration. There is never anything to store for this stage. + * + * @param array $data + * + * @return MessageBag + */ + public function configureJob(array $data): MessageBag + { + Log::debug('YNAB NewYnabJobHandler configureJob: nothing to do.'); + + return new MessageBag; + } + + /** + * Get data for config view. + * + * @return array + * @throws \Psr\Container\NotFoundExceptionInterface + * @throws \Psr\Container\ContainerExceptionInterface + */ + public function getNextData(): array + { + $data = []; + // here we update the job so it can redirect properly to YNAB + if (!$this->hasRefreshToken() && $this->hasClientSecret() && $this->hasClientId()) { + // update stage to make sure we catch the token. + $this->repository->setStage($this->importJob, 'catch-auth-code'); + $clientId = app('preferences')->get('ynab_client_id')->data; + $callBackUri = route('import.callback.ynab'); + $uri = sprintf( + 'https://app.youneedabudget.com/oauth/authorize?client_id=%s&redirect_uri=%s&response_type=code&state=%s', $clientId, $callBackUri, + $this->importJob->key + ); + $data['token-url'] = $uri; + Log::debug(sprintf('YNAB getNextData: URI to redirect to is %s', $uri)); + } + + return $data; + } + + /** + * Get the view for this stage. + * + * @return string + */ + public function getNextView(): string + { + Log::debug('Return YNAB redirect view.'); + + return 'import.ynab.redirect'; + } + + /** + * Set the import job. + * + * @param ImportJob $importJob + */ + public function setImportJob(ImportJob $importJob): void + { + $this->importJob = $importJob; + $this->repository = app(ImportJobRepositoryInterface::class); + $this->repository->setUser($importJob->user); + } + + + /** + * @throws \Psr\Container\NotFoundExceptionInterface + * @throws \Psr\Container\ContainerExceptionInterface + * @throws FireflyException + */ + private function getAccessToken(): void + { + $clientId = app('preferences')->get('ynab_client_id')->data; + $clientSecret = app('preferences')->get('ynab_client_secret')->data; + $refreshToken = app('preferences')->get('ynab_refresh_token')->data; + $uri = sprintf( + 'https://app.youneedabudget.com/oauth/token?client_id=%s&client_secret=%s&grant_type=refresh_token&refresh_token=%s', $clientId, $clientSecret, + $refreshToken + ); + + $client = new Client(); + try { + $res = $client->request('post', $uri, []); + } catch (GuzzleException $e) { + Log::error($e->getMessage()); + Log::error($e->getTraceAsString()); + throw new FireflyException($e->getMessage()); + } + $statusCode = $res->getStatusCode(); + try { + $content = trim($res->getBody()->getContents()); + } catch (RuntimeException $e) { + Log::error($e->getMessage()); + Log::error($e->getTraceAsString()); + throw new FireflyException($e->getMessage()); + } + $json = json_decode($content, true) ?? []; + Log::debug(sprintf('Status code from YNAB is %d', $statusCode)); + Log::debug(sprintf('Body of result is %s', $content), $json); + + // store refresh token (if present?) as preference + // store token in job: + $configuration = $this->repository->getConfiguration($this->importJob); + $configuration['access_token'] = $json['access_token']; + $configuration['access_token_expires'] = (int)$json['created_at'] + (int)$json['expires_in']; + $this->repository->setConfiguration($this->importJob, $configuration); + + // also store new refresh token: + $refreshToken = (string)($json['refresh_token'] ?? ''); + if ('' !== $refreshToken) { + app('preferences')->set('ynab_refresh_token', $refreshToken); + } + + + Log::debug('end of NewYnabJobHandler::getAccessToken()'); + } + + /** + * Check if we have the client ID. + * + * @return bool + */ + private function hasClientId(): bool + { + $clientId = app('preferences')->getForUser($this->importJob->user, 'ynab_client_id', null); + if (null === $clientId) { + Log::debug('user has no YNAB client ID'); + + return false; + } + if ('' === (string)$clientId->data) { + Log::debug('user has no YNAB client ID (empty)'); + + return false; + } + Log::debug('user has a YNAB client ID'); + + return true; + } + + /** + * Check if we have the client secret + * + * @return bool + */ + private function hasClientSecret(): bool + { + $clientSecret = app('preferences')->getForUser($this->importJob->user, 'ynab_client_secret', null); + if (null === $clientSecret) { + Log::debug('user has no YNAB client secret'); + + return false; + } + if ('' === (string)$clientSecret->data) { + Log::debug('user has no YNAB client secret (empty)'); + + return false; + } + Log::debug('user has a YNAB client secret'); + + return true; + } + + /** + * @return bool + * @throws \Psr\Container\NotFoundExceptionInterface + * @throws \Psr\Container\ContainerExceptionInterface + */ + private function hasRefreshToken(): bool + { + $preference = app('preferences')->get('ynab_refresh_token'); + if (null === $preference) { + Log::debug('user has no YNAB refresh token.'); + + return false; + } + if ('' === (string)$preference->data) { + Log::debug('user has no YNAB refresh token (empty).'); + + return false; + } + Log::debug(sprintf('user has YNAB refresh token: %s', $preference->data)); + + return true; + } +} \ No newline at end of file diff --git a/app/Support/Import/JobConfiguration/Ynab/SelectAccountsHandler.php b/app/Support/Import/JobConfiguration/Ynab/SelectAccountsHandler.php new file mode 100644 index 0000000000..4dbf8fa49b --- /dev/null +++ b/app/Support/Import/JobConfiguration/Ynab/SelectAccountsHandler.php @@ -0,0 +1,237 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Import\JobConfiguration\Ynab; + + +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Models\Account; +use FireflyIII\Models\AccountType; +use FireflyIII\Models\ImportJob; +use FireflyIII\Models\TransactionCurrency; +use FireflyIII\Repositories\Account\AccountRepositoryInterface; +use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface; +use FireflyIII\Repositories\ImportJob\ImportJobRepositoryInterface; +use Illuminate\Support\MessageBag; +use Log; + +/** + * Class SelectAccountsHandler + */ +class SelectAccountsHandler implements YnabJobConfigurationInterface +{ + /** @var AccountRepositoryInterface */ + private $accountRepository; + /** @var CurrencyRepositoryInterface */ + private $currencyRepository; + /** @var ImportJob */ + private $importJob; + /** @var ImportJobRepositoryInterface */ + private $repository; + + /** + * Return true when this stage is complete. + * + * @return bool + */ + public function configurationComplete(): bool + { + Log::debug('Now in SelectAccountsHandler::configurationComplete()'); + $config = $this->importJob->configuration; + $mapping = $config['mapping'] ?? []; + if (\count($mapping) > 0) { + // mapping is complete. + Log::debug('Looks like user has mapped YNAB accounts to Firefly III accounts', $mapping); + $this->repository->setStage($this->importJob, 'go-for-import'); + + return true; + } + + return false; + } + + /** + * Store the job configuration. + * + * @param array $data + * + * @return MessageBag + */ + public function configureJob(array $data): MessageBag + { + Log::debug('Now in SelectAccountsHandler::configureJob()', $data); + $config = $this->importJob->configuration; + $mapping = $data['account_mapping'] ?? []; + $final = []; + $applyRules = 1 === (int)($data['apply_rules'] ?? 0); + foreach ($mapping as $ynabId => $localId) { + // validate each + $ynabId = $this->validYnabAccount($ynabId); + $accountId = $this->validLocalAccount((int)$localId); + $final[$ynabId] = $accountId; + + } + Log::debug('Final mapping is:', $final); + $messages = new MessageBag; + $config['mapping'] = $final; + $config['apply-rules'] = $applyRules; + $this->repository->setConfiguration($this->importJob, $config); + if ($final === ['' => 0] || 0 === \count($final)) { + $messages->add('count', (string)trans('import.ynab_no_mapping')); + } + + return $messages; + } + + /** + * Get data for config view. + * + * @return array + * @throws FireflyException + */ + public function getNextData(): array + { + + Log::debug('Now in ChooseAccountsHandler::getnextData()'); + $config = $this->importJob->configuration; + $ynabAccounts = $config['accounts'] ?? []; + $budget = $this->getSelectedBudget(); + if (0 === \count($ynabAccounts)) { + throw new FireflyException('It seems you have no accounts with this budget. The import cannot continue.'); // @codeCoverageIgnore + } + // list the users accounts: + $ffAccounts = $this->accountRepository->getAccountsByType([AccountType::ASSET]); + + $array = []; + /** @var Account $account */ + foreach ($ffAccounts as $account) { + $accountId = $account->id; + $currencyId = (int)$this->accountRepository->getMetaValue($account, 'currency_id'); + $currency = $this->getCurrency($currencyId); + $array[$accountId] = [ + 'name' => $account->name, + 'iban' => $account->iban, + 'code' => $currency->code, + ]; + } + + return [ + 'budget' => $budget, + 'ynab_accounts' => $ynabAccounts, + 'ff_accounts' => $array, + ]; + } + + /** + * Get the view for this stage. + * + * @return string + */ + public function getNextView(): string + { + return 'import.ynab.accounts'; + } + + /** + * @codeCoverageIgnore + * Set the import job. + * + * @param ImportJob $importJob + */ + public function setImportJob(ImportJob $importJob): void + { + $this->importJob = $importJob; + $this->repository = app(ImportJobRepositoryInterface::class); + $this->accountRepository = app(AccountRepositoryInterface::class); + $this->currencyRepository = app(CurrencyRepositoryInterface::class); + $this->repository->setUser($importJob->user); + $this->currencyRepository->setUser($importJob->user); + $this->accountRepository->setUser($importJob->user); + } + + /** + * @param int $currencyId + * + * @return TransactionCurrency + */ + private function getCurrency(int $currencyId): TransactionCurrency + { + $currency = $this->currencyRepository->findNull($currencyId); + if (null === $currency) { + return app('amount')->getDefaultCurrencyByUser($this->importJob->user); + } + + return $currency; + + } + + /** + * @return array + */ + private function getSelectedBudget(): array + { + $config = $this->repository->getConfiguration($this->importJob); + $budgets = $config['budgets'] ?? []; + $selected = $config['selected_budget'] ?? ''; + foreach ($budgets as $budget) { + if ($budget['id'] === $selected) { + return $budget; + } + } + + return $budgets[0] ?? []; + } + + /** + * @param int $accountId + * + * @return int + */ + private function validLocalAccount(int $accountId): int + { + $account = $this->accountRepository->findNull($accountId); + if (null === $account) { + return 0; + } + + return $accountId; + } + + /** + * @param string $accountId + * + * @return string + */ + private function validYnabAccount(string $accountId): string + { + $config = $this->importJob->configuration; + $accounts = $config['accounts'] ?? []; + foreach ($accounts as $account) { + if ($account['id'] === $accountId) { + return $accountId; + } + } + + return ''; + } +} \ No newline at end of file diff --git a/app/Support/Import/JobConfiguration/Ynab/SelectBudgetHandler.php b/app/Support/Import/JobConfiguration/Ynab/SelectBudgetHandler.php new file mode 100644 index 0000000000..92a14d57c1 --- /dev/null +++ b/app/Support/Import/JobConfiguration/Ynab/SelectBudgetHandler.php @@ -0,0 +1,185 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Import\JobConfiguration\Ynab; + +use FireflyIII\Models\Account; +use FireflyIII\Models\AccountType; +use FireflyIII\Models\ImportJob; +use FireflyIII\Repositories\Account\AccountRepositoryInterface; +use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface; +use FireflyIII\Repositories\ImportJob\ImportJobRepositoryInterface; +use Illuminate\Support\Collection; +use Illuminate\Support\MessageBag; +use Log; + +/** + * Class SelectBudgetHandler + */ +class SelectBudgetHandler implements YnabJobConfigurationInterface +{ + /** @var AccountRepositoryInterface */ + private $accountRepository; + /** @var Collection */ + private $accounts; + /** @var CurrencyRepositoryInterface */ + private $currencyRepository; + /** @var ImportJob */ + private $importJob; + /** @var ImportJobRepositoryInterface */ + private $repository; + + /** + * Return true when this stage is complete. + * + * @return bool + */ + public function configurationComplete(): bool + { + Log::debug('Now in SelectBudgetHandler::configComplete'); + $configuration = $this->repository->getConfiguration($this->importJob); + $selectedBudget = $configuration['selected_budget'] ?? ''; + if ('' !== $selectedBudget) { + Log::debug(sprintf('Selected budget is %s, config is complete. Return true.', $selectedBudget)); + $this->repository->setStage($this->importJob, 'get_accounts'); + + return true; + } + Log::debug('User has not selected a budget yet, config is not yet complete.'); + + return false; + } + + /** + * Store the job configuration. + * + * @param array $data + * + * @return MessageBag + */ + public function configureJob(array $data): MessageBag + { + Log::debug('Now in SelectBudgetHandler::configureJob'); + $configuration = $this->repository->getConfiguration($this->importJob); + $configuration['selected_budget'] = $data['budget_id']; + + Log::debug(sprintf('Set selected budget to %s', $data['budget_id'])); + Log::debug('Mark job as ready for next stage.'); + + + $this->repository->setConfiguration($this->importJob, $configuration); + + return new MessageBag; + } + + /** + * Get data for config view. + * + * @return array + */ + public function getNextData(): array + { + Log::debug('Now in SelectBudgetHandler::getNextData'); + $configuration = $this->repository->getConfiguration($this->importJob); + $budgets = $configuration['budgets'] ?? []; + $available = []; + $notAvailable = []; + $total = \count($budgets); + foreach ($budgets as $budget) { + if ($this->haveAssetWithCurrency($budget['currency_code'])) { + Log::debug('Add budget to available list.'); + $available[$budget['id']] = $budget['name'] . ' (' . $budget['currency_code'] . ')'; + continue; + } + Log::debug('Add budget to notAvailable list.'); + $notAvailable[$budget['id']] = $budget['name'] . ' (' . $budget['currency_code'] . ')'; + + } + + return [ + 'available' => $available, + 'not_available' => $notAvailable, + 'total' => $total, + ]; + } + + /** + * Get the view for this stage. + * + * @return string + */ + public function getNextView(): string + { + Log::debug('Now in SelectBudgetHandler::getNextView'); + + return 'import.ynab.select-budgets'; + } + + /** + * Set the import job. + * + * @param ImportJob $importJob + */ + public function setImportJob(ImportJob $importJob): void + { + $this->importJob = $importJob; + $this->repository = app(ImportJobRepositoryInterface::class); + $this->currencyRepository = app(CurrencyRepositoryInterface::class); + $this->accountRepository = app(AccountRepositoryInterface::class); + + $this->repository->setUser($importJob->user); + $this->currencyRepository->setUser($importJob->user); + $this->accountRepository->setUser($importJob->user); + $this->accountRepository->setUser($importJob->user); + + $this->accounts = $this->accountRepository->getAccountsByType([AccountType::ASSET, AccountType::DEFAULT]); + } + + /** + * @param string $code + * + * @return bool + */ + private function haveAssetWithCurrency(string $code): bool + { + $currency = $this->currencyRepository->findByCodeNull($code); + if (null === $currency) { + Log::debug(sprintf('No currency found with code "%s"', $code)); + + return false; + } + /** @var Account $account */ + foreach ($this->accounts as $account) { + $currencyId = (int)$this->accountRepository->getMetaValue($account, 'currency_id'); + Log::debug(sprintf('Currency of %s is %d (looking for %d).', $account->name, $currencyId, $currency->id)); + if ($currencyId === $currency->id) { + Log::debug('Return true!'); + + return true; + } + } + Log::debug('Found nothing, return false.'); + + return false; + } +} \ No newline at end of file diff --git a/app/Support/Import/JobConfiguration/Ynab/YnabJobConfigurationInterface.php b/app/Support/Import/JobConfiguration/Ynab/YnabJobConfigurationInterface.php new file mode 100644 index 0000000000..539f26dc44 --- /dev/null +++ b/app/Support/Import/JobConfiguration/Ynab/YnabJobConfigurationInterface.php @@ -0,0 +1,72 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Import\JobConfiguration\Ynab; + +use FireflyIII\Models\ImportJob; +use Illuminate\Support\MessageBag; + +/** + * Interface YnabJobConfigurationInterface + * + */ +interface YnabJobConfigurationInterface +{ + /** + * Return true when this stage is complete. + * + * @return bool + */ + public function configurationComplete(): bool; + + + /** + * Store the job configuration. + * + * @param array $data + * + * @return MessageBag + */ + public function configureJob(array $data): MessageBag; + + /** + * Get data for config view. + * + * @return array + */ + public function getNextData(): array; + + /** + * Get the view for this stage. + * + * @return string + */ + public function getNextView(): string; + + /** + * Set the import job. + * + * @param ImportJob $importJob + */ + public function setImportJob(ImportJob $importJob): void; +} \ No newline at end of file diff --git a/app/Support/Import/Placeholder/ImportTransaction.php b/app/Support/Import/Placeholder/ImportTransaction.php index 3847c7553c..0d397561f7 100644 --- a/app/Support/Import/Placeholder/ImportTransaction.php +++ b/app/Support/Import/Placeholder/ImportTransaction.php @@ -183,7 +183,9 @@ class ImportTransaction $meta = ['sepa-ct-id', 'sepa-ct-op', 'sepa-db', 'sepa-cc', 'sepa-country', 'sepa-batch-id', 'sepa-ep', 'sepa-ci', 'internal-reference', 'date-interest', 'date-invoice', 'date-book', 'date-payment', 'date-process', 'date-due',]; + Log::debug(sprintf('Now going to check role "%s".', $role)); if (\in_array($role, $meta, true)) { + Log::debug(sprintf('Role "%s" is in allowed meta roles, so store its value %s.', $role, $columnValue->getValue())); $this->meta[$role] = $columnValue->getValue(); return; diff --git a/app/Support/Import/Routine/Bunq/StageImportDataHandler.php b/app/Support/Import/Routine/Bunq/StageImportDataHandler.php index f0067b57dd..3920d988e4 100644 --- a/app/Support/Import/Routine/Bunq/StageImportDataHandler.php +++ b/app/Support/Import/Routine/Bunq/StageImportDataHandler.php @@ -44,17 +44,33 @@ use Log; */ class StageImportDataHandler { + private const DOWNLOAD_BACKWARDS = 1; + private const DOWNLOAD_FORWARDS = 2; + + /** @var bool */ + public $stillRunning; /** @var AccountFactory */ private $accountFactory; /** @var AccountRepositoryInterface */ private $accountRepository; /** @var ImportJob */ private $importJob; + /** @var array */ + private $jobConfiguration; /** @var ImportJobRepositoryInterface */ private $repository; + /** @var float */ + private $timeStart; /** @var array */ private $transactions; + public function __construct() + { + $this->stillRunning = true; + $this->timeStart = microtime(true); + + } + /** * @codeCoverageIgnore * @return array @@ -71,10 +87,11 @@ class StageImportDataHandler public function run(): void { $this->getContext(); - $config = $this->repository->getConfiguration($this->importJob); - $accounts = $config['accounts'] ?? []; - $mapping = $config['mapping'] ?? []; - $collection = [[]]; + $config = $this->repository->getConfiguration($this->importJob); + $accounts = $config['accounts'] ?? []; + $mapping = $config['mapping'] ?? []; + $collection = [[]]; + $this->jobConfiguration = $config; /** @var array $bunqAccount */ foreach ($accounts as $bunqAccount) { $bunqAccountId = $bunqAccount['id'] ?? 0; @@ -111,8 +128,9 @@ class StageImportDataHandler * @param LocalAccount $source * * @return array + * @throws FireflyException */ - private function convertPayment(BunqPayment $payment, LocalAccount $source): array + private function convertPayment(BunqPayment $payment, int $bunqAccountId, LocalAccount $source): array { Log::debug(sprintf('Now at payment with ID #%d', $payment->getId())); $type = TransactionType::WITHDRAWAL; @@ -191,9 +209,24 @@ class StageImportDataHandler */ private function convertToAccount(LabelMonetaryAccount $party, string $expectedType): LocalAccount { - Log::debug('in convertToAccount()'); + + Log::debug(sprintf('in convertToAccount() with LabelMonetaryAccount: %s', '')); if (null !== $party->getIban()) { - // find opposing party by IBAN first. + // find account in 'bunq-iban' array first. + $bunqIbans = $this->jobConfiguration['bunq-iban'] ?? []; + if (isset($bunqIbans[$party->getIban()])) { + $accountId = (int)$bunqIbans[$party->getIban()]; + $result = $this->accountRepository->findNull($accountId); + if (null !== $result) { + Log::debug( + sprintf('Search for #%s (based on IBAN %s) resulted in account %s (#%d)', $accountId, $party->getIban(), $result->name, $result->id) + ); + + return $result; + } + } + + // find opposing party by IBAN second. $result = $this->accountRepository->findByIbanNull($party->getIban(), [$expectedType]); if (null !== $result) { Log::debug(sprintf('Search for %s resulted in account %s (#%d)', $party->getIban(), $result->name, $result->id)); @@ -253,6 +286,40 @@ class StageImportDataHandler throw new FireflyException('The bunq API context is unexpectedly empty.'); // @codeCoverageIgnore } + /** + * Get the direction in which we must download. + * + * @param int $bunqAccountId + * + * @return int + */ + private function getDirection(int $bunqAccountId): int + { + Log::debug(sprintf('Now in getDirection(%d)', $bunqAccountId)); + + // if oldest transaction ID is 0, AND the newest transaction is 0 + // we don't know about this account, so we must go backward in time. + $oldest = \Preferences::getForUser($this->importJob->user, sprintf('bunq-oldest-transaction-%d', $bunqAccountId), 0); + $newest = \Preferences::getForUser($this->importJob->user, sprintf('bunq-newest-transaction-%d', $bunqAccountId), 0); + + if (0 === $oldest->data && 0 === $newest->data) { + Log::debug(sprintf('Oldest tranaction ID is %d and newest tranasction ID is %d, so go backwards.', $oldest->data, $newest->data)); + + return self::DOWNLOAD_BACKWARDS; + } + + // if newest is not zero but oldest is zero, go forward. + if (0 === $oldest->data && 0 !== $newest->data) { + Log::debug(sprintf('Oldest tranaction ID is %d and newest tranasction ID is %d, so go forwards.', $oldest->data, $newest->data)); + + return self::DOWNLOAD_FORWARDS; + } + + Log::debug(sprintf('Oldest tranaction ID is %d and newest tranasction ID is %d, so go backwards.', $oldest->data, $newest->data)); + + return self::DOWNLOAD_BACKWARDS; + } + /** * @param int $accountId * @@ -281,17 +348,232 @@ class StageImportDataHandler */ private function getTransactionsFromBunq(int $bunqAccountId, LocalAccount $localAccount): array { - $return = []; - // make request: - /** @var Payment $paymentRequest */ - $paymentRequest = app(Payment::class); - $result = $paymentRequest->listing($bunqAccountId, ['count' => 100]); - // loop result: - /** @var BunqPayment $payment */ - foreach ($result->getValue() as $payment) { - $return[] = $this->convertPayment($payment, $localAccount); + Log::debug(sprintf('Now in getTransactionsFromBunq(%d).', $bunqAccountId)); + + $direction = $this->getDirection($bunqAccountId); + $return = []; + if (self::DOWNLOAD_BACKWARDS === $direction) { + // go back either from NULL or from ID. + // ID is the very last transaction downloaded from bunq. + $preference = \Preferences::getForUser($this->importJob->user, sprintf('bunq-oldest-transaction-%d', $bunqAccountId), 0); + $transactionId = 0 === $preference->data ? null : $preference->data; + $return = $this->goBackInTime($bunqAccountId, $localAccount, $transactionId); + } + if (self::DOWNLOAD_FORWARDS === $direction) { + // go forward from ID. There is no NULL, young padawan + $return = $this->goForwardInTime($bunqAccountId, $localAccount); } return $return; } + + /** + * This method downloads the transactions from bunq going back in time. Assuming bunq + * is fairly consistent with the transactions it provides through the API, the method + * will store both the highest and the lowest transaction ID downloaded in this manner. + * + * The highest transaction ID is used to continue forward in time. The lowest is used to continue + * even further back in time. + * + * The lowest transaction ID can also be given to this method as a parameter (as $startTransaction). + * + * @param int $bunqAccountId + * @param LocalAccount $localAccount + * @param int $startTransaction + * + * @return array + * @throws FireflyException + */ + private function goBackInTime(int $bunqAccountId, LocalAccount $localAccount, int $startTransaction = null): array + { + Log::debug(sprintf('Now in goBackInTime(#%d, #%s, #%s).', $bunqAccountId, $localAccount->id, $startTransaction)); + $hasMoreTransactions = true; + $olderId = $startTransaction; + $oldestTransaction = null; + $newestTransaction = null; + $count = 0; + $return = []; + + /* + * Do a loop during which we run: + */ + while ($hasMoreTransactions && $this->timeRunning() < 25) { + Log::debug(sprintf('Now in loop #%d', $count)); + Log::debug(sprintf('Now running for %s seconds.', $this->timeRunning())); + + /* + * Send request to bunq. + */ + /** @var Payment $paymentRequest */ + $paymentRequest = app(Payment::class); + $params = ['count' => 53, 'older_id' => $olderId]; + $response = $paymentRequest->listing($bunqAccountId, $params); + $pagination = $response->getPagination(); + Log::debug('Params for the request to bunq are: ', $params); + + /* + * If pagination is not null, we can go back even further. + */ + if (null !== $pagination) { + $olderId = $pagination->getOlderId(); + Log::debug(sprintf('Pagination object is not null, new olderID is "%s"', $olderId)); + } + + /* + * Loop the results from bunq + */ + Log::debug('Now looping results from bunq...'); + /** @var BunqPayment $payment */ + foreach ($response->getValue() as $index => $payment) { + $return[] = $this->convertPayment($payment, $bunqAccountId, $localAccount); + $paymentId = $payment->getId(); + /* + * If oldest and newest transaction are null, they have to be set: + */ + $oldestTransaction = $oldestTransaction ?? $paymentId; + $newestTransaction = $newestTransaction ?? $paymentId; + + /* + * Then, overwrite if appropriate + */ + $oldestTransaction = $paymentId < $oldestTransaction ? $paymentId : $oldestTransaction; + $newestTransaction = $paymentId > $newestTransaction ? $paymentId : $newestTransaction; + } + + /* + * After the loop, check if Firefly III must loop again. + */ + Log::debug(sprintf('Count of result is now %d', \count($return))); + $count++; + if (null === $olderId) { + Log::debug('Older ID is NULL, so stop looping cause we are done!'); + $hasMoreTransactions = false; + $this->stillRunning = false; + /* + * We no longer care about the oldest transaction ID: + */ + $oldestTransaction = 0; + } + if (null === $pagination) { + Log::debug('No pagination object, stop looping.'); + $hasMoreTransactions = false; + $this->stillRunning = false; + /* + * We no longer care about the oldest transaction ID: + */ + $oldestTransaction = 0; + } + sleep(1); + + + } + // store newest and oldest tranasction ID to be used later: + \Preferences::setForUser($this->importJob->user, sprintf('bunq-oldest-transaction-%d', $bunqAccountId), $oldestTransaction); + \Preferences::setForUser($this->importJob->user, sprintf('bunq-newest-transaction-%d', $bunqAccountId), $newestTransaction); + + return $return; + } + + /** + * @param int $bunqAccountId + * @param LocalAccount $localAccount + * + * @return array + * @throws FireflyException + */ + private function goForwardInTime(int $bunqAccountId, LocalAccount $localAccount): array + { + Log::debug(sprintf('Now in goForwardInTime(%d).', $bunqAccountId)); + $hasMoreTransactions = true; + $count = 0; + $return = []; + $newestTransaction = null; + + /* + * Go forward from the newest transaction we know about: + */ + $preferenceName = sprintf('bunq-newest-transaction-%d', $bunqAccountId); + $transactionPref = \Preferences::getForUser($this->importJob->user, $preferenceName, 0); + $newerId = (int)$transactionPref->data; + + /* + * Run a loop. + */ + while ($hasMoreTransactions && $this->timeRunning() < 25) { + /* + * Debug information: + */ + Log::debug(sprintf('Now in loop #%d', $count)); + Log::debug(sprintf('Now running for %s seconds.', $this->timeRunning())); + + /* + * Send a request to bunq. + */ + /** @var Payment $paymentRequest */ + $paymentRequest = app(Payment::class); + $params = ['count' => 53, 'newer_id' => $newerId]; + $response = $paymentRequest->listing($bunqAccountId, $params); + $pagination = $response->getPagination(); + Log::debug('Submit payment request with params', $params); + + /* + * If pagination is not null, we can go forward even further. + */ + if (null !== $pagination) { + $newerId = $pagination->getNewerId(); + Log::debug(sprintf('Pagination object is not null, newerID is "%s"', $newerId)); + } + Log::debug('Now looping results...'); + /* + * Process the bunq loop. + */ + /** @var BunqPayment $payment */ + foreach ($response->getValue() as $payment) { + $return[] = $this->convertPayment($payment, $bunqAccountId, $localAccount); + $paymentId = $payment->getId(); + + /* + * If oldest and newest transaction are null, they have to be set: + */ + $newestTransaction = $newestTransaction ?? $paymentId; + + /* + * Then, overwrite if appropriate + */ + $newestTransaction = $paymentId > $newestTransaction ? $paymentId : $newestTransaction; + } + + /* + * After the loop, check if Firefly III must loop again. + */ + Log::debug(sprintf('Count of result is now %d', \count($return))); + $count++; + if (null === $newerId) { + Log::debug('Newer ID is NULL, so stop looping cause we are done!'); + $hasMoreTransactions = false; + $this->stillRunning = false; + } + if (null === $pagination) { + Log::debug('No pagination object, stop looping.'); + $hasMoreTransactions = false; + $this->stillRunning = false; + } + sleep(1); + } + + // store newest tranasction ID to be used later: + \Preferences::setForUser($this->importJob->user, sprintf('bunq-newest-transaction-%d', $bunqAccountId), $newestTransaction); + + return $return; + } + + /** + * @return float + */ + private function timeRunning(): float + { + $time_end = microtime(true); + + return $time_end - $this->timeStart; + } } diff --git a/app/Support/Import/Routine/Bunq/StageNewHandler.php b/app/Support/Import/Routine/Bunq/StageNewHandler.php index b4e9a30743..05debeba55 100644 --- a/app/Support/Import/Routine/Bunq/StageNewHandler.php +++ b/app/Support/Import/Routine/Bunq/StageNewHandler.php @@ -68,7 +68,6 @@ class StageNewHandler $config = $this->repository->getConfiguration($this->importJob); $config['accounts'] = $accounts; $this->repository->setConfiguration($this->importJob, $config); - return; } throw new FireflyException('The bunq API context is unexpectedly empty.'); // @codeCoverageIgnore @@ -150,6 +149,7 @@ class StageNewHandler 'balance' => $mab->getBalance(), 'status' => $mab->getStatus(), 'type' => 'MonetaryAccountBank', + 'iban' => null, 'aliases' => [], ]; @@ -168,6 +168,11 @@ class StageNewHandler 'name' => $alias->getName(), 'value' => $alias->getValue(), ]; + + // store IBAN alias separately: + if ('IBAN' === $alias->getType()) { + $return['iban'] = $alias->getValue(); + } } } diff --git a/app/Support/Import/Routine/Fake/StageFinalHandler.php b/app/Support/Import/Routine/Fake/StageFinalHandler.php index 23d02676ca..bb93636f90 100644 --- a/app/Support/Import/Routine/Fake/StageFinalHandler.php +++ b/app/Support/Import/Routine/Fake/StageFinalHandler.php @@ -47,7 +47,7 @@ class StageFinalHandler for ($i = 0; $i < 5; $i++) { $transaction = [ 'type' => 'withdrawal', - 'date' => Carbon::create()->format('Y-m-d'), + 'date' => Carbon::now()->format('Y-m-d'), 'tags' => '', 'user' => $this->importJob->user_id, diff --git a/app/Support/Import/Routine/File/CurrencyMapper.php b/app/Support/Import/Routine/File/CurrencyMapper.php index 21362912b5..83821c019f 100644 --- a/app/Support/Import/Routine/File/CurrencyMapper.php +++ b/app/Support/Import/Routine/File/CurrencyMapper.php @@ -71,7 +71,7 @@ class CurrencyMapper return $result; } } - if (!isset($data['code']) || null === $data['code']) { + if (!isset($data['code'])) { return null; } diff --git a/app/Support/Import/Routine/File/ImportableConverter.php b/app/Support/Import/Routine/File/ImportableConverter.php index 17fcfe2b46..b12cb52216 100644 --- a/app/Support/Import/Routine/File/ImportableConverter.php +++ b/app/Support/Import/Routine/File/ImportableConverter.php @@ -133,9 +133,9 @@ class ImportableConverter /** * @param string|null $date * - * @return string + * @return string|null */ - private function convertDateValue(string $date = null): string + private function convertDateValue(string $date = null): ?string { $result = null; if (null !== $date) { @@ -147,10 +147,6 @@ class ImportableConverter Log::error($e->getTraceAsString()); } } - if (null === $result) { - $object = new Carbon; - $result = $object->format('Y-m-d'); - } return $result; } @@ -167,6 +163,8 @@ class ImportableConverter $foreignAmount = $importable->calculateForeignAmount(); $amount = $importable->calculateAmount(); + Log::debug('All meta data: ', $importable->meta); + if ('' === $amount) { $amount = $foreignAmount; } @@ -215,7 +213,7 @@ class ImportableConverter return [ 'type' => $transactionType, - 'date' => $this->convertDateValue($importable->date), + 'date' => $this->convertDateValue($importable->date) ?? Carbon::now()->format('Y-m-d'), 'tags' => $importable->tags, 'user' => $this->importJob->user_id, 'notes' => $importable->note, diff --git a/app/Support/Import/Routine/Ynab/GetAccountsHandler.php b/app/Support/Import/Routine/Ynab/GetAccountsHandler.php new file mode 100644 index 0000000000..a0c05fd195 --- /dev/null +++ b/app/Support/Import/Routine/Ynab/GetAccountsHandler.php @@ -0,0 +1,80 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Import\Routine\Ynab; + +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Models\ImportJob; +use FireflyIII\Repositories\ImportJob\ImportJobRepositoryInterface; +use FireflyIII\Services\Ynab\Request\GetAccountsRequest; + +/** + * Class GetAccountsHandler + */ +class GetAccountsHandler +{ + /** @var ImportJob */ + private $importJob; + /** @var ImportJobRepositoryInterface */ + private $repository; + + /** + * Get list of accounts for the selected budget. + * + * @throws FireflyException + */ + public function run(): void + { + + $config = $this->repository->getConfiguration($this->importJob); + $selectedBudget = $config['selected_budget'] ?? ''; + if ('' === $selectedBudget) { + $firstBudget = $config['budgets'][0] ?? false; + if (false === $firstBudget) { + throw new FireflyException('The configuration contains no budget. Erroring out.'); + } + $selectedBudget = $firstBudget['id']; + $config['selected_budget'] = $selectedBudget; + } + $token = $config['access_token']; + $request = new GetAccountsRequest; + $request->budgetId = $selectedBudget; + $request->setAccessToken($token); + $request->call(); + $config['accounts'] = $request->accounts; + $this->repository->setConfiguration($this->importJob, $config); + if (0 === \count($config['accounts'])) { + throw new FireflyException('This budget contains zero accounts.'); + } + } + + /** + * @param ImportJob $importJob + */ + public function setImportJob(ImportJob $importJob): void + { + $this->importJob = $importJob; + $this->repository = app(ImportJobRepositoryInterface::class); + $this->repository->setUser($importJob->user); + } +} \ No newline at end of file diff --git a/app/Support/Import/Routine/Ynab/ImportDataHandler.php b/app/Support/Import/Routine/Ynab/ImportDataHandler.php new file mode 100644 index 0000000000..0e97b2f5ea --- /dev/null +++ b/app/Support/Import/Routine/Ynab/ImportDataHandler.php @@ -0,0 +1,273 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Import\Routine\Ynab; + + +use Carbon\Carbon; +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Models\Account; +use FireflyIII\Models\AccountType; +use FireflyIII\Models\ImportJob; +use FireflyIII\Repositories\Account\AccountRepositoryInterface; +use FireflyIII\Repositories\ImportJob\ImportJobRepositoryInterface; +use FireflyIII\Services\Ynab\Request\GetTransactionsRequest; +use FireflyIII\Support\Import\Routine\File\OpposingAccountMapper; +use Log; + +/** + * Class ImportDataHandler + */ +class ImportDataHandler +{ + /** @var AccountRepositoryInterface */ + private $accountRepository; + /** @var ImportJob */ + private $importJob; + /** @var OpposingAccountMapper */ + private $mapper; + /** @var ImportJobRepositoryInterface */ + private $repository; + + /** + * Get list of accounts for the selected budget. + * + * @throws FireflyException + */ + public function run(): void + { + $config = $this->repository->getConfiguration($this->importJob); + $token = $config['access_token']; + // make request for each mapping: + $mapping = $config['mapping'] ?? []; + $total = [[]]; + + /** + * @var string $ynabId + * @var string $localId + */ + foreach ($mapping as $ynabId => $localId) { + $localAccount = $this->getLocalAccount((int)$localId); + $transactions = $this->getTransactions($token, $ynabId); + $converted = $this->convertToArray($transactions, $localAccount); + $total[] = $converted; + } + + $totalSet = array_merge(...$total); + Log::debug(sprintf('Found %d transactions in total.', \count($totalSet))); + $this->repository->setTransactions($this->importJob, $totalSet); + + // assuming this works, store today's date as a preference + // (combined with the budget from which FF3 imported) + $budgetId = $this->getSelectedBudget()['id'] ?? ''; + if ('' !== $budgetId) { + app('preferences')->set('ynab_' . $budgetId, Carbon::now()->format('Y-m-d')); + } + } + + /** + * @param ImportJob $importJob + */ + public function setImportJob(ImportJob $importJob): void + { + $this->importJob = $importJob; + $this->repository = app(ImportJobRepositoryInterface::class); + $this->accountRepository = app(AccountRepositoryInterface::class); + $this->mapper = app(OpposingAccountMapper::class); + $this->accountRepository->setUser($importJob->user); + $this->repository->setUser($importJob->user); + $this->mapper->setUser($importJob->user); + } + + /** + * @param array $transactions + * @param Account $localAccount + * + * @return array + * @throws FireflyException + */ + private function convertToArray(array $transactions, Account $localAccount): array + { + $config = $this->repository->getConfiguration($this->importJob); + $array = []; + $total = \count($transactions); + $budget = $this->getSelectedBudget(); + Log::debug(sprintf('Now in StageImportDataHandler::convertToArray() with count %d', \count($transactions))); + /** @var array $transaction */ + foreach ($transactions as $index => $transaction) { + $description = $transaction['memo'] ?? '(empty)'; + Log::debug(sprintf('Now creating array for transaction %d of %d ("%s")', $index + 1, $total, $description)); + $amount = (string)($transaction['amount'] ?? 0); + if ('0' === $amount) { + Log::debug(sprintf('Amount is zero (%s), skip this transaction.', $amount)); + continue; + } + Log::debug(sprintf('Amount detected is %s', $amount)); + $source = $localAccount; + $type = 'withdrawal'; + $tags = [ + $transaction['cleared'] ?? '', + $transaction['approved'] ? 'approved' : 'not-approved', + $transaction['flag_color'] ?? '', + ]; + $possibleDestinationId = null; + if (null !== $transaction['transfer_account_id']) { + // indication that it is a transfer. + $possibleDestinationId = $config['mapping'][$transaction['transfer_account_id']] ?? null; + Log::debug(sprintf('transfer_account_id has value %s', $transaction['transfer_account_id'])); + Log::debug(sprintf('Can map this to the following FF3 asset account: %d', $possibleDestinationId)); + $type = 'transfer'; + + } + + $destinationData = [ + 'name' => $transaction['payee_name'], + 'iban' => null, + 'number' => $transaction['payee_id'], + 'bic' => null, + ]; + + $destination = $this->mapper->map($possibleDestinationId, $amount, $destinationData); + if (1 === bccomp($amount, '0')) { + [$source, $destination] = [$destination, $source]; + $type = 'transfer' === $type ? 'transfer' : 'deposit'; + Log::debug(sprintf('Amount is %s, so switch source/dest and make this a %s', $amount, $type)); + } + + Log::debug(sprintf('Final source account: #%d ("%s")', $source->id, $source->name)); + Log::debug(sprintf('Final destination account: #%d ("%s")', $destination->id, $destination->name)); + + $entry = [ + 'type' => $type, + 'date' => $transaction['date'] ?? date('Y-m-d'), + 'tags' => $tags, + 'user' => $this->importJob->user_id, + 'notes' => null, + + // all custom fields: + 'external_id' => $transaction['id'] ?? '', + + // journal data: + 'description' => $description, + 'piggy_bank_id' => null, + 'piggy_bank_name' => null, + 'bill_id' => null, + 'bill_name' => null, + + // transaction data: + 'transactions' => [ + [ + 'currency_id' => null, + 'currency_code' => $budget['currency_code'], + 'description' => null, + 'amount' => bcdiv((string)$transaction['amount'], '1000'), + 'budget_id' => null, + 'budget_name' => null, + 'category_id' => null, + 'category_name' => $transaction['category_name'], + 'source_id' => $source->id, + 'source_name' => null, + 'destination_id' => $destination->id, + 'destination_name' => null, + 'foreign_currency_id' => null, + 'foreign_currency_code' => null, + 'foreign_amount' => null, + 'reconciled' => false, + 'identifier' => 0, + ], + ], + ]; + Log::debug(sprintf('Done with entry #%d', $index)); + $array[] = $entry; + } + + return $array; + } + + /** + * @param int $accountId + * + * @return Account + * @throws FireflyException + */ + private function getLocalAccount(int $accountId): Account + { + $account = $this->accountRepository->findNull($accountId); + if (null === $account) { + throw new FireflyException(sprintf('Cannot find Firefly III asset account with ID #%d. Job must stop now.', $accountId)); // @codeCoverageIgnore + } + if ($account->accountType->type !== AccountType::ASSET) { + throw new FireflyException(sprintf('Account with ID #%d is not an asset account. Job must stop now.', $accountId)); // @codeCoverageIgnore + } + + return $account; + } + + /** + * @return array + * @throws FireflyException + */ + private function getSelectedBudget(): array + { + $config = $this->repository->getConfiguration($this->importJob); + $budgets = $config['budgets'] ?? []; + $selected = $config['selected_budget'] ?? ''; + + if ('' === $selected) { + $firstBudget = $config['budgets'][0] ?? false; + if (false === $firstBudget) { + throw new FireflyException('The configuration contains no budget. Erroring out.'); + } + $selected = $firstBudget['id']; + } + + foreach ($budgets as $budget) { + if ($budget['id'] === $selected) { + return $budget; + } + } + + return $budgets[0] ?? []; + } + + /** + * @param string $token + * @param string $account + * + * @return array + * @throws FireflyException + */ + private function getTransactions(string $token, string $account): array + { + $budget = $this->getSelectedBudget(); + $request = new GetTransactionsRequest; + $request->budgetId = $budget['id']; + $request->accountId = $account; + + // todo grab latest date for $ynabId + $request->setAccessToken($token); + $request->call(); + + return $request->transactions; + } +} \ No newline at end of file diff --git a/app/Support/Import/Routine/Ynab/StageGetAccessHandler.php b/app/Support/Import/Routine/Ynab/StageGetAccessHandler.php new file mode 100644 index 0000000000..c155a4e0ca --- /dev/null +++ b/app/Support/Import/Routine/Ynab/StageGetAccessHandler.php @@ -0,0 +1,108 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Import\Routine\Ynab; + +use Exception; +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Models\ImportJob; +use FireflyIII\Repositories\ImportJob\ImportJobRepositoryInterface; +use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; +use Log; +use RuntimeException; + +/** + * Class StageGetAccessHandler + */ +class StageGetAccessHandler +{ + /** @var ImportJob */ + private $importJob; + /** @var ImportJobRepositoryInterface */ + private $repository; + + /** + * Send a token request to YNAB. Return with access token (if all goes well). + * + * @throws \Psr\Container\NotFoundExceptionInterface + * @throws \Psr\Container\ContainerExceptionInterface + * @throws FireflyException + */ + public function run(): void + { + $config = $this->repository->getConfiguration($this->importJob); + $clientId = app('preferences')->get('ynab_client_id', '')->data; + $clientSecret = app('preferences')->get('ynab_client_secret', '')->data; + $redirectUri = route('import.callback.ynab'); + $code = $config['auth_code']; + $uri = sprintf( + 'https://app.youneedabudget.com/oauth/token?client_id=%s&client_secret=%s&redirect_uri=%s&grant_type=authorization_code&code=%s', $clientId, + $clientSecret, $redirectUri, $code + ); + $client = new Client; + try { + $res = $client->request('POST', $uri); + } catch (GuzzleException|Exception $e) { + Log::error($e->getMessage()); + Log::error($e->getTraceAsString()); + throw new FireflyException($e->getMessage()); + } + $statusCode = $res->getStatusCode(); + try { + $content = trim($res->getBody()->getContents()); + } catch (RuntimeException $e) { + Log::error($e->getMessage()); + Log::error($e->getTraceAsString()); + throw new FireflyException($e->getMessage()); + } + + $json = json_decode($content, true) ?? []; + Log::debug(sprintf('Status code from YNAB is %d', $statusCode)); + Log::debug(sprintf('Body of result is %s', $content), $json); + + // store refresh token (if present?) as preference + // store token in job: + $configuration = $this->repository->getConfiguration($this->importJob); + $configuration['access_token'] = $json['access_token']; + $configuration['access_token_expires'] = (int)$json['created_at'] + (int)$json['expires_in']; + $this->repository->setConfiguration($this->importJob, $configuration); + + Log::debug('end of StageGetAccessHandler::run()'); + + $refreshToken = (string)($json['refresh_token'] ?? ''); + if ('' !== $refreshToken) { + app('preferences')->set('ynab_refresh_token', $refreshToken); + } + } + + /** + * @param ImportJob $importJob + */ + public function setImportJob(ImportJob $importJob): void + { + $this->importJob = $importJob; + $this->repository = app(ImportJobRepositoryInterface::class); + $this->repository->setUser($importJob->user); + } +} \ No newline at end of file diff --git a/app/Support/Import/Routine/Ynab/StageGetBudgetsHandler.php b/app/Support/Import/Routine/Ynab/StageGetBudgetsHandler.php new file mode 100644 index 0000000000..fd8c3dbeb6 --- /dev/null +++ b/app/Support/Import/Routine/Ynab/StageGetBudgetsHandler.php @@ -0,0 +1,75 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Import\Routine\Ynab; + +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Models\ImportJob; +use FireflyIII\Repositories\ImportJob\ImportJobRepositoryInterface; +use FireflyIII\Services\Ynab\Request\GetBudgetsRequest; +use Log; + +/** + * Class StageGetBudgetsHandler + */ +class StageGetBudgetsHandler +{ + + /** @var ImportJob */ + private $importJob; + /** @var ImportJobRepositoryInterface */ + private $repository; + + /** + * + * @throws FireflyException + */ + public function run(): void + { + Log::debug('Now in StageGetBudgetsHandler::run()'); + // grab access token from job: + $configuration = $this->repository->getConfiguration($this->importJob); + $token = $configuration['access_token']; + $request = new GetBudgetsRequest; + $request->setAccessToken($token); + $request->call(); + + // store budgets in users preferences. + $configuration['budgets'] = $request->budgets; + $this->repository->setConfiguration($this->importJob, $configuration); + Log::debug(sprintf('Found %d budgets', \count($request->budgets))); + if (0 === \count($request->budgets)) { + throw new FireflyException('It seems this user has zero budgets or an error prevented Firefly III from reading them.'); + } + } + + /** + * @param ImportJob $importJob + */ + public function setImportJob(ImportJob $importJob): void + { + $this->importJob = $importJob; + $this->repository = app(ImportJobRepositoryInterface::class); + $this->repository->setUser($importJob->user); + } +} \ No newline at end of file diff --git a/app/Support/Import/Routine/Ynab/StageGetTransactionsHandler.php b/app/Support/Import/Routine/Ynab/StageGetTransactionsHandler.php new file mode 100644 index 0000000000..9c87cf78d5 --- /dev/null +++ b/app/Support/Import/Routine/Ynab/StageGetTransactionsHandler.php @@ -0,0 +1,55 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Import\Routine\Ynab; + +use FireflyIII\Models\ImportJob; +use FireflyIII\Repositories\ImportJob\ImportJobRepositoryInterface; + +/** + * Class StageGetTransactionsHandler + */ +class StageGetTransactionsHandler +{ + /** @var ImportJob */ + private $importJob; + /** @var ImportJobRepositoryInterface */ + private $repository; + + /** + * + */ + public function run(): void + { + } + + /** + * @param ImportJob $importJob + */ + public function setImportJob(ImportJob $importJob): void + { + $this->importJob = $importJob; + $this->repository = app(ImportJobRepositoryInterface::class); + $this->repository->setUser($importJob->user); + } +} \ No newline at end of file diff --git a/app/Support/Import/Routine/Ynab/StageMatchAccountsHandler.php b/app/Support/Import/Routine/Ynab/StageMatchAccountsHandler.php new file mode 100644 index 0000000000..5944f8514c --- /dev/null +++ b/app/Support/Import/Routine/Ynab/StageMatchAccountsHandler.php @@ -0,0 +1,57 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Import\Routine\Ynab; + +use FireflyIII\Models\ImportJob; +use FireflyIII\Repositories\ImportJob\ImportJobRepositoryInterface; + +/** + * Class StageMatchAccountsHandler + */ +class StageMatchAccountsHandler +{ + + + /** @var ImportJob */ + private $importJob; + /** @var ImportJobRepositoryInterface */ + private $repository; + + /** + * + */ + public function run(): void + { + } + + /** + * @param ImportJob $importJob + */ + public function setImportJob(ImportJob $importJob): void + { + $this->importJob = $importJob; + $this->repository = app(ImportJobRepositoryInterface::class); + $this->repository->setUser($importJob->user); + } +} \ No newline at end of file diff --git a/app/Support/Preferences.php b/app/Support/Preferences.php index a27ab81001..f86340ded9 100644 --- a/app/Support/Preferences.php +++ b/app/Support/Preferences.php @@ -62,7 +62,7 @@ class Preferences try { Preference::where('user_id', auth()->user()->id)->where('name', $name)->delete(); } catch (Exception $e) { - Log::debug(sprintf('Not interesting: %s', $e->getMessage())); + Log::debug(sprintf('Could not delete preference: %s', $e->getMessage())); // don't care. } @@ -171,10 +171,8 @@ class Preferences if (\is_array($lastActivity)) { $lastActivity = implode(',', $lastActivity); } - $hash = md5($lastActivity); - Log::debug(sprintf('Value of last activity is %s, hash is %s', $lastActivity, $hash)); - return $hash; + return md5($lastActivity); } /** diff --git a/app/Support/Search/Modifier.php b/app/Support/Search/Modifier.php index 5b6972c46b..9e29d24925 100644 --- a/app/Support/Search/Modifier.php +++ b/app/Support/Search/Modifier.php @@ -101,7 +101,7 @@ class Modifier try { $compareDate = new Carbon($compare); } catch (Exception $e) { - Log::debug(sprintf('Not interesting: %s', $e->getMessage())); + Log::debug(sprintf('Not interesting in Modifier:dateAfter(): %s', $e->getMessage())); return false; } @@ -120,7 +120,7 @@ class Modifier try { $compareDate = new Carbon($compare); } catch (Exception $e) { - Log::debug(sprintf('Not interesting: %s', $e->getMessage())); + Log::debug(sprintf('Not interesting in modifier:dateBefore(): %s', $e->getMessage())); return false; } @@ -139,7 +139,7 @@ class Modifier try { $compareDate = new Carbon($compare); } catch (Exception $e) { - Log::debug(sprintf('Not interesting: %s', $e->getMessage())); + Log::debug(sprintf('Not interesting in Modifier:sameDate(): %s', $e->getMessage())); return false; } diff --git a/app/Support/Search/Search.php b/app/Support/Search/Search.php index cc1e884954..f456c4be8a 100644 --- a/app/Support/Search/Search.php +++ b/app/Support/Search/Search.php @@ -23,7 +23,7 @@ declare(strict_types=1); namespace FireflyIII\Support\Search; use Carbon\Carbon; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Helpers\Filter\InternalTransferFilter; use FireflyIII\Models\Transaction; use FireflyIII\User; @@ -111,8 +111,8 @@ class Search implements SearchInterface $result = new Collection(); $startTime = microtime(true); do { - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setLimit($pageSize)->setPage($page)->withOpposingAccount(); if ($this->hasModifiers()) { $collector->withOpposingAccount()->withCategoryInformation()->withBudgetInformation(); @@ -122,7 +122,7 @@ class Search implements SearchInterface $collector = $this->applyModifiers($collector); $collector->removeFilter(InternalTransferFilter::class); - $set = $collector->getPaginatedJournals()->getCollection(); + $set = $collector->getPaginatedTransactions()->getCollection(); Log::debug(sprintf('Found %d journals to check. ', $set->count())); @@ -185,12 +185,12 @@ class Search implements SearchInterface } /** - * @param JournalCollectorInterface $collector + * @param TransactionCollectorInterface $collector * - * @return JournalCollectorInterface + * @return TransactionCollectorInterface * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ - private function applyModifiers(JournalCollectorInterface $collector): JournalCollectorInterface + private function applyModifiers(TransactionCollectorInterface $collector): TransactionCollectorInterface { foreach ($this->modifiers as $modifier) { switch ($modifier['type']) { @@ -246,7 +246,7 @@ class Search implements SearchInterface private function extractModifier(string $string): void { $parts = explode(':', $string); - if (2 === \count($parts) && \strlen(trim((string)$parts[0])) > 0 && '' !== trim((string)$parts[1])) { + if (2 === \count($parts) && '' !== trim((string)$parts[1]) && \strlen(trim((string)$parts[0])) > 0) { $type = trim((string)$parts[0]); $value = trim((string)$parts[1]); if (\in_array($type, $this->validModifiers, true)) { diff --git a/app/Support/Steam.php b/app/Support/Steam.php index 3256f37f0a..1e1d135a73 100644 --- a/app/Support/Steam.php +++ b/app/Support/Steam.php @@ -31,6 +31,7 @@ use FireflyIII\Repositories\Account\AccountRepositoryInterface; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Support\Collection; use Log; +use stdClass; /** * Class Steam. @@ -162,6 +163,13 @@ class Steam $balances[$formatted] = $startBalance; $currencyId = (int)$repository->getMetaValue($account, 'currency_id'); + + // use system default currency: + if (0 === $currencyId) { + $currency = app('amount')->getDefaultCurrencyByUser($account->user); + $currencyId = $currency->id; + } + $start->addDay(); // query! @@ -211,6 +219,38 @@ class Steam return $balances; } + /** + * @param \FireflyIII\Models\Account $account + * @param \Carbon\Carbon $date + * + * @return string + */ + public function balancePerCurrency(Account $account, Carbon $date): array + { + + // abuse chart properties: + $cache = new CacheProperties; + $cache->addProperty($account->id); + $cache->addProperty('balance-per-currency'); + $cache->addProperty($date); + if ($cache->has()) { + return $cache->get(); // @codeCoverageIgnore + } + $query = $account->transactions() + ->leftJoin('transaction_journals', 'transaction_journals.id', '=', 'transactions.transaction_journal_id') + ->where('transaction_journals.date', '<=', $date->format('Y-m-d 23:59:59')) + ->groupBy('transactions.transaction_currency_id'); + $balances = $query->get(['transactions.transaction_currency_id', DB::raw('SUM(transactions.amount) as sum_for_currency')]); + $return = []; + /** @var stdClass $entry */ + foreach ($balances as $entry) { + $return[(int)$entry->transaction_currency_id] = $entry->sum_for_currency; + } + $cache->store($return); + + return $return; + } + /** * This method always ignores the virtual balance. * @@ -243,6 +283,38 @@ class Steam return $result; } + /** + * Same as above, but also groups per currency. + * + * @param \Illuminate\Support\Collection $accounts + * @param \Carbon\Carbon $date + * + * @return array + */ + public function balancesPerCurrencyByAccounts(Collection $accounts, Carbon $date): array + { + $ids = $accounts->pluck('id')->toArray(); + // cache this property. + $cache = new CacheProperties; + $cache->addProperty($ids); + $cache->addProperty('balances-per-currency'); + $cache->addProperty($date); + if ($cache->has()) { + return $cache->get(); // @codeCoverageIgnore + } + + // need to do this per account. + $result = []; + /** @var Account $account */ + foreach ($accounts as $account) { + $result[$account->id] = $this->balancePerCurrency($account, $date); + } + + $cache->store($result); + + return $result; + } + /** * @param int $isEncrypted * @param $value @@ -367,7 +439,6 @@ class Steam $value = Crypt::decrypt($value); } catch (DecryptException $e) { // do not care. - Log::debug(sprintf('Not interesting: %s', $e->getMessage())); } return $value; diff --git a/app/Support/Twig/AmountFormat.php b/app/Support/Twig/AmountFormat.php index 52a7554885..9992740d8e 100644 --- a/app/Support/Twig/AmountFormat.php +++ b/app/Support/Twig/AmountFormat.php @@ -132,6 +132,7 @@ class AmountFormat extends Twig_Extension { return new Twig_SimpleFunction( 'formatAmountBySymbol', + /** @noinspection MoreThanThreeArgumentsInspection */ function (string $amount, string $symbol, int $decimalPlaces = null, bool $coloured = null): string { $decimalPlaces = $decimalPlaces ?? 2; $coloured = $coloured ?? true; diff --git a/app/Support/Twig/Extension/TransactionJournal.php b/app/Support/Twig/Extension/TransactionJournal.php index 32bfa25f3a..59e15cfbe6 100644 --- a/app/Support/Twig/Extension/TransactionJournal.php +++ b/app/Support/Twig/Extension/TransactionJournal.php @@ -136,7 +136,7 @@ class TransactionJournal extends Twig_Extension /** * @param JournalModel $journal * - * @return string + * @return array */ private function getTotalAmount(JournalModel $journal): array { @@ -157,6 +157,7 @@ class TransactionJournal extends Twig_Extension $totals[$currencyId]['amount'] = bcadd($transaction->amount, $totals[$currencyId]['amount']); if (null !== $transaction->foreign_currency_id) { + $foreignAmount = $transaction->foreign_amount ?? '0'; $foreignId = $transaction->foreign_currency_id; $foreign = $transaction->foreignCurrency; if (!isset($totals[$foreignId])) { @@ -166,7 +167,7 @@ class TransactionJournal extends Twig_Extension ]; } $totals[$foreignId]['amount'] = bcadd( - $transaction->foreign_amount, + $foreignAmount, $totals[$foreignId]['amount'] ); } diff --git a/app/Support/Twig/General.php b/app/Support/Twig/General.php index 633de8f1c3..e26f79374d 100644 --- a/app/Support/Twig/General.php +++ b/app/Support/Twig/General.php @@ -24,6 +24,7 @@ namespace FireflyIII\Support\Twig; use Carbon\Carbon; use FireflyIII\Models\Account; +use FireflyIII\Repositories\User\UserRepositoryInterface; use FireflyIII\Support\Twig\Extension\Account as AccountExtension; use League\CommonMark\CommonMarkConverter; use Route; @@ -64,6 +65,7 @@ class General extends Twig_Extension $this->activeRoutePartialWhat(), $this->formatDate(), new Twig_SimpleFunction('accountGetMetaField', [AccountExtension::class, 'getMetaField']), + $this->hasRole(), ]; } @@ -101,9 +103,7 @@ class General extends Twig_Extension return new Twig_SimpleFunction( 'activeRoutePartialWhat', function ($context): string { - $args = \func_get_args(); - $route = $args[1]; // name of the route. - $what = $args[2]; // name of the route. + [, $route, $what] = \func_get_args(); $activeWhat = $context['what'] ?? false; if ($what === $activeWhat && !(false === stripos(Route::getCurrentRoute()->getName(), $route))) { @@ -235,6 +235,26 @@ class General extends Twig_Extension ); } + /** + * Will return true if the user is of role X. + * + * @return Twig_SimpleFunction + */ + protected function hasRole(): Twig_SimpleFunction + { + return new Twig_SimpleFunction( + 'hasRole', + function (string $role): bool { + $repository = app(UserRepositoryInterface::class); + if ($repository->hasRole(auth()->user(), $role)) { + return true; + } + + return false; + } + ); + } + /** * @return Twig_SimpleFilter */ diff --git a/app/Support/Twig/Translation.php b/app/Support/Twig/Translation.php index 74198da564..6aa55ceec3 100644 --- a/app/Support/Twig/Translation.php +++ b/app/Support/Twig/Translation.php @@ -70,6 +70,7 @@ class Translation extends Twig_Extension function (string $direction, string $original) { $key = sprintf('firefly.%s_%s', $original, $direction); $translation = trans($key); + if ($key === $translation) { return $original; } diff --git a/app/TransactionRules/Factory/TriggerFactory.php b/app/TransactionRules/Factory/TriggerFactory.php index 96caf8d78e..3bced52e35 100644 --- a/app/TransactionRules/Factory/TriggerFactory.php +++ b/app/TransactionRules/Factory/TriggerFactory.php @@ -89,7 +89,7 @@ class TriggerFactory /** @var AbstractTrigger $class */ $class = self::getTriggerClass($triggerType); $obj = $class::makeFromStrings($triggerValue, $stopProcessing); - Log::debug('Created trigger from string', ['type' => $triggerType, 'value' => $triggerValue, 'stopProcessing' => $stopProcessing, 'class' => $class]); + Log::debug('Created trigger from string', ['type' => $triggerType, 'value' => $triggerValue, 'stop_processing' => $stopProcessing, 'class' => $class]); return $obj; } diff --git a/app/TransactionRules/Processor.php b/app/TransactionRules/Processor.php index 14d44d0fc7..8ff61c2b46 100644 --- a/app/TransactionRules/Processor.php +++ b/app/TransactionRules/Processor.php @@ -38,7 +38,7 @@ use Log; /** * Class Processor. */ -final class Processor +class Processor { /** @var Collection Actions to exectute */ public $actions; @@ -56,90 +56,26 @@ final class Processor /** * Processor constructor. */ - private function __construct() + public function __construct() { $this->triggers = new Collection; $this->actions = new Collection; } /** - * This method will make a Processor that will process each transaction journal using the triggers - * and actions found in the given Rule. - * - * @param Rule $rule - * @param bool $includeActions - * - * @return Processor - * @throws \FireflyIII\Exceptions\FireflyException + * @return bool */ - public static function make(Rule $rule, bool $includeActions = null): Processor + public function isStrict(): bool { - $includeActions = $includeActions ?? true; - Log::debug(sprintf('Making new rule from Rule %d', $rule->id)); - Log::debug(sprintf('Rule is strict: %s', var_export($rule->strict, true))); - $self = new self; - $self->rule = $rule; - $self->strict = $rule->strict; - $triggerSet = $rule->ruleTriggers()->orderBy('order', 'ASC')->get(); - /** @var RuleTrigger $trigger */ - foreach ($triggerSet as $trigger) { - Log::debug(sprintf('Push trigger %d', $trigger->id)); - $self->triggers->push(TriggerFactory::getTrigger($trigger)); - } - if ($includeActions === true) { - $self->actions = $rule->ruleActions()->orderBy('order', 'ASC')->get(); - } - - return $self; + return $this->strict; } /** - * This method will make a Processor that will process each transaction journal using the given - * trigger (singular!). It can only report if the transaction journal was hit by the given trigger - * and will not be able to act on it using actions. - * - * @param string $triggerName - * @param string $triggerValue - * - * @return Processor - * - * @throws \FireflyIII\Exceptions\FireflyException + * @param bool $strict */ - public static function makeFromString(string $triggerName, string $triggerValue): Processor + public function setStrict(bool $strict): void { - Log::debug(sprintf('Processor::makeFromString("%s", "%s")', $triggerName, $triggerValue)); - $self = new self; - $trigger = TriggerFactory::makeTriggerFromStrings($triggerName, $triggerValue, false); - $self->triggers->push($trigger); - - return $self; - } - - /** - * This method will make a Processor that will process each transaction journal using the given - * triggers. It can only report if the transaction journal was hit by the given triggers - * and will not be able to act on it using actions. - * - * The given triggers must be in the following format: - * - * [type => xx, value => yy, stopProcessing => bool], [type => xx, value => yy, stopProcessing => bool], - * - * @param array $triggers - * - * @return Processor - * - * @throws \FireflyIII\Exceptions\FireflyException - */ - public static function makeFromStringArray(array $triggers): Processor - { - $self = new self; - foreach ($triggers as $entry) { - $entry['value'] = $entry['value'] ?? ''; - $trigger = TriggerFactory::makeTriggerFromStrings($entry['type'], $entry['value'], $entry['stopProcessing']); - $self->triggers->push($trigger); - } - - return $self; + $this->strict = $strict; } /** @@ -234,6 +170,73 @@ final class Processor return false; } + /** + * This method will make a Processor that will process each transaction journal using the triggers + * and actions found in the given Rule. + * + * @param Rule $rule + * @param bool $includeActions + * + * @throws \FireflyIII\Exceptions\FireflyException + */ + public function make(Rule $rule, bool $includeActions = null): void + { + $includeActions = $includeActions ?? true; + Log::debug(sprintf('Making new rule from Rule %d', $rule->id)); + Log::debug(sprintf('Rule is strict: %s', var_export($rule->strict, true))); + $this->rule = $rule; + $this->strict = $rule->strict; + $triggerSet = $rule->ruleTriggers()->orderBy('order', 'ASC')->get(); + /** @var RuleTrigger $trigger */ + foreach ($triggerSet as $trigger) { + Log::debug(sprintf('Push trigger %d', $trigger->id)); + $this->triggers->push(TriggerFactory::getTrigger($trigger)); + } + if (true === $includeActions) { + $this->actions = $rule->ruleActions()->orderBy('order', 'ASC')->get(); + } + } + + /** + * This method will make a Processor that will process each transaction journal using the given + * trigger (singular!). It can only report if the transaction journal was hit by the given trigger + * and will not be able to act on it using actions. + * + * @param string $triggerName + * @param string $triggerValue + * + * @throws \FireflyIII\Exceptions\FireflyException + */ + public function makeFromString(string $triggerName, string $triggerValue): void + { + Log::debug(sprintf('Processor::makeFromString("%s", "%s")', $triggerName, $triggerValue)); + $trigger = TriggerFactory::makeTriggerFromStrings($triggerName, $triggerValue, false); + $this->triggers->push($trigger); + } + + /** + * This method will make a Processor that will process each transaction journal using the given + * triggers. It can only report if the transaction journal was hit by the given triggers + * and will not be able to act on it using actions. + * + * The given triggers must be in the following format: + * + * [type => xx, value => yy, stop_processing => bool], [type => xx, value => yy, stop_processing => bool], + * + * @param array $triggers + * + * @throws \FireflyIII\Exceptions\FireflyException + */ + public function makeFromStringArray(array $triggers): void + { + foreach ($triggers as $entry) { + $entry['value'] = $entry['value'] ?? ''; + $trigger = TriggerFactory::makeTriggerFromStrings($entry['type'], $entry['value'], $entry['stop_processing']); + $this->triggers->push($trigger); + } + + } + /** * Run the actions * diff --git a/app/TransactionRules/TransactionMatcher.php b/app/TransactionRules/TransactionMatcher.php index b417311aae..cd901335ae 100644 --- a/app/TransactionRules/TransactionMatcher.php +++ b/app/TransactionRules/TransactionMatcher.php @@ -22,7 +22,7 @@ declare(strict_types=1); namespace FireflyIII\TransactionRules; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Models\Rule; use FireflyIII\Models\RuleTrigger; use FireflyIII\Models\Transaction; @@ -48,11 +48,18 @@ class TransactionMatcher private $range = 200; /** @var Rule The rule to apply */ private $rule; + /** @var bool */ + private $strict; /** @var array Types that can be matched using this matcher */ private $transactionTypes = [TransactionType::DEPOSIT, TransactionType::WITHDRAWAL, TransactionType::TRANSFER]; /** @var array List of triggers to match */ private $triggers = []; + public function __construct() + { + $this->strict = false; + } + /** * This method will search the user's transaction journal (with an upper limit of $range) for * transaction journals matching the given rule. This is accomplished by trying to fire these @@ -68,8 +75,10 @@ class TransactionMatcher } // Variables used within the loop - $processor = Processor::make($this->rule, false); - $result = $this->runProcessor($processor); + /** @var Processor $processor */ + $processor = app(Processor::class); + $processor->make($this->rule, false); + $result = $this->runProcessor($processor); // If the list of matchingTransactions is larger than the maximum number of results // (e.g. if a large percentage of the transactions match), truncate the list @@ -93,8 +102,11 @@ class TransactionMatcher } // Variables used within the loop - $processor = Processor::makeFromStringArray($this->triggers); - $result = $this->runProcessor($processor); + /** @var Processor $processor */ + $processor = app(Processor::class); + $processor->makeFromStringArray($this->triggers); + $processor->setStrict($this->strict); + $result = $this->runProcessor($processor); // If the list of matchingTransactions is larger than the maximum number of results // (e.g. if a large percentage of the transactions match), truncate the list @@ -175,6 +187,22 @@ class TransactionMatcher return $this; } + /** + * @return bool + */ + public function isStrict(): bool + { + return $this->strict; + } + + /** + * @param bool $strict + */ + public function setStrict(bool $strict): void + { + $this->strict = $strict; + } + /** * Set rule * @@ -195,15 +223,15 @@ class TransactionMatcher $allTriggers = $this->rule->ruleTriggers()->whereIn('trigger_type', $valid)->get(); /** @var RuleTrigger $trigger */ foreach ($allTriggers as $trigger) { - if ($trigger->trigger_type === 'amount_less') { + if ('amount_less' === $trigger->trigger_type) { $this->maxAmount = $trigger->trigger_value; Log::debug(sprintf('Set max amount to be %s', $trigger->trigger_value)); } - if ($trigger->trigger_type === 'amount_more') { + if ('amount_more' === $trigger->trigger_type) { $this->minAmount = $trigger->trigger_value; Log::debug(sprintf('Set min amount to be %s', $trigger->trigger_value)); } - if ($trigger->trigger_type === 'amount_exactly') { + if ('amount_exactly' === $trigger->trigger_type) { $this->exactAmount = $trigger->trigger_value; Log::debug(sprintf('Set exact amount to be %s', $trigger->trigger_value)); } @@ -236,8 +264,8 @@ class TransactionMatcher $result = new Collection(); do { // Fetch a batch of transactions from the database - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setUser(auth()->user()); $collector->setAllAssetAccounts()->setLimit($pageSize)->setPage($page)->setTypes($this->transactionTypes); if (null !== $this->maxAmount) { @@ -254,7 +282,7 @@ class TransactionMatcher } - $set = $collector->getPaginatedJournals(); + $set = $collector->getPaginatedTransactions(); Log::debug(sprintf('Found %d journals to check. ', $set->count())); // Filter transactions that match the given triggers. diff --git a/app/Transformers/AccountTransformer.php b/app/Transformers/AccountTransformer.php index 64ec626911..f22bbf6bf5 100644 --- a/app/Transformers/AccountTransformer.php +++ b/app/Transformers/AccountTransformer.php @@ -25,7 +25,7 @@ namespace FireflyIII\Transformers; use Carbon\Carbon; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Models\Account; use FireflyIII\Models\AccountType; use FireflyIII\Models\TransactionCurrency; @@ -104,7 +104,7 @@ class AccountTransformer extends TransformerAbstract $pageSize = (int)app('preferences')->getForUser($account->user, 'listPageSize', 50)->data; // journals always use collector and limited using URL parameters. - $collector = app(JournalCollectorInterface::class); + $collector = app(TransactionCollectorInterface::class); $collector->setUser($account->user); $collector->withOpposingAccount()->withCategoryInformation()->withBudgetInformation(); if ($account->accountType->type === AccountType::ASSET) { @@ -116,9 +116,9 @@ class AccountTransformer extends TransformerAbstract $collector->setRange($this->parameters->get('start'), $this->parameters->get('end')); } $collector->setLimit($pageSize)->setPage($this->parameters->get('page')); - $journals = $collector->getJournals(); + $transactions = $collector->getTransactions(); - return $this->collection($journals, new TransactionTransformer($this->parameters), 'transactions'); + return $this->collection($transactions, new TransactionTransformer($this->parameters), 'transactions'); } /** @@ -148,7 +148,7 @@ class AccountTransformer extends TransformerAbstract $type = $account->accountType->type; $role = $this->repository->getMetaValue($account, 'accountRole'); - if ($type !== AccountType::ASSET || (string)$role === '') { + if ($type !== AccountType::ASSET || '' === (string)$role) { $role = null; } $currencyId = (int)$this->repository->getMetaValue($account, 'currency_id'); @@ -167,13 +167,13 @@ class AccountTransformer extends TransformerAbstract $date = $this->parameters->get('date'); } - if ($currencyId === 0) { + if (0 === $currencyId) { $currencyId = null; } $monthlyPaymentDate = null; $creditCardType = null; - if ($role === 'ccAsset' && $type === AccountType::ASSET) { + if ('ccAsset' === $role && $type === AccountType::ASSET) { $creditCardType = $this->repository->getMetaValue($account, 'ccType'); $monthlyPaymentDate = $this->repository->getMetaValue($account, 'ccMonthlyPaymentDate'); } @@ -194,7 +194,7 @@ class AccountTransformer extends TransformerAbstract 'updated_at' => $account->updated_at->toAtomString(), 'created_at' => $account->created_at->toAtomString(), 'name' => $account->name, - 'active' => (int)$account->active === 1, + 'active' => 1 === (int)$account->active, 'type' => $type, 'currency_id' => $currencyId, 'currency_code' => $currencyCode, diff --git a/app/Transformers/BillTransformer.php b/app/Transformers/BillTransformer.php index 2654d304d8..acc49db569 100644 --- a/app/Transformers/BillTransformer.php +++ b/app/Transformers/BillTransformer.php @@ -24,7 +24,7 @@ declare(strict_types=1); namespace FireflyIII\Transformers; use Carbon\Carbon; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Models\Bill; use FireflyIII\Repositories\Bill\BillRepositoryInterface; use Illuminate\Support\Collection; @@ -129,7 +129,7 @@ class BillTransformer extends TransformerAbstract $pageSize = (int)app('preferences')->getForUser($bill->user, 'listPageSize', 50)->data; // journals always use collector and limited using URL parameters. - $collector = app(JournalCollectorInterface::class); + $collector = app(TransactionCollectorInterface::class); $collector->setUser($bill->user); $collector->withOpposingAccount()->withCategoryInformation()->withBudgetInformation(); $collector->setAllAssetAccounts(); @@ -138,9 +138,9 @@ class BillTransformer extends TransformerAbstract $collector->setRange($this->parameters->get('start'), $this->parameters->get('end')); } $collector->setLimit($pageSize)->setPage($this->parameters->get('page')); - $journals = $collector->getJournals(); + $transactions = $collector->getTransactions(); - return $this->collection($journals, new TransactionTransformer($this->parameters), 'transactions'); + return $this->collection($transactions, new TransactionTransformer($this->parameters), 'transactions'); } /** @@ -174,6 +174,7 @@ class BillTransformer extends TransformerAbstract 'name' => $bill->name, 'currency_id' => $bill->transaction_currency_id, 'currency_code' => $bill->transactionCurrency->code, + 'currency_symbol' => $bill->transactionCurrency->symbol, 'amount_min' => round((float)$bill->amount_min, 2), 'amount_max' => round((float)$bill->amount_max, 2), 'date' => $bill->date->format('Y-m-d'), diff --git a/app/Transformers/BudgetTransformer.php b/app/Transformers/BudgetTransformer.php index 4b9562292a..09fe78c2d7 100644 --- a/app/Transformers/BudgetTransformer.php +++ b/app/Transformers/BudgetTransformer.php @@ -24,7 +24,7 @@ declare(strict_types=1); namespace FireflyIII\Transformers; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Models\Budget; use Illuminate\Support\Collection; use League\Fractal\Resource\Collection as FractalCollection; @@ -78,7 +78,7 @@ class BudgetTransformer extends TransformerAbstract $pageSize = (int)app('preferences')->getForUser($budget->user, 'listPageSize', 50)->data; // journals always use collector and limited using URL parameters. - $collector = app(JournalCollectorInterface::class); + $collector = app(TransactionCollectorInterface::class); $collector->setUser($budget->user); $collector->withOpposingAccount()->withCategoryInformation()->withBudgetInformation(); $collector->setAllAssetAccounts(); @@ -87,9 +87,9 @@ class BudgetTransformer extends TransformerAbstract $collector->setRange($this->parameters->get('start'), $this->parameters->get('end')); } $collector->setLimit($pageSize)->setPage($this->parameters->get('page')); - $journals = $collector->getJournals(); + $transactions = $collector->getTransactions(); - return $this->collection($journals, new TransactionTransformer($this->parameters), 'transactions'); + return $this->collection($transactions, new TransactionTransformer($this->parameters), 'transactions'); } /** @@ -118,7 +118,7 @@ class BudgetTransformer extends TransformerAbstract 'id' => (int)$budget->id, 'updated_at' => $budget->updated_at->toAtomString(), 'created_at' => $budget->created_at->toAtomString(), - 'active' => (int)$budget->active === 1, + 'active' => 1 === (int)$budget->active, 'name' => $budget->name, 'links' => [ [ diff --git a/app/Transformers/CategoryTransformer.php b/app/Transformers/CategoryTransformer.php index 421946e6b2..6282a1b48b 100644 --- a/app/Transformers/CategoryTransformer.php +++ b/app/Transformers/CategoryTransformer.php @@ -24,7 +24,7 @@ declare(strict_types=1); namespace FireflyIII\Transformers; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Models\Category; use Illuminate\Support\Collection; use League\Fractal\Resource\Collection as FractalCollection; @@ -78,7 +78,7 @@ class CategoryTransformer extends TransformerAbstract $pageSize = (int)app('preferences')->getForUser($category->user, 'listPageSize', 50)->data; // journals always use collector and limited using URL parameters. - $collector = app(JournalCollectorInterface::class); + $collector = app(TransactionCollectorInterface::class); $collector->setUser($category->user); $collector->withOpposingAccount()->withCategoryInformation()->withCategoryInformation(); $collector->setAllAssetAccounts(); @@ -87,9 +87,9 @@ class CategoryTransformer extends TransformerAbstract $collector->setRange($this->parameters->get('start'), $this->parameters->get('end')); } $collector->setLimit($pageSize)->setPage($this->parameters->get('page')); - $journals = $collector->getJournals(); + $transactions = $collector->getTransactions(); - return $this->collection($journals, new TransactionTransformer($this->parameters), 'transactions'); + return $this->collection($transactions, new TransactionTransformer($this->parameters), 'transactions'); } /** diff --git a/app/Transformers/CurrencyExchangeRateTransformer.php b/app/Transformers/CurrencyExchangeRateTransformer.php index e1c8ebc0e6..e9dbd007dc 100644 --- a/app/Transformers/CurrencyExchangeRateTransformer.php +++ b/app/Transformers/CurrencyExchangeRateTransformer.php @@ -29,6 +29,9 @@ use League\Fractal\Resource\Item; use League\Fractal\TransformerAbstract; use Symfony\Component\HttpFoundation\ParameterBag; +/** + * Class CurrencyExchangeRateTransformer + */ class CurrencyExchangeRateTransformer extends TransformerAbstract { /** @@ -79,6 +82,11 @@ class CurrencyExchangeRateTransformer extends TransformerAbstract return $this->item($rate->toCurrency, new CurrencyTransformer($this->parameters), 'transaction_currencies'); } + /** + * @param CurrencyExchangeRate $rate + * + * @return array + */ public function transform(CurrencyExchangeRate $rate): array { $data = [ diff --git a/app/Transformers/JournalLinkTransformer.php b/app/Transformers/JournalLinkTransformer.php index f05d6807b8..43e5f386dd 100644 --- a/app/Transformers/JournalLinkTransformer.php +++ b/app/Transformers/JournalLinkTransformer.php @@ -24,7 +24,7 @@ declare(strict_types=1); namespace FireflyIII\Transformers; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Models\Note; use FireflyIII\Models\TransactionJournalLink; use Illuminate\Support\Collection; @@ -75,12 +75,12 @@ class JournalLinkTransformer extends TransformerAbstract { // need to use the collector to get the transaction :( // journals always use collector and limited using URL parameters. - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setUser($link->source->user); $collector->withOpposingAccount()->withCategoryInformation()->withBudgetInformation(); $collector->setJournals(new Collection([$link->source])); - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); return $this->item($transactions->first(), new TransactionTransformer($this->parameters), 'transactions'); } @@ -104,12 +104,12 @@ class JournalLinkTransformer extends TransformerAbstract { // need to use the collector to get the transaction :( // journals always use collector and limited using URL parameters. - /** @var JournalCollectorInterface $collector */ - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setUser($link->source->user); $collector->withOpposingAccount()->withCategoryInformation()->withBudgetInformation(); $collector->setJournals(new Collection([$link->destination])); - $transactions = $collector->getJournals(); + $transactions = $collector->getTransactions(); return $this->item($transactions->first(), new TransactionTransformer($this->parameters), 'transactions'); } diff --git a/app/Transformers/JournalMetaTransformer.php b/app/Transformers/JournalMetaTransformer.php index 6f54be2e2e..125304b05b 100644 --- a/app/Transformers/JournalMetaTransformer.php +++ b/app/Transformers/JournalMetaTransformer.php @@ -24,7 +24,7 @@ declare(strict_types=1); namespace FireflyIII\Transformers; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Models\TransactionJournalMeta; use Illuminate\Support\Collection; use League\Fractal\Resource\Collection as FractalCollection; @@ -78,7 +78,7 @@ class JournalMetaTransformer extends TransformerAbstract $pageSize = (int)app('preferences')->getForUser($journal->user, 'listPageSize', 50)->data; // journals always use collector and limited using URL parameters. - $collector = app(JournalCollectorInterface::class); + $collector = app(TransactionCollectorInterface::class); $collector->setUser($journal->user); $collector->withOpposingAccount()->withCategoryInformation()->withCategoryInformation(); $collector->setAllAssetAccounts(); @@ -87,9 +87,9 @@ class JournalMetaTransformer extends TransformerAbstract $collector->setRange($this->parameters->get('start'), $this->parameters->get('end')); } $collector->setLimit($pageSize)->setPage($this->parameters->get('page')); - $journals = $collector->getJournals(); + $transactions= $collector->getTransactions(); - return $this->collection($journals, new TransactionTransformer($this->parameters), 'transactions'); + return $this->collection($transactions, new TransactionTransformer($this->parameters), 'transactions'); } /** diff --git a/app/Transformers/PiggyBankEventTransformer.php b/app/Transformers/PiggyBankEventTransformer.php index 44b60aad55..5999421b30 100644 --- a/app/Transformers/PiggyBankEventTransformer.php +++ b/app/Transformers/PiggyBankEventTransformer.php @@ -24,7 +24,7 @@ declare(strict_types=1); namespace FireflyIII\Transformers; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Models\PiggyBankEvent; use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface; @@ -95,7 +95,8 @@ class PiggyBankEventTransformer extends TransformerAbstract $pageSize = (int)app('preferences')->getForUser($journal->user, 'listPageSize', 50)->data; // journals always use collector and limited using URL parameters. - $collector = app(JournalCollectorInterface::class); + /** @var TransactionCollectorInterface $collector */ + $collector = app(TransactionCollectorInterface::class); $collector->setUser($journal->user); $collector->withOpposingAccount()->withCategoryInformation()->withCategoryInformation(); $collector->setAllAssetAccounts(); @@ -104,9 +105,9 @@ class PiggyBankEventTransformer extends TransformerAbstract $collector->setRange($this->parameters->get('start'), $this->parameters->get('end')); } $collector->setLimit($pageSize)->setPage($this->parameters->get('page')); - $journals = $collector->getJournals(); + $transactions = $collector->getTransactions(); - return $this->item($journals->first(), new TransactionTransformer($this->parameters), 'transactions'); + return $this->item($transactions->first(), new TransactionTransformer($this->parameters), 'transactions'); } /** @@ -129,7 +130,8 @@ class PiggyBankEventTransformer extends TransformerAbstract /** @var CurrencyRepositoryInterface $repository */ $repository = app(CurrencyRepositoryInterface::class); $repository->setUser($account->user); - $currency = $repository->findNull($currencyId); + $currency = $repository->findNull($currencyId); + /** @noinspection NullPointerExceptionInspection */ $decimalPlaces = $currency->decimal_places; } diff --git a/app/Transformers/PiggyBankTransformer.php b/app/Transformers/PiggyBankTransformer.php index 897e969b7e..7490b89f02 100644 --- a/app/Transformers/PiggyBankTransformer.php +++ b/app/Transformers/PiggyBankTransformer.php @@ -170,7 +170,7 @@ class PiggyBankTransformer extends TransformerAbstract 'start_date' => $startDate, 'target_date' => $targetDate, 'order' => (int)$piggyBank->order, - 'active' => (int)$piggyBank->active === 1, + 'active' => true, 'notes' => null, 'links' => [ [ diff --git a/app/Transformers/PreferenceTransformer.php b/app/Transformers/PreferenceTransformer.php index 033b682050..254c11bf3c 100644 --- a/app/Transformers/PreferenceTransformer.php +++ b/app/Transformers/PreferenceTransformer.php @@ -28,6 +28,9 @@ use FireflyIII\Models\Preference; use League\Fractal\TransformerAbstract; use Symfony\Component\HttpFoundation\ParameterBag; +/** + * Class PreferenceTransformer + */ class PreferenceTransformer extends TransformerAbstract { /** @@ -45,6 +48,11 @@ class PreferenceTransformer extends TransformerAbstract /** @var ParameterBag */ protected $parameters; + /** + * PreferenceTransformer constructor. + * + * @param ParameterBag $parameters + */ public function __construct(ParameterBag $parameters) { $this->parameters = $parameters; diff --git a/app/Transformers/TagTransformer.php b/app/Transformers/TagTransformer.php index 19ce472453..09f1ac8264 100644 --- a/app/Transformers/TagTransformer.php +++ b/app/Transformers/TagTransformer.php @@ -24,7 +24,7 @@ declare(strict_types=1); namespace FireflyIII\Transformers; -use FireflyIII\Helpers\Collector\JournalCollectorInterface; +use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Models\Tag; use League\Fractal\Resource\Collection as FractalCollection; use League\Fractal\Resource\Item; @@ -77,7 +77,7 @@ class TagTransformer extends TransformerAbstract $pageSize = (int)app('preferences')->getForUser($tag->user, 'listPageSize', 50)->data; // journals always use collector and limited using URL parameters. - $collector = app(JournalCollectorInterface::class); + $collector = app(TransactionCollectorInterface::class); $collector->setUser($tag->user); $collector->withOpposingAccount()->withCategoryInformation()->withCategoryInformation(); $collector->setAllAssetAccounts(); @@ -86,9 +86,9 @@ class TagTransformer extends TransformerAbstract $collector->setRange($this->parameters->get('start'), $this->parameters->get('end')); } $collector->setLimit($pageSize)->setPage($this->parameters->get('page')); - $journals = $collector->getJournals(); + $transactions = $collector->getTransactions(); - return $this->collection($journals, new TransactionTransformer($this->parameters), 'transactions'); + return $this->collection($transactions, new TransactionTransformer($this->parameters), 'transactions'); } /** @@ -121,7 +121,7 @@ class TagTransformer extends TransformerAbstract 'tag' => $tag->tag, 'tag_mode' => $tag->tagMode, 'date' => $date, - 'description' => $tag->description === '' ? null : $tag->description, + 'description' => '' === $tag->description ? null : $tag->description, 'latitude' => (float)$tag->latitude, 'longitude' => (float)$tag->longitude, 'zoom_level' => (int)$tag->zoomLevel, diff --git a/app/Transformers/UserTransformer.php b/app/Transformers/UserTransformer.php index dadb23fb94..cca96ecedd 100644 --- a/app/Transformers/UserTransformer.php +++ b/app/Transformers/UserTransformer.php @@ -193,7 +193,7 @@ class UserTransformer extends TransformerAbstract 'updated_at' => $user->updated_at->toAtomString(), 'created_at' => $user->created_at->toAtomString(), 'email' => $user->email, - 'blocked' => (int)$user->blocked === 1, + 'blocked' => 1 === (int)$user->blocked, 'blocked_code' => $user->blocked_code, 'role' => $role, 'links' => [ diff --git a/app/User.php b/app/User.php index c8ff6c43c6..17a64c29c9 100644 --- a/app/User.php +++ b/app/User.php @@ -61,7 +61,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; * @property bool $isAdmin used in admin user controller. * @property bool $has2FA used in admin user controller. * @property array $prefs used in admin user controller. - * @property string password + * @property string password * @property Collection roles * @property string blocked_code * @property bool blocked @@ -216,7 +216,7 @@ class User extends Authenticatable { $bytes = random_bytes(16); - return (string)bin2hex($bytes); + return bin2hex($bytes); } /** @@ -302,7 +302,7 @@ class User extends Authenticatable * * @param string $token */ - public function sendPasswordResetNotification($token) + public function sendPasswordResetNotification($token): void { $ipAddress = Request::ip(); diff --git a/app/Validation/FireflyValidator.php b/app/Validation/FireflyValidator.php index 1034b1ef63..c16c7430ba 100644 --- a/app/Validation/FireflyValidator.php +++ b/app/Validation/FireflyValidator.php @@ -38,7 +38,9 @@ use FireflyIII\TransactionRules\Triggers\TriggerInterface; use FireflyIII\User; use Google2FA; use Illuminate\Contracts\Encryption\DecryptException; +use Illuminate\Support\Collection; use Illuminate\Validation\Validator; +use Log; /** * Class FireflyValidator. @@ -195,12 +197,12 @@ class FireflyValidator extends Validator * * @return bool */ - public function validateMore($attribute, $value, $parameters): bool + public function validateLess($attribute, $value, $parameters): bool { /** @var mixed $compare */ $compare = $parameters[0] ?? '0'; - return bccomp((string)$value, (string)$compare) > 0; + return bccomp((string)$value, (string)$compare) < 0; } /** @@ -211,15 +213,14 @@ class FireflyValidator extends Validator * * @return bool */ - public function validateLess($attribute, $value, $parameters): bool + public function validateMore($attribute, $value, $parameters): bool { /** @var mixed $compare */ $compare = $parameters[0] ?? '0'; - return bccomp((string)$value, (string)$compare) < 0; + return bccomp((string)$value, (string)$compare) > 0; } - /** * @SuppressWarnings(PHPMD.UnusedFormalParameter) * @@ -242,152 +243,108 @@ class FireflyValidator extends Validator } /** - * @param $attribute + * @param string $attribute + * + * @param string $value * * @return bool */ - public function validateRuleActionValue($attribute): bool + public function validateRuleActionValue(string $attribute, string $value): bool { - // get the index from a string like "rule-action-value.2". + // first, get the index from this string: $parts = explode('.', $attribute); - $index = $parts[\count($parts) - 1]; - if ($index === 'value') { - // user is coming from API. - $index = $parts[\count($parts) - 2]; - } - $index = (int)$index; + $index = (int)($parts[1] ?? '0'); - // get actions from $this->data - $actions = []; - if (isset($this->data['rule-action']) && \is_array($this->data['rule-action'])) { - $actions = $this->data['rule-action']; - } - if (isset($this->data['rule-actions']) && \is_array($this->data['rule-actions'])) { - $actions = $this->data['rule-actions']; + // get the name of the trigger from the data array: + $actionType = $this->data['rule_actions'][$index]['name'] ?? 'invalid'; + + // if it's "invalid" return false. + if ('invalid' === $actionType) { + return false; } + // if it's set_budget, verify the budget name: + if ('set_budget' === $actionType) { + /** @var BudgetRepositoryInterface $repository */ + $repository = app(BudgetRepositoryInterface::class); + $budgets = $repository->getBudgets(); + // count budgets, should have at least one + $count = $budgets->filter( + function (Budget $budget) use ($value) { + return $budget->name === $value; + } + )->count(); - // loop all rule-actions. - // check if rule-action-value matches the thing. - if (\is_array($actions)) { - $name = $this->getRuleActionName($index); - $value = $this->getRuleActionValue($index); - switch ($name) { - default: - - return true; - case 'set_budget': - /** @var BudgetRepositoryInterface $repository */ - $repository = app(BudgetRepositoryInterface::class); - $budgets = $repository->getBudgets(); - // count budgets, should have at least one - $count = $budgets->filter( - function (Budget $budget) use ($value) { - return $budget->name === $value; - } - )->count(); - - return 1 === $count; - case 'link_to_bill': - /** @var BillRepositoryInterface $repository */ - $repository = app(BillRepositoryInterface::class); - $bill = $repository->findByName((string)$value); - - return null !== $bill; - case 'invalid': - return false; - } + return 1 === $count; } - return false; + // if it's link to bill, verify the name of the bill. + if ('link_to_bill' === $actionType) { + /** @var BillRepositoryInterface $repository */ + $repository = app(BillRepositoryInterface::class); + $bill = $repository->findByName($value); + + return null !== $bill; + } + + // return true for the rest. + return true; } /** - * @param $attribute + * $attribute has the format rule_triggers.%d.value. + * + * @param string $attribute + * @param string $value * * @return bool */ - public function validateRuleTriggerValue($attribute): bool + public function validateRuleTriggerValue(string $attribute, string $value): bool { - // get the index from a string like "rule-trigger-value.2". + // + + // first, get the index from this string: $parts = explode('.', $attribute); - $index = $parts[\count($parts) - 1]; - // if the index is not a number, then we might be dealing with an API $attribute - // which is formatted "rule-triggers.0.value" - if ($index === 'value') { - $index = $parts[\count($parts) - 2]; - } - $index = (int)$index; + $index = (int)($parts[1] ?? '0'); - // get triggers from $this->data - $triggers = []; - if (isset($this->data['rule-trigger']) && \is_array($this->data['rule-trigger'])) { - $triggers = $this->data['rule-trigger']; - } - if (isset($this->data['rule-triggers']) && \is_array($this->data['rule-triggers'])) { - $triggers = $this->data['rule-triggers']; + // get the name of the trigger from the data array: + $triggerType = $this->data['rule_triggers'][$index]['name'] ?? 'invalid'; + + // invalid always returns false: + if ('invalid' === $triggerType) { + return false; } - // loop all rule-triggers. - // check if rule-value matches the thing. - if (\is_array($triggers)) { - $name = $this->getRuleTriggerName($index); - $value = $this->getRuleTriggerValue($index); - - // break on some easy checks: - switch ($name) { - case 'amount_less': - case 'amount_more': - case 'amount_exactly': - $result = is_numeric($value); - if (false === $result) { - return false; - } - break; - case 'from_account_starts': - case 'from_account_ends': - case 'from_account_is': - case 'from_account_contains': - case 'to_account_starts': - case 'to_account_ends': - case 'to_account_is': - case 'to_account_contains': - case 'description_starts': - case 'description_ends': - case 'description_contains': - case 'description_is': - case 'category_is': - case 'budget_is': - case 'tag_is': - case 'currency_is': - case 'notes_contain': - case 'notes_start': - case 'notes_end': - case 'notes_are': - return \strlen($value) > 0; - - break; - case 'transaction_type': - $count = TransactionType::where('type', $value)->count(); - if (!(1 === $count)) { - return false; - } - break; - case 'invalid': - return false; - } - // still a special case where the trigger is - // triggered in such a way that it would trigger ANYTHING. We can check for such things - // with function willmatcheverything - // we know which class it is so dont bother checking that. - $classes = Config::get('firefly.rule-triggers'); - /** @var TriggerInterface $class */ - $class = $classes[$name]; - - return !$class::willMatchEverything($value); + // these trigger types need a numerical check: + $numerical = ['amount_less', 'amount_more', 'amount_exactly']; + if (\in_array($triggerType, $numerical, true)) { + return is_numeric($value); } - return false; + // these trigger types need a simple strlen check: + $length = ['from_account_starts', 'from_account_ends', 'from_account_is', 'from_account_contains', 'to_account_starts', 'to_account_ends', + 'to_account_is', 'to_account_contains', 'description_starts', 'description_ends', 'description_contains', 'description_is', 'category_is', + 'budget_is', 'tag_is', 'currency_is', 'notes_contain', 'notes_start', 'notes_end', 'notes_are',]; + if (\in_array($triggerType, $length, true)) { + return '' !== $value; + } + + // check transaction type. + if ('transaction_type' === $triggerType) { + $count = TransactionType::where('type', $value)->count(); + + return 1 !== $count; + } + + // and finally a "will match everything check": + $classes = app('config')->get('firefly.rule-triggers'); + /** @var TriggerInterface $class */ + $class = $classes[$triggerType] ?? false; + if (false === $class) { + return false; + } + + return !$class::willMatchEverything($value); } /** @@ -457,7 +414,7 @@ class FireflyValidator extends Validator public function validateUniqueAccountNumberForUser($attribute, $value, $parameters): bool { $accountId = (int)($this->data['id'] ?? 0.0); - if ($accountId === 0) { + if (0 === $accountId) { $accountId = (int)($parameters[0] ?? 0.0); } @@ -466,9 +423,9 @@ class FireflyValidator extends Validator ->where('accounts.user_id', auth()->user()->id) ->where('account_meta.name', 'accountNumber'); - if ((int)$accountId > 0) { + if ($accountId > 0) { // exclude current account from check. - $query->where('account_meta.account_id', '!=', (int)$accountId); + $query->where('account_meta.account_id', '!=', $accountId); } $set = $query->get(['account_meta.*']); @@ -499,9 +456,7 @@ class FireflyValidator extends Validator public function validateUniqueObjectForUser($attribute, $value, $parameters): bool { $value = $this->tryDecrypt($value); - // exclude? - $table = $parameters[0]; - $field = $parameters[1]; + [$table, $field] = $parameters; $exclude = (int)($parameters[2] ?? 0.0); /* @@ -560,66 +515,6 @@ class FireflyValidator extends Validator return true; } - /** - * @param int $index - * - * @return string - */ - private function getRuleActionName(int $index): string - { - $name = $this->data['rule-action'][$index] ?? 'invalid'; - if (!isset($this->data['rule-action'][$index])) { - $name = $this->data['rule-actions'][$index]['name'] ?? 'invalid'; - } - - return $name; - } - - /** - * @param int $index - * - * @return string - */ - private function getRuleActionValue(int $index): string - { - $value = $this->data['rule-action-value'][$index] ?? ''; - if (!isset($this->data['rule-action-value'][$index])) { - $value = $this->data['rule-actions'][$index]['value'] ?? ''; - } - - return $value; - } - - /** - * @param int $index - * - * @return string - */ - private function getRuleTriggerName(int $index): string - { - $name = $this->data['rule-trigger'][$index] ?? 'invalid'; - if (!isset($this->data['rule-trigger'][$index])) { - $name = $this->data['rule-triggers'][$index]['name'] ?? 'invalid'; - } - - return $name; - } - - /** - * @param int $index - * - * @return string - */ - private function getRuleTriggerValue(int $index): string - { - $value = $this->data['rule-trigger-value'][$index] ?? ''; - if (!isset($this->data['rule-trigger-value'][$index])) { - $value = $this->data['rule-triggers'][$index]['value'] ?? ''; - } - - return $value; - } - /** * @param $value * @@ -630,7 +525,7 @@ class FireflyValidator extends Validator try { $value = Crypt::decrypt($value); } catch (DecryptException $e) { - // do not care. + Log::debug(sprintf('Could not decrypt. %s', $e->getMessage())); } return $value; @@ -674,6 +569,7 @@ class FireflyValidator extends Validator $ignore = $existingAccount->id; $value = $this->tryDecrypt($value); + /** @var Collection $set */ $set = auth()->user()->accounts()->where('account_type_id', $type->id)->where('id', '!=', $ignore)->get(); /** @var Account $entry */ foreach ($set as $entry) { @@ -697,6 +593,7 @@ class FireflyValidator extends Validator $ignore = (int)($parameters[0] ?? 0.0); $value = $this->tryDecrypt($value); + /** @var Collection $set */ $set = auth()->user()->accounts()->where('account_type_id', $type->id)->where('id', '!=', $ignore)->get(); /** @var Account $entry */ foreach ($set as $entry) { @@ -717,11 +614,14 @@ class FireflyValidator extends Validator */ private function validateByAccountTypeString(string $value, array $parameters, string $type): bool { - $search = Config::get('firefly.accountTypeByIdentifier.' . $type); - $accountType = AccountType::whereType($search)->first(); - $ignore = (int)($parameters[0] ?? 0.0); - - $set = auth()->user()->accounts()->where('account_type_id', $accountType->id)->where('id', '!=', $ignore)->get(); + /** @var array $search */ + $search = Config::get('firefly.accountTypeByIdentifier.' . $type); + /** @var Collection $accountTypes */ + $accountTypes = AccountType::whereIn('type', $search)->get(); + $ignore = (int)($parameters[0] ?? 0.0); + $accountTypeIds = $accountTypes->pluck('id')->toArray(); + /** @var Collection $set */ + $set = auth()->user()->accounts()->whereIn('account_type_id', $accountTypeIds)->where('id', '!=', $ignore)->get(); /** @var Account $entry */ foreach ($set as $entry) { if ($entry->name === $value) { diff --git a/app/Validation/RecurrenceValidation.php b/app/Validation/RecurrenceValidation.php index 98ad0d0342..91e4cc47de 100644 --- a/app/Validation/RecurrenceValidation.php +++ b/app/Validation/RecurrenceValidation.php @@ -27,6 +27,7 @@ use Carbon\Carbon; use Exception; use Illuminate\Validation\Validator; use InvalidArgumentException; +use Log; /** * Trait RecurrenceValidation @@ -36,6 +37,39 @@ use InvalidArgumentException; */ trait RecurrenceValidation { + /** + * Adds an error to the validator when there are no repetitions in the array of data. + * + * @param Validator $validator + */ + public function validateOneRepetition(Validator $validator): void + { + $data = $validator->getData(); + $repetitions = $data['repetitions'] ?? []; + // need at least one transaction + if (0 === \count($repetitions)) { + $validator->errors()->add('description', (string)trans('validation.at_least_one_repetition')); + } + } + + /** + * Validates that the recurrence has valid repetition information. It either doesn't stop, + * or stops after X times or at X date. Not both of them., + * + * @param Validator $validator + */ + public function validateRecurrenceRepetition(Validator $validator): void + { + $data = $validator->getData(); + $repetitions = $data['nr_of_repetitions'] ?? null; + $repeatUntil = $data['repeat_until'] ?? null; + if (null !== $repetitions && null !== $repeatUntil) { + // expect a date OR count: + $validator->errors()->add('repeat_until', (string)trans('validation.require_repeat_until')); + $validator->errors()->add('nr_of_repetitions', (string)trans('validation.require_repeat_until')); + } + } + /** * @param Validator $validator */ @@ -72,39 +106,6 @@ trait RecurrenceValidation } } - /** - * Adds an error to the validator when there are no repetitions in the array of data. - * - * @param Validator $validator - */ - public function validateOneRepetition(Validator $validator): void - { - $data = $validator->getData(); - $repetitions = $data['repetitions'] ?? []; - // need at least one transaction - if (\count($repetitions) === 0) { - $validator->errors()->add('description', (string)trans('validation.at_least_one_repetition')); - } - } - - /** - * Validates that the recurrence has valid repetition information. It either doesn't stop, - * or stops after X times or at X date. Not both of them., - * - * @param Validator $validator - */ - public function validateRecurrenceRepetition(Validator $validator): void - { - $data = $validator->getData(); - $repetitions = $data['nr_of_repetitions'] ?? null; - $repeatUntil = $data['repeat_until'] ?? null; - if (null !== $repetitions && null !== $repeatUntil) { - // expect a date OR count: - $validator->errors()->add('repeat_until', (string)trans('validation.require_repeat_until')); - $validator->errors()->add('nr_of_repetitions', (string)trans('validation.require_repeat_until')); - } - } - /** * If the repetition type is daily, the moment should be empty. * @@ -144,7 +145,7 @@ trait RecurrenceValidation protected function validateNdom(Validator $validator, int $index, string $moment): void { $parameters = explode(',', $moment); - if (\count($parameters) !== 2) { + if (2 !== \count($parameters)) { $validator->errors()->add(sprintf('repetitions.%d.moment', $index), (string)trans('validation.valid_recurrence_rep_moment')); return; @@ -187,6 +188,7 @@ trait RecurrenceValidation try { Carbon::createFromFormat('Y-m-d', $moment); } catch (InvalidArgumentException|Exception $e) { + Log::debug(sprintf('Invalid argument for Carbon: %s', $e->getMessage())); $validator->errors()->add(sprintf('repetitions.%d.moment', $index), (string)trans('validation.valid_recurrence_rep_moment')); } } diff --git a/app/Validation/TransactionValidation.php b/app/Validation/TransactionValidation.php index 0c874afd03..cac645acdd 100644 --- a/app/Validation/TransactionValidation.php +++ b/app/Validation/TransactionValidation.php @@ -112,10 +112,10 @@ trait TransactionValidation { $data = $validator->getData(); $transactions = $data['transactions'] ?? []; - $journalDescription = (string)($data['description'] ?? ''); + $journalDescription = (string)($data['description'] ?? null); $validDescriptions = 0; foreach ($transactions as $index => $transaction) { - if (\strlen((string)($transaction['description'] ?? '')) > 0) { + if (\strlen((string)($transaction['description'] ?? null)) > 0) { $validDescriptions++; } } @@ -157,9 +157,9 @@ trait TransactionValidation { $data = $validator->getData(); $transactions = $data['transactions'] ?? []; - $journalDescription = (string)($data['description'] ?? ''); + $journalDescription = (string)($data['description'] ?? null); foreach ($transactions as $index => $transaction) { - $description = (string)($transaction['description'] ?? ''); + $description = (string)($transaction['description'] ?? null); // description cannot be equal to journal description. if ($description === $journalDescription) { $validator->errors()->add('transactions.' . $index . '.description', (string)trans('validation.equal_description')); @@ -249,7 +249,7 @@ trait TransactionValidation $data = $validator->getData(); $transactions = $data['transactions'] ?? []; foreach ($transactions as $index => $transaction) { - $description = (string)($transaction['description'] ?? ''); + $description = (string)($transaction['description'] ?? null); // filled description is mandatory for split transactions. if ('' === $description && \count($transactions) > 1) { $validator->errors()->add( @@ -338,13 +338,13 @@ trait TransactionValidation if ($accountId < 1 && '' === $accountName) { return null; } - if ($accountId !== 0) { + if (0 !== $accountId) { // ID belongs to user and is $type account: /** @var AccountRepositoryInterface $repository */ $repository = app(AccountRepositoryInterface::class); $repository->setUser($admin); $set = $repository->getAccountsById([$accountId]); - if ($set->count() === 1) { + if (1 === $set->count()) { /** @var Account $first */ $first = $set->first(); if ($first->accountType->type !== $type) { diff --git a/changelog.md b/changelog.md index 121443f5bf..2756b9a2af 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,56 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [4.7.6] - 2018-09-02 +### Added +- [Issue 145](https://github.com/firefly-iii/firefly-iii/issues/145) You can now download transactions from YNAB. +- [Issue 306](https://github.com/firefly-iii/firefly-iii/issues/306) You can now add liabilities to Firefly III. +- [Issue 740](https://github.com/firefly-iii/firefly-iii/issues/740) Various charts are now currency aware. +- [Issue 833](https://github.com/firefly-iii/firefly-iii/issues/833) Bills can use non-default currencies. +- [Issue 1578](https://github.com/firefly-iii/firefly-iii/issues/1578) Firefly III will notify you if the cron job hasn't fired. +- [Issue 1623](https://github.com/firefly-iii/firefly-iii/issues/1623) New transactions will link back from the success message. +- [Issue 1624](https://github.com/firefly-iii/firefly-iii/issues/1624) transactions will link to the object. +- You can call the cron job over the web now (see docs). +- You don't need to call the cron job every minute any more. +- Various charts are now red/green to signify income and expenses. +- Option to add or remove accounts from the net worth calculations. + +### Deprecated +- This will be the last release on PHP 7.1. Future versions will require PHP 7.2. + +### Fixed +- [Issue 1460](https://github.com/firefly-iii/firefly-iii/issues/1460) Downloading transactions from bunq should go more smoothly. +- [Issue 1464](https://github.com/firefly-iii/firefly-iii/issues/1464) Fixed the docker file to work on Raspberry Pi's. +- [Issue 1540](https://github.com/firefly-iii/firefly-iii/issues/1540) The Docker file now has a working cron job for recurring transactions. +- [Issue 1564](https://github.com/firefly-iii/firefly-iii/issues/1564) Fix double transfers when importing from bunq. +- [Issue 1575](https://github.com/firefly-iii/firefly-iii/issues/1575) Some views would give a XSRF token warning +- [Issue 1576](https://github.com/firefly-iii/firefly-iii/issues/1576) Fix assigning budgets +- [Issue 1580](https://github.com/firefly-iii/firefly-iii/issues/1580) Missing string for translation +- [Issue 1581](https://github.com/firefly-iii/firefly-iii/issues/1581) Expand help text +- [Issue 1584](https://github.com/firefly-iii/firefly-iii/issues/1584) Link to administration is back. +- [Issue 1586](https://github.com/firefly-iii/firefly-iii/issues/1586) Date fields in import were mislabeled. +- [Issue 1593](https://github.com/firefly-iii/firefly-iii/issues/1593) Link types are translatable. +- [Issue 1594](https://github.com/firefly-iii/firefly-iii/issues/1594) Very long breadcrumbs are weird. +- [Issue 1598](https://github.com/firefly-iii/firefly-iii/issues/1598) Fix budget calculations. +- [Issue 1597](https://github.com/firefly-iii/firefly-iii/issues/1597) Piggy banks are always inactive. +- [Issue 1605](https://github.com/firefly-iii/firefly-iii/issues/1605) System will ignore foreign currency setting if user doesn't indicate the amount. +- [Issue 1607](https://github.com/firefly-iii/firefly-iii/issues/1607) Firefly III trusts the Heroku load balancer, fixing deployment on Heroku. +- [Issue 1608](https://github.com/firefly-iii/firefly-iii/issues/1608) Spelling error in command line import. +- [Issue 1609](https://github.com/firefly-iii/firefly-iii/issues/1609) Link to budgets page was absolute. +- [Issue 1615](https://github.com/firefly-iii/firefly-iii/issues/1615) Fix currency bug in transactions. +- [Issue 1616](https://github.com/firefly-iii/firefly-iii/issues/1616) Fix null pointer exception in pie charts. +- [Issue 1617](https://github.com/firefly-iii/firefly-iii/issues/1617) Fix for complex tag names in URL's. +- [Issue 1620](https://github.com/firefly-iii/firefly-iii/issues/1620) Fixed index reference in API. +- [Issue 1642](https://github.com/firefly-iii/firefly-iii/issues/1642) Fix issue with split journals. +- [Issue 1643](https://github.com/firefly-iii/firefly-iii/issues/1643) Fix reconciliation issue. +- Users can no longer give expenses a budget. +- Fix bug in Spectre import. +- Heroku would not make you owner. + +### Security +- Add `.htaccess` files to all public directories. +- New secure headers will make Firefly III slightly more secure. + ## [4.7.5.3] - 2017-07-28 ### Added - Many updated French translations thanks to [@bubka](https://crowdin.com/profile/bubka). diff --git a/composer.lock b/composer.lock index 7ecd5c1198..416c0a5e1a 100644 --- a/composer.lock +++ b/composer.lock @@ -114,7 +114,7 @@ "payment", "sepa" ], - "time": "2018-07-24T18:37:12+00:00" + "time": "2018-09-01T12:54:04+00:00" }, { "name": "davejamesmiller/laravel-breadcrumbs", @@ -240,16 +240,16 @@ }, { "name": "doctrine/cache", - "version": "v1.7.1", + "version": "v1.8.0", "source": { "type": "git", "url": "https://github.com/doctrine/cache.git", - "reference": "b3217d58609e9c8e661cd41357a54d926c4a2a1a" + "reference": "d768d58baee9a4862ca783840eca1b9add7a7f57" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/cache/zipball/b3217d58609e9c8e661cd41357a54d926c4a2a1a", - "reference": "b3217d58609e9c8e661cd41357a54d926c4a2a1a", + "url": "https://api.github.com/repos/doctrine/cache/zipball/d768d58baee9a4862ca783840eca1b9add7a7f57", + "reference": "d768d58baee9a4862ca783840eca1b9add7a7f57", "shasum": "" }, "require": { @@ -260,8 +260,9 @@ }, "require-dev": { "alcaeus/mongo-php-adapter": "^1.1", + "doctrine/coding-standard": "^4.0", "mongodb/mongodb": "^1.1", - "phpunit/phpunit": "^5.7", + "phpunit/phpunit": "^7.0", "predis/predis": "~1.0" }, "suggest": { @@ -270,7 +271,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.7.x-dev" + "dev-master": "1.8.x-dev" } }, "autoload": { @@ -305,12 +306,12 @@ } ], "description": "Caching library offering an object-oriented API for many cache backends", - "homepage": "http://www.doctrine-project.org", + "homepage": "https://www.doctrine-project.org", "keywords": [ "cache", "caching" ], - "time": "2017-08-25T07:02:50+00:00" + "time": "2018-08-21T18:01:43+00:00" }, { "name": "doctrine/dbal", @@ -637,16 +638,16 @@ }, { "name": "egulias/email-validator", - "version": "2.1.4", + "version": "2.1.5", "source": { "type": "git", "url": "https://github.com/egulias/EmailValidator.git", - "reference": "8790f594151ca6a2010c6218e09d96df67173ad3" + "reference": "54859fabea8b3beecbb1a282888d5c990036b9e3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/8790f594151ca6a2010c6218e09d96df67173ad3", - "reference": "8790f594151ca6a2010c6218e09d96df67173ad3", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/54859fabea8b3beecbb1a282888d5c990036b9e3", + "reference": "54859fabea8b3beecbb1a282888d5c990036b9e3", "shasum": "" }, "require": { @@ -690,7 +691,7 @@ "validation", "validator" ], - "time": "2018-04-10T10:11:19+00:00" + "time": "2018-08-16T20:49:45+00:00" }, { "name": "erusev/parsedown", @@ -1021,16 +1022,16 @@ }, { "name": "laravel/framework", - "version": "v5.6.29", + "version": "v5.6.35", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "acc6b5c54ab196d3358f60acc5f55d9ebaaccc02" + "reference": "02abbe2091404ebe032a6d5a83075031b7072ebe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/acc6b5c54ab196d3358f60acc5f55d9ebaaccc02", - "reference": "acc6b5c54ab196d3358f60acc5f55d9ebaaccc02", + "url": "https://api.github.com/repos/laravel/framework/zipball/02abbe2091404ebe032a6d5a83075031b7072ebe", + "reference": "02abbe2091404ebe032a6d5a83075031b7072ebe", "shasum": "" }, "require": { @@ -1156,7 +1157,7 @@ "framework", "laravel" ], - "time": "2018-07-26T16:01:26+00:00" + "time": "2018-08-27T13:18:24+00:00" }, { "name": "laravel/passport", @@ -1297,16 +1298,16 @@ }, { "name": "lcobucci/jwt", - "version": "3.2.2", + "version": "3.2.4", "source": { "type": "git", "url": "https://github.com/lcobucci/jwt.git", - "reference": "0b5930be73582369e10c4d4bb7a12bac927a203c" + "reference": "c9704b751315d21735dc98d78d4f37bd73596da7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/lcobucci/jwt/zipball/0b5930be73582369e10c4d4bb7a12bac927a203c", - "reference": "0b5930be73582369e10c4d4bb7a12bac927a203c", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/c9704b751315d21735dc98d78d4f37bd73596da7", + "reference": "c9704b751315d21735dc98d78d4f37bd73596da7", "shasum": "" }, "require": { @@ -1351,7 +1352,7 @@ "JWS", "jwt" ], - "time": "2017-09-01T08:23:26+00:00" + "time": "2018-08-03T11:23:50+00:00" }, { "name": "league/commonmark", @@ -1541,16 +1542,16 @@ }, { "name": "league/flysystem", - "version": "1.0.45", + "version": "1.0.46", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "a99f94e63b512d75f851b181afcdf0ee9ebef7e6" + "reference": "f3e0d925c18b92cf3ce84ea5cc58d62a1762a2b2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/a99f94e63b512d75f851b181afcdf0ee9ebef7e6", - "reference": "a99f94e63b512d75f851b181afcdf0ee9ebef7e6", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f3e0d925c18b92cf3ce84ea5cc58d62a1762a2b2", + "reference": "f3e0d925c18b92cf3ce84ea5cc58d62a1762a2b2", "shasum": "" }, "require": { @@ -1562,7 +1563,7 @@ "require-dev": { "ext-fileinfo": "*", "phpspec/phpspec": "^3.4", - "phpunit/phpunit": "^5.7" + "phpunit/phpunit": "^5.7.10" }, "suggest": { "ext-fileinfo": "Required for MimeType", @@ -1621,7 +1622,7 @@ "sftp", "storage" ], - "time": "2018-05-07T08:44:23+00:00" + "time": "2018-08-22T07:45:22+00:00" }, { "name": "league/fractal", @@ -2091,21 +2092,21 @@ }, { "name": "pragmarx/google2fa", - "version": "v3.0.1", + "version": "v3.0.3", "source": { "type": "git", "url": "https://github.com/antonioribeiro/google2fa.git", - "reference": "40b3ce025bed0f9cd0c1c8ab7fc8265344c73de0" + "reference": "6949226739e4424f40031e6f1c96b1fd64047335" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/40b3ce025bed0f9cd0c1c8ab7fc8265344c73de0", - "reference": "40b3ce025bed0f9cd0c1c8ab7fc8265344c73de0", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/6949226739e4424f40031e6f1c96b1fd64047335", + "reference": "6949226739e4424f40031e6f1c96b1fd64047335", "shasum": "" }, "require": { "paragonie/constant_time_encoding": "~1.0|~2.0", - "paragonie/random_compat": "~1.4|~2.0", + "paragonie/random_compat": ">=1", "php": ">=5.4", "symfony/polyfill-php56": "~1.2" }, @@ -2148,7 +2149,7 @@ "google2fa", "laravel" ], - "time": "2018-03-15T23:14:19+00:00" + "time": "2018-08-29T13:28:06+00:00" }, { "name": "pragmarx/google2fa-laravel", @@ -2630,16 +2631,16 @@ }, { "name": "symfony/console", - "version": "v4.1.2", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "5c31f6a97c1c240707f6d786e7e59bfacdbc0219" + "reference": "ca80b8ced97cf07390078b29773dc384c39eee1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/5c31f6a97c1c240707f6d786e7e59bfacdbc0219", - "reference": "5c31f6a97c1c240707f6d786e7e59bfacdbc0219", + "url": "https://api.github.com/repos/symfony/console/zipball/ca80b8ced97cf07390078b29773dc384c39eee1f", + "reference": "ca80b8ced97cf07390078b29773dc384c39eee1f", "shasum": "" }, "require": { @@ -2694,20 +2695,20 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2018-07-16T14:05:40+00:00" + "time": "2018-07-26T11:24:31+00:00" }, { "name": "symfony/css-selector", - "version": "v4.1.2", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "03ac71606ecb0b0ce792faa17d74cc32c2949ef4" + "reference": "2a4df7618f869b456f9096781e78c57b509d76c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/03ac71606ecb0b0ce792faa17d74cc32c2949ef4", - "reference": "03ac71606ecb0b0ce792faa17d74cc32c2949ef4", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/2a4df7618f869b456f9096781e78c57b509d76c7", + "reference": "2a4df7618f869b456f9096781e78c57b509d76c7", "shasum": "" }, "require": { @@ -2747,20 +2748,20 @@ ], "description": "Symfony CssSelector Component", "homepage": "https://symfony.com", - "time": "2018-05-30T07:26:09+00:00" + "time": "2018-07-26T09:10:45+00:00" }, { "name": "symfony/debug", - "version": "v4.1.2", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/symfony/debug.git", - "reference": "a1f2118cedb8731c45e945cdd2b808ca82abc4b5" + "reference": "47ead688f1f2877f3f14219670f52e4722ee7052" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/debug/zipball/a1f2118cedb8731c45e945cdd2b808ca82abc4b5", - "reference": "a1f2118cedb8731c45e945cdd2b808ca82abc4b5", + "url": "https://api.github.com/repos/symfony/debug/zipball/47ead688f1f2877f3f14219670f52e4722ee7052", + "reference": "47ead688f1f2877f3f14219670f52e4722ee7052", "shasum": "" }, "require": { @@ -2803,20 +2804,20 @@ ], "description": "Symfony Debug Component", "homepage": "https://symfony.com", - "time": "2018-07-06T14:52:28+00:00" + "time": "2018-08-03T11:13:38+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v4.1.2", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "00d64638e4f0703a00ab7fc2c8ae5f75f3b4020f" + "reference": "bfb30c2ad377615a463ebbc875eba64a99f6aa3e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/00d64638e4f0703a00ab7fc2c8ae5f75f3b4020f", - "reference": "00d64638e4f0703a00ab7fc2c8ae5f75f3b4020f", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/bfb30c2ad377615a463ebbc875eba64a99f6aa3e", + "reference": "bfb30c2ad377615a463ebbc875eba64a99f6aa3e", "shasum": "" }, "require": { @@ -2866,20 +2867,20 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2018-07-10T11:02:47+00:00" + "time": "2018-07-26T09:10:45+00:00" }, { "name": "symfony/finder", - "version": "v4.1.2", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "84714b8417d19e4ba02ea78a41a975b3efaafddb" + "reference": "e162f1df3102d0b7472805a5a9d5db9fcf0a8068" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/84714b8417d19e4ba02ea78a41a975b3efaafddb", - "reference": "84714b8417d19e4ba02ea78a41a975b3efaafddb", + "url": "https://api.github.com/repos/symfony/finder/zipball/e162f1df3102d0b7472805a5a9d5db9fcf0a8068", + "reference": "e162f1df3102d0b7472805a5a9d5db9fcf0a8068", "shasum": "" }, "require": { @@ -2915,20 +2916,20 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2018-06-19T21:38:16+00:00" + "time": "2018-07-26T11:24:31+00:00" }, { "name": "symfony/http-foundation", - "version": "v4.1.2", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "8da9ea68ab2d80dfabd41e0d14b9606bb47a10c0" + "reference": "3a5c91e133b220bb882b3cd773ba91bf39989345" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/8da9ea68ab2d80dfabd41e0d14b9606bb47a10c0", - "reference": "8da9ea68ab2d80dfabd41e0d14b9606bb47a10c0", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/3a5c91e133b220bb882b3cd773ba91bf39989345", + "reference": "3a5c91e133b220bb882b3cd773ba91bf39989345", "shasum": "" }, "require": { @@ -2969,20 +2970,20 @@ ], "description": "Symfony HttpFoundation Component", "homepage": "https://symfony.com", - "time": "2018-07-16T14:05:40+00:00" + "time": "2018-08-27T17:47:02+00:00" }, { "name": "symfony/http-kernel", - "version": "v4.1.2", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "ebd28f4f88a2ca0a0488882ad73c4004f3afdbe3" + "reference": "33de0a1ff2e1720096189e3ced682d7a4e8f5e35" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/ebd28f4f88a2ca0a0488882ad73c4004f3afdbe3", - "reference": "ebd28f4f88a2ca0a0488882ad73c4004f3afdbe3", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/33de0a1ff2e1720096189e3ced682d7a4e8f5e35", + "reference": "33de0a1ff2e1720096189e3ced682d7a4e8f5e35", "shasum": "" }, "require": { @@ -3056,29 +3057,32 @@ ], "description": "Symfony HttpKernel Component", "homepage": "https://symfony.com", - "time": "2018-07-23T17:16:22+00:00" + "time": "2018-08-28T06:17:42+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.8.0", + "version": "v1.9.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "7cc359f1b7b80fc25ed7796be7d96adc9b354bae" + "reference": "e3d826245268269cd66f8326bd8bc066687b4a19" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/7cc359f1b7b80fc25ed7796be7d96adc9b354bae", - "reference": "7cc359f1b7b80fc25ed7796be7d96adc9b354bae", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/e3d826245268269cd66f8326bd8bc066687b4a19", + "reference": "e3d826245268269cd66f8326bd8bc066687b4a19", "shasum": "" }, "require": { "php": ">=5.3.3" }, + "suggest": { + "ext-ctype": "For best performance" + }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.8-dev" + "dev-master": "1.9-dev" } }, "autoload": { @@ -3111,20 +3115,20 @@ "polyfill", "portable" ], - "time": "2018-04-30T19:57:29+00:00" + "time": "2018-08-06T14:22:27+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.8.0", + "version": "v1.9.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "3296adf6a6454a050679cde90f95350ad604b171" + "reference": "d0cd638f4634c16d8df4508e847f14e9e43168b8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/3296adf6a6454a050679cde90f95350ad604b171", - "reference": "3296adf6a6454a050679cde90f95350ad604b171", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d0cd638f4634c16d8df4508e847f14e9e43168b8", + "reference": "d0cd638f4634c16d8df4508e847f14e9e43168b8", "shasum": "" }, "require": { @@ -3136,7 +3140,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.8-dev" + "dev-master": "1.9-dev" } }, "autoload": { @@ -3170,20 +3174,20 @@ "portable", "shim" ], - "time": "2018-04-26T10:06:28+00:00" + "time": "2018-08-06T14:22:27+00:00" }, { "name": "symfony/polyfill-php56", - "version": "v1.8.0", + "version": "v1.9.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php56.git", - "reference": "af98553c84912459db3f636329567809d639a8f6" + "reference": "7b4fc009172cc0196535b0328bd1226284a28000" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php56/zipball/af98553c84912459db3f636329567809d639a8f6", - "reference": "af98553c84912459db3f636329567809d639a8f6", + "url": "https://api.github.com/repos/symfony/polyfill-php56/zipball/7b4fc009172cc0196535b0328bd1226284a28000", + "reference": "7b4fc009172cc0196535b0328bd1226284a28000", "shasum": "" }, "require": { @@ -3193,7 +3197,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.8-dev" + "dev-master": "1.9-dev" } }, "autoload": { @@ -3226,20 +3230,20 @@ "portable", "shim" ], - "time": "2018-04-26T10:06:28+00:00" + "time": "2018-08-06T14:22:27+00:00" }, { "name": "symfony/polyfill-php72", - "version": "v1.8.0", + "version": "v1.9.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php72.git", - "reference": "a4576e282d782ad82397f3e4ec1df8e0f0cafb46" + "reference": "95c50420b0baed23852452a7f0c7b527303ed5ae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/a4576e282d782ad82397f3e4ec1df8e0f0cafb46", - "reference": "a4576e282d782ad82397f3e4ec1df8e0f0cafb46", + "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/95c50420b0baed23852452a7f0c7b527303ed5ae", + "reference": "95c50420b0baed23852452a7f0c7b527303ed5ae", "shasum": "" }, "require": { @@ -3248,7 +3252,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.8-dev" + "dev-master": "1.9-dev" } }, "autoload": { @@ -3281,20 +3285,20 @@ "portable", "shim" ], - "time": "2018-04-26T10:06:28+00:00" + "time": "2018-08-06T14:22:27+00:00" }, { "name": "symfony/polyfill-util", - "version": "v1.8.0", + "version": "v1.9.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-util.git", - "reference": "1a5ad95d9436cbff3296034fe9f8d586dce3fb3a" + "reference": "8e15d04ba3440984d23e7964b2ee1d25c8de1581" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-util/zipball/1a5ad95d9436cbff3296034fe9f8d586dce3fb3a", - "reference": "1a5ad95d9436cbff3296034fe9f8d586dce3fb3a", + "url": "https://api.github.com/repos/symfony/polyfill-util/zipball/8e15d04ba3440984d23e7964b2ee1d25c8de1581", + "reference": "8e15d04ba3440984d23e7964b2ee1d25c8de1581", "shasum": "" }, "require": { @@ -3303,7 +3307,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.8-dev" + "dev-master": "1.9-dev" } }, "autoload": { @@ -3333,20 +3337,20 @@ "polyfill", "shim" ], - "time": "2018-04-26T10:06:28+00:00" + "time": "2018-08-06T14:22:27+00:00" }, { "name": "symfony/process", - "version": "v4.1.2", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "1d1677391ecf00d1c5b9482d6050c0c27aa3ac3a" + "reference": "86cdb930a6a855b0ab35fb60c1504cb36184f843" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/1d1677391ecf00d1c5b9482d6050c0c27aa3ac3a", - "reference": "1d1677391ecf00d1c5b9482d6050c0c27aa3ac3a", + "url": "https://api.github.com/repos/symfony/process/zipball/86cdb930a6a855b0ab35fb60c1504cb36184f843", + "reference": "86cdb930a6a855b0ab35fb60c1504cb36184f843", "shasum": "" }, "require": { @@ -3382,38 +3386,39 @@ ], "description": "Symfony Process Component", "homepage": "https://symfony.com", - "time": "2018-05-31T10:17:53+00:00" + "time": "2018-08-03T11:13:38+00:00" }, { "name": "symfony/psr-http-message-bridge", - "version": "v1.0.2", + "version": "v1.1.0", "source": { "type": "git", "url": "https://github.com/symfony/psr-http-message-bridge.git", - "reference": "c2b757934f2d9681a287e662efbc27c41fe8ef86" + "reference": "53c15a6a7918e6c2ab16ae370ea607fb40cab196" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/c2b757934f2d9681a287e662efbc27c41fe8ef86", - "reference": "c2b757934f2d9681a287e662efbc27c41fe8ef86", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/53c15a6a7918e6c2ab16ae370ea607fb40cab196", + "reference": "53c15a6a7918e6c2ab16ae370ea607fb40cab196", "shasum": "" }, "require": { - "php": ">=5.3.3", - "psr/http-message": "~1.0", - "symfony/http-foundation": "~2.3|~3.0|~4.0" + "php": "^5.3.3 || ^7.0", + "psr/http-message": "^1.0", + "symfony/http-foundation": "^2.3.42 || ^3.4 || ^4.0" }, "require-dev": { - "symfony/phpunit-bridge": "~3.2|4.0" + "symfony/phpunit-bridge": "^3.4 || 4.0" }, "suggest": { + "psr/http-factory-implementation": "To use the PSR-17 factory", "psr/http-message-implementation": "To use the HttpFoundation factory", "zendframework/zend-diactoros": "To use the Zend Diactoros factory" }, "type": "symfony-bridge", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-master": "1.1-dev" } }, "autoload": { @@ -3442,20 +3447,20 @@ "http-message", "psr-7" ], - "time": "2017-12-19T00:31:44+00:00" + "time": "2018-08-30T16:28:28+00:00" }, { "name": "symfony/routing", - "version": "v4.1.2", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "73770bf3682b4407b017c2bdcb2b11cdcbce5322" + "reference": "a5784c2ec4168018c87b38f0e4f39d2278499f51" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/73770bf3682b4407b017c2bdcb2b11cdcbce5322", - "reference": "73770bf3682b4407b017c2bdcb2b11cdcbce5322", + "url": "https://api.github.com/repos/symfony/routing/zipball/a5784c2ec4168018c87b38f0e4f39d2278499f51", + "reference": "a5784c2ec4168018c87b38f0e4f39d2278499f51", "shasum": "" }, "require": { @@ -3519,20 +3524,20 @@ "uri", "url" ], - "time": "2018-06-28T06:30:33+00:00" + "time": "2018-08-03T07:58:40+00:00" }, { "name": "symfony/translation", - "version": "v4.1.2", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "2dd74d6b2dcbd46a93971e6ce7d245cf3123e957" + "reference": "fa2182669f7983b7aa5f1a770d053f79f0ef144f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/2dd74d6b2dcbd46a93971e6ce7d245cf3123e957", - "reference": "2dd74d6b2dcbd46a93971e6ce7d245cf3123e957", + "url": "https://api.github.com/repos/symfony/translation/zipball/fa2182669f7983b7aa5f1a770d053f79f0ef144f", + "reference": "fa2182669f7983b7aa5f1a770d053f79f0ef144f", "shasum": "" }, "require": { @@ -3588,20 +3593,20 @@ ], "description": "Symfony Translation Component", "homepage": "https://symfony.com", - "time": "2018-07-23T08:20:20+00:00" + "time": "2018-08-07T12:45:11+00:00" }, { "name": "symfony/var-dumper", - "version": "v4.1.2", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "9f882aed43f364de1d43038e8fb39703c577afc1" + "reference": "a05426e27294bba7b0226ffc17dd01a3c6ef9777" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9f882aed43f364de1d43038e8fb39703c577afc1", - "reference": "9f882aed43f364de1d43038e8fb39703c577afc1", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/a05426e27294bba7b0226ffc17dd01a3c6ef9777", + "reference": "a05426e27294bba7b0226ffc17dd01a3c6ef9777", "shasum": "" }, "require": { @@ -3663,7 +3668,7 @@ "debug", "dump" ], - "time": "2018-07-05T11:54:23+00:00" + "time": "2018-08-02T09:24:26+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -3780,16 +3785,16 @@ }, { "name": "vlucas/phpdotenv", - "version": "v2.5.0", + "version": "v2.5.1", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "6ae3e2e6494bb5e58c2decadafc3de7f1453f70a" + "reference": "8abb4f9aa89ddea9d52112c65bbe8d0125e2fa8e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/6ae3e2e6494bb5e58c2decadafc3de7f1453f70a", - "reference": "6ae3e2e6494bb5e58c2decadafc3de7f1453f70a", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/8abb4f9aa89ddea9d52112c65bbe8d0125e2fa8e", + "reference": "8abb4f9aa89ddea9d52112c65bbe8d0125e2fa8e", "shasum": "" }, "require": { @@ -3826,20 +3831,20 @@ "env", "environment" ], - "time": "2018-07-01T10:25:50+00:00" + "time": "2018-07-29T20:33:41+00:00" }, { "name": "zendframework/zend-diactoros", - "version": "1.8.3", + "version": "1.8.5", "source": { "type": "git", "url": "https://github.com/zendframework/zend-diactoros.git", - "reference": "72c13834fb3db2a962e913758b384ff2e6425d6e" + "reference": "3e4edb822c942f37ade0d09579cfbab11e2fee87" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zendframework/zend-diactoros/zipball/72c13834fb3db2a962e913758b384ff2e6425d6e", - "reference": "72c13834fb3db2a962e913758b384ff2e6425d6e", + "url": "https://api.github.com/repos/zendframework/zend-diactoros/zipball/3e4edb822c942f37ade0d09579cfbab11e2fee87", + "reference": "3e4edb822c942f37ade0d09579cfbab11e2fee87", "shasum": "" }, "require": { @@ -3889,7 +3894,7 @@ "psr", "psr-7" ], - "time": "2018-07-24T21:54:38+00:00" + "time": "2018-08-10T14:16:32+00:00" } ], "packages-dev": [ @@ -4729,16 +4734,16 @@ }, { "name": "phpspec/prophecy", - "version": "1.7.6", + "version": "1.8.0", "source": { "type": "git", "url": "https://github.com/phpspec/prophecy.git", - "reference": "33a7e3c4fda54e912ff6338c48823bd5c0f0b712" + "reference": "4ba436b55987b4bf311cb7c6ba82aa528aac0a06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/33a7e3c4fda54e912ff6338c48823bd5c0f0b712", - "reference": "33a7e3c4fda54e912ff6338c48823bd5c0f0b712", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/4ba436b55987b4bf311cb7c6ba82aa528aac0a06", + "reference": "4ba436b55987b4bf311cb7c6ba82aa528aac0a06", "shasum": "" }, "require": { @@ -4750,12 +4755,12 @@ }, "require-dev": { "phpspec/phpspec": "^2.5|^3.2", - "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.5" + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.5 || ^7.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.7.x-dev" + "dev-master": "1.8.x-dev" } }, "autoload": { @@ -4788,7 +4793,7 @@ "spy", "stub" ], - "time": "2018-04-18T13:57:24+00:00" + "time": "2018-08-05T17:53:17+00:00" }, { "name": "phpunit/php-code-coverage", @@ -5041,16 +5046,16 @@ }, { "name": "phpunit/phpunit", - "version": "7.2.7", + "version": "7.3.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "8e878aff7917ef66e702e03d1359b16eee254e2c" + "reference": "1bd5629cccfb2c0a9ef5474b4ff772349e1ec898" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/8e878aff7917ef66e702e03d1359b16eee254e2c", - "reference": "8e878aff7917ef66e702e03d1359b16eee254e2c", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/1bd5629cccfb2c0a9ef5474b4ff772349e1ec898", + "reference": "1bd5629cccfb2c0a9ef5474b4ff772349e1ec898", "shasum": "" }, "require": { @@ -5095,7 +5100,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "7.2-dev" + "dev-master": "7.3-dev" } }, "autoload": { @@ -5121,22 +5126,11 @@ "testing", "xunit" ], - "time": "2018-07-15T05:20:50+00:00" + "time": "2018-09-01T15:49:55+00:00" }, { "name": "roave/security-advisories", "version": "dev-master", - "source": { - "type": "git", - "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "053766d789f6393e5bc0896635d35abf8d2d362e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/053766d789f6393e5bc0896635d35abf8d2d362e", - "reference": "053766d789f6393e5bc0896635d35abf8d2d362e", - "shasum": "" - }, "conflict": { "3f/pygmentize": "<1.2", "adodb/adodb-php": "<5.20.12", @@ -5177,11 +5171,12 @@ "gregwar/rst": "<1.0.3", "guzzlehttp/guzzle": ">=6,<6.2.1|>=4.0.0-rc2,<4.2.4|>=5,<5.3.1", "illuminate/auth": ">=4,<4.0.99|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.10", + "illuminate/cookie": ">=4,<=4.0.11|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.42|>=5.6,<5.6.30", "illuminate/database": ">=4,<4.0.99|>=4.1,<4.1.29", "illuminate/encryption": ">=4,<=4.0.11|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.40|>=5.6,<5.6.15", "joomla/session": "<1.3.1", "kreait/firebase-php": ">=3.2,<3.8.1", - "laravel/framework": ">=4,<4.0.99|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.40|>=5.6,<5.6.15", + "laravel/framework": ">=4,<4.0.99|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.42|>=5.6,<5.6.30", "laravel/socialite": ">=1,<1.0.99|>=2,<2.0.10", "magento/magento1ce": "<1.9.3.9", "magento/magento1ee": ">=1.9,<1.14.3.2", @@ -5221,7 +5216,7 @@ "symfony/dependency-injection": ">=2,<2.0.17", "symfony/form": ">=2.3,<2.3.35|>=2.4,<2.6.12|>=2.7,<2.7.38|>=2.8,<2.8.31|>=3,<3.2.14|>=3.3,<3.3.13", "symfony/framework-bundle": ">=2,<2.3.18|>=2.4,<2.4.8|>=2.5,<2.5.2", - "symfony/http-foundation": ">=2,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11", + "symfony/http-foundation": ">=2,<2.7.49|>=2.8,<2.8.44|>=3,<3.3.18|>=3.4,<3.4.14|>=4,<4.0.14|>=4.1,<4.1.3", "symfony/http-kernel": ">=2,<2.3.29|>=2.4,<2.5.12|>=2.6,<2.6.8", "symfony/intl": ">=2.7,<2.7.38|>=2.8,<2.8.31|>=3,<3.2.14|>=3.3,<3.3.13", "symfony/routing": ">=2,<2.0.19", @@ -5232,7 +5227,7 @@ "symfony/security-guard": ">=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11", "symfony/security-http": ">=2.3,<2.3.41|>=2.4,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11", "symfony/serializer": ">=2,<2.0.11", - "symfony/symfony": ">=2,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11", + "symfony/symfony": ">=2,<2.7.49|>=2.8,<2.8.44|>=3,<3.3.18|>=3.4,<3.4.14|>=4,<4.0.14|>=4.1,<4.1.3", "symfony/translation": ">=2,<2.0.17", "symfony/validator": ">=2,<2.0.24|>=2.1,<2.1.12|>=2.2,<2.2.5|>=2.3,<2.3.3", "symfony/web-profiler-bundle": ">=2,<2.3.19|>=2.4,<2.4.9|>=2.5,<2.5.4", @@ -5258,9 +5253,10 @@ "zendframework/zend-captcha": ">=2,<2.4.9|>=2.5,<2.5.2", "zendframework/zend-crypt": ">=2,<2.4.9|>=2.5,<2.5.2", "zendframework/zend-db": ">=2,<2.0.99|>=2.1,<2.1.99|>=2.2,<2.2.10|>=2.3,<2.3.5", - "zendframework/zend-diactoros": ">=1,<1.0.4", + "zendframework/zend-diactoros": ">=1,<1.8.4", + "zendframework/zend-feed": ">=1,<2.10.3", "zendframework/zend-form": ">=2,<2.2.7|>=2.3,<2.3.1", - "zendframework/zend-http": ">=2,<2.0.99|>=2.1,<2.1.99|>=2.3,<2.3.8|>=2.4,<2.4.1", + "zendframework/zend-http": ">=1,<2.8.1", "zendframework/zend-json": ">=2.1,<2.1.6|>=2.2,<2.2.6", "zendframework/zend-ldap": ">=2,<2.0.99|>=2.1,<2.1.99|>=2.2,<2.2.8|>=2.3,<2.3.3", "zendframework/zend-mail": ">=2,<2.4.11|>=2.5,<2.7.2", @@ -5269,7 +5265,7 @@ "zendframework/zend-validator": ">=2.3,<2.3.6", "zendframework/zend-view": ">=2,<2.2.7|>=2.3,<2.3.1", "zendframework/zend-xmlrpc": ">=2.1,<2.1.6|>=2.2,<2.2.6", - "zendframework/zendframework": ">=2,<2.4.11|>=2.5,<2.5.1", + "zendframework/zendframework": "<2.5.1", "zendframework/zendframework1": "<1.12.20", "zendframework/zendopenid": ">=2,<2.0.2", "zendframework/zendxml": ">=1,<1.0.1", @@ -5291,7 +5287,7 @@ } ], "description": "Prevents installation of composer packages with known security vulnerabilities: no API, simply require it", - "time": "2018-07-18T13:51:34+00:00" + "time": "2018-08-14T15:39:17+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", @@ -5858,16 +5854,16 @@ }, { "name": "symfony/class-loader", - "version": "v3.4.13", + "version": "v3.4.15", "source": { "type": "git", "url": "https://github.com/symfony/class-loader.git", - "reference": "e63c12699822bb3b667e7216ba07fbcc3a3e203e" + "reference": "31db283fc86d3143e7ff87e922177b457d909c30" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/class-loader/zipball/e63c12699822bb3b667e7216ba07fbcc3a3e203e", - "reference": "e63c12699822bb3b667e7216ba07fbcc3a3e203e", + "url": "https://api.github.com/repos/symfony/class-loader/zipball/31db283fc86d3143e7ff87e922177b457d909c30", + "reference": "31db283fc86d3143e7ff87e922177b457d909c30", "shasum": "" }, "require": { @@ -5910,20 +5906,20 @@ ], "description": "Symfony ClassLoader Component", "homepage": "https://symfony.com", - "time": "2018-01-03T07:37:34+00:00" + "time": "2018-07-26T11:19:56+00:00" }, { "name": "symfony/config", - "version": "v4.1.2", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "e57e7b573df9d0eaa8c0152768c708ee7ea2b8e5" + "reference": "76015a3cc372b14d00040ff58e18e29f69eba717" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/e57e7b573df9d0eaa8c0152768c708ee7ea2b8e5", - "reference": "e57e7b573df9d0eaa8c0152768c708ee7ea2b8e5", + "url": "https://api.github.com/repos/symfony/config/zipball/76015a3cc372b14d00040ff58e18e29f69eba717", + "reference": "76015a3cc372b14d00040ff58e18e29f69eba717", "shasum": "" }, "require": { @@ -5973,20 +5969,20 @@ ], "description": "Symfony Config Component", "homepage": "https://symfony.com", - "time": "2018-06-20T11:15:17+00:00" + "time": "2018-08-08T06:37:38+00:00" }, { "name": "symfony/filesystem", - "version": "v4.1.2", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "562bf7005b55fd80d26b582d28e3e10f2dd5ae9c" + "reference": "c0f5f62db218fa72195b8b8700e4b9b9cf52eb5e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/562bf7005b55fd80d26b582d28e3e10f2dd5ae9c", - "reference": "562bf7005b55fd80d26b582d28e3e10f2dd5ae9c", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/c0f5f62db218fa72195b8b8700e4b9b9cf52eb5e", + "reference": "c0f5f62db218fa72195b8b8700e4b9b9cf52eb5e", "shasum": "" }, "require": { @@ -6023,20 +6019,20 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2018-05-30T07:26:09+00:00" + "time": "2018-08-18T16:52:46+00:00" }, { "name": "symfony/stopwatch", - "version": "v4.1.2", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "07463bbbbbfe119045a24c4a516f92ebd2752784" + "reference": "966c982df3cca41324253dc0c7ffe76b6076b705" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/07463bbbbbfe119045a24c4a516f92ebd2752784", - "reference": "07463bbbbbfe119045a24c4a516f92ebd2752784", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/966c982df3cca41324253dc0c7ffe76b6076b705", + "reference": "966c982df3cca41324253dc0c7ffe76b6076b705", "shasum": "" }, "require": { @@ -6072,20 +6068,20 @@ ], "description": "Symfony Stopwatch Component", "homepage": "https://symfony.com", - "time": "2018-02-19T16:51:42+00:00" + "time": "2018-07-26T11:00:49+00:00" }, { "name": "symfony/yaml", - "version": "v4.1.2", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "80e4bfa9685fc4a09acc4a857ec16974a9cd944e" + "reference": "b832cc289608b6d305f62149df91529a2ab3c314" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/80e4bfa9685fc4a09acc4a857ec16974a9cd944e", - "reference": "80e4bfa9685fc4a09acc4a857ec16974a9cd944e", + "url": "https://api.github.com/repos/symfony/yaml/zipball/b832cc289608b6d305f62149df91529a2ab3c314", + "reference": "b832cc289608b6d305f62149df91529a2ab3c314", "shasum": "" }, "require": { @@ -6131,7 +6127,7 @@ ], "description": "Symfony Yaml Component", "homepage": "https://symfony.com", - "time": "2018-05-30T07:26:09+00:00" + "time": "2018-08-18T16:52:46+00:00" }, { "name": "theseer/tokenizer", diff --git a/config/app.php b/config/app.php index 5127530c6d..47d80fc8ba 100644 --- a/config/app.php +++ b/config/app.php @@ -32,8 +32,6 @@ return [ 'fallback_locale' => 'en_US', 'key' => env('APP_KEY'), 'cipher' => 'AES-256-CBC', - 'log' => envNonEmpty('APP_LOG', 'errorlog'), - 'log_level' => envNonEmpty('APP_LOG_LEVEL', 'info'), 'providers' => [ /* diff --git a/config/firefly.php b/config/firefly.php index 1af5d5b4a7..c182081c1e 100644 --- a/config/firefly.php +++ b/config/firefly.php @@ -88,8 +88,8 @@ return [ 'is_demo_site' => false, ], 'encryption' => null === env('USE_ENCRYPTION') || env('USE_ENCRYPTION') === true, - 'version' => '4.7.5.3', - 'api_version' => '0.6', + 'version' => '4.7.6', + 'api_version' => '0.7', 'db_version' => 4, 'maxUploadSize' => 15242880, 'allowedMimes' => [ @@ -176,10 +176,12 @@ return [ ], 'subTitlesByIdentifier' => [ - 'asset' => 'Asset accounts', - 'expense' => 'Expense accounts', - 'revenue' => 'Revenue accounts', - 'cash' => 'Cash accounts', + 'asset' => 'Asset accounts', + 'expense' => 'Expense accounts', + 'revenue' => 'Revenue accounts', + 'cash' => 'Cash accounts', + 'liabilities' => 'Liabilities', + 'liability' => 'Liabilities', ], 'subIconsByIdentifier' => [ @@ -194,23 +196,27 @@ return [ 'Revenue account' => 'fa-download', 'import' => 'fa-download', 'Import account' => 'fa-download', + 'liabilities' => 'fa-ticket', ], 'accountTypesByIdentifier' => [ - 'asset' => ['Default account', 'Asset account'], - 'expense' => ['Expense account', 'Beneficiary account'], - 'revenue' => ['Revenue account'], - 'import' => ['Import account'], + 'asset' => ['Default account', 'Asset account'], + 'expense' => ['Expense account', 'Beneficiary account'], + 'revenue' => ['Revenue account'], + 'import' => ['Import account'], + 'liabilities' => ['Loan', 'Debt', 'Credit card', 'Mortgage'], ], 'accountTypeByIdentifier' => [ - 'asset' => 'Asset account', - 'expense' => 'Expense account', - 'revenue' => 'Revenue account', - 'opening' => 'Initial balance account', - 'initial' => 'Initial balance account', - 'import' => 'Import account', - 'reconcile' => 'Reconciliation account', + 'asset' => ['Asset account'], + 'expense' => ['Expense account'], + 'revenue' => ['Revenue account'], + 'opening' => ['Initial balance account'], + 'initial' => ['Initial balance account'], + 'import' => ['Import account'], + 'reconcile' => ['Reconciliation account'], + 'liabilities' => ['Loan', 'Debt', 'Mortgage', 'Credit card'], + 'liability' => ['Loan', 'Debt', 'Mortgage', 'Credit card'], ], 'shortNamesByFullName' => [ @@ -221,6 +227,10 @@ return [ 'Beneficiary account' => 'expense', 'Revenue account' => 'revenue', 'Cash account' => 'cash', + 'Credit card' => 'liabilities', + 'Loan' => 'liabilities', + 'Debt' => 'liabilities', + 'Mortgage' => 'liabilities', ], 'languages' => [ // completed languages @@ -267,7 +277,7 @@ return [ 'journalLink' => \FireflyIII\Models\TransactionJournalLink::class, 'currency' => \FireflyIII\Models\TransactionCurrency::class, 'piggyBank' => \FireflyIII\Models\PiggyBank::class, - 'preference' => \FireflyIII\Models\Preference::class, + 'preference' => \FireflyIII\Models\Preference::class, 'tj' => \FireflyIII\Models\TransactionJournal::class, 'tag' => \FireflyIII\Models\Tag::class, 'recurrence' => \FireflyIII\Models\Recurrence::class, @@ -299,6 +309,7 @@ return [ 'fromCurrencyCode' => \FireflyIII\Support\Binder\CurrencyCode::class, 'toCurrencyCode' => \FireflyIII\Support\Binder\CurrencyCode::class, 'unfinishedJournal' => \FireflyIII\Support\Binder\UnfinishedJournal::class, + 'cliToken' => \FireflyIII\Support\Binder\CLIToken::class, ], diff --git a/config/import.php b/config/import.php index ed49fa8417..5f6a724700 100644 --- a/config/import.php +++ b/config/import.php @@ -26,13 +26,16 @@ use FireflyIII\Import\JobConfiguration\BunqJobConfiguration; use FireflyIII\Import\JobConfiguration\FakeJobConfiguration; use FireflyIII\Import\JobConfiguration\FileJobConfiguration; use FireflyIII\Import\JobConfiguration\SpectreJobConfiguration; +use FireflyIII\Import\JobConfiguration\YnabJobConfiguration; use FireflyIII\Import\Prerequisites\BunqPrerequisites; use FireflyIII\Import\Prerequisites\FakePrerequisites; use FireflyIII\Import\Prerequisites\SpectrePrerequisites; +use FireflyIII\Import\Prerequisites\YnabPrerequisites; use FireflyIII\Import\Routine\BunqRoutine; use FireflyIII\Import\Routine\FakeRoutine; use FireflyIII\Import\Routine\FileRoutine; use FireflyIII\Import\Routine\SpectreRoutine; +use FireflyIII\Import\Routine\YnabRoutine; use FireflyIII\Support\Import\Routine\File\CSVProcessor; return [ @@ -42,6 +45,7 @@ return [ 'file' => true, 'bunq' => true, 'spectre' => true, + 'ynab' => true, 'plaid' => false, 'quovo' => false, 'yodlee' => false, @@ -53,6 +57,7 @@ return [ 'file' => false, 'bunq' => false, 'spectre' => false, + 'ynab' => false, 'plaid' => false, 'quovo' => false, 'yodlee' => false, @@ -63,6 +68,7 @@ return [ 'file' => true, 'bunq' => true, 'spectre' => true, + 'ynab' => true, 'plaid' => true, 'quovo' => true, 'yodlee' => true, @@ -73,6 +79,7 @@ return [ 'file' => false, 'bunq' => true, 'spectre' => true, + 'ynab' => true, 'plaid' => true, 'quovo' => true, 'yodlee' => true, @@ -83,6 +90,7 @@ return [ 'file' => false, 'bunq' => BunqPrerequisites::class, 'spectre' => SpectrePrerequisites::class, + 'ynab' => YnabPrerequisites::class, 'plaid' => false, 'quovo' => false, 'yodlee' => false, @@ -93,6 +101,7 @@ return [ 'file' => true, 'bunq' => true, 'spectre' => true, + 'ynab' => true, 'plaid' => false, 'quovo' => false, 'yodlee' => false, @@ -103,6 +112,7 @@ return [ 'file' => FileJobConfiguration::class, 'bunq' => BunqJobConfiguration::class, 'spectre' => SpectreJobConfiguration::class, + 'ynab' => YnabJobConfiguration::class, 'plaid' => false, 'quovo' => false, 'yodlee' => false, @@ -113,6 +123,7 @@ return [ 'file' => FileRoutine::class, 'bunq' => BunqRoutine::class, 'spectre' => SpectreRoutine::class, + 'ynab' => YnabRoutine::class, 'plaid' => false, 'quovo' => false, 'yodlee' => false, @@ -140,6 +151,10 @@ return [ 'spectre' => [ 'server' => 'www.saltedge.com', ], + 'ynab' => [ + 'live' => 'api.youneedabudget.com', + 'version' => 'v1', + ], 'plaid' => [], 'quovo' => [], 'yodlee' => [], diff --git a/config/logging.php b/config/logging.php index 3e1dcfd1f3..1ac3fb0284 100644 --- a/config/logging.php +++ b/config/logging.php @@ -59,7 +59,12 @@ return [ 'single' => [ 'driver' => 'single', 'path' => storage_path('logs/laravel.log'), - 'level' => 'debug', + 'level' => envNonEmpty('APP_LOG_LEVEL', 'info'), + ], + 'stdout' => [ + 'driver' => 'single', + 'path' => 'php://stdout', + 'level' => envNonEmpty('APP_LOG_LEVEL', 'info'), ], 'daily' => [ diff --git a/config/twigbridge.php b/config/twigbridge.php index fa0557b7c9..d1e807a6be 100644 --- a/config/twigbridge.php +++ b/config/twigbridge.php @@ -185,17 +185,11 @@ return [ 'Config', 'Request', 'ExpandedForm' => [ - 'is_safe' => [ - 'date', 'text', 'select', 'balance', 'optionsList', 'checkbox', 'amount', 'tags', 'integer', 'textarea', 'location', - 'file', 'staticText', 'password', 'nonSelectableAmount', - 'number', 'assetAccountList','amountNoCurrency','currencyList','ruleGroupList','assetAccountCheckList','ruleGroupListWithEmpty', - 'piggyBankList','currencyListEmpty','activeAssetAccountList' - ], - ], - 'Form' => [ - 'is_safe' => [ - 'input', 'select', 'checkbox', 'model', 'open', 'radio', 'textarea', 'file', - ], + 'is_safe' => ['date', 'text', 'select', 'balance', 'optionsList', 'checkbox', 'amount', 'tags', 'integer', 'textarea', 'location', 'file', + 'staticText', 'password', 'nonSelectableAmount', 'number', 'assetAccountList', 'amountNoCurrency', 'currencyList', + 'ruleGroupList', 'assetAccountCheckList', 'ruleGroupListWithEmpty', 'piggyBankList', 'currencyListEmpty', + 'activeAssetAccountList', 'percentage', 'activeLongAccountList', 'longAccountList',],], + 'Form' => ['is_safe' => ['input', 'select', 'checkbox', 'model', 'open', 'radio', 'textarea', 'file',], ], ], diff --git a/config/upgrade.php b/config/upgrade.php index 58def7e7f9..d24e17c798 100644 --- a/config/upgrade.php +++ b/config/upgrade.php @@ -30,6 +30,7 @@ return [ '4.6.4' => 'This version of Firefly III requires PHP7.1.', '4.7.3' => 'This version of Firefly III handles bills differently. See http://bit.ly/FF3-new-bills for more information.', '4.7.4' => 'This version of Firefly III has a new import routine. See http://bit.ly/FF3-new-import for more information.', + '4.7.6' => 'This will be the last version to require PHP7.1. Future versions will require PHP7.2 minimum.', ], 'install' => [ @@ -38,6 +39,7 @@ return [ '4.6.4' => 'This version of Firefly III requires PHP7.1.', '4.7.3' => 'This version of Firefly III handles bills differently. See http://bit.ly/FF3-new-bills for more information.', '4.7.4' => 'This version of Firefly III has a new import routine. See http://bit.ly/FF3-new-import for more information.', + '4.7.6' => 'This will be the last version to require PHP7.1. Future versions will require PHP7.2 minimum.', ], ], ]; diff --git a/database/seeds/AccountTypeSeeder.php b/database/seeds/AccountTypeSeeder.php index 0ebde97fb3..f695c310d8 100644 --- a/database/seeds/AccountTypeSeeder.php +++ b/database/seeds/AccountTypeSeeder.php @@ -41,12 +41,15 @@ class AccountTypeSeeder extends Seeder AccountType::IMPORT, AccountType::LOAN, AccountType::RECONCILIATION, + AccountType::DEBT, + AccountType::MORTGAGE, + AccountType::CREDITCARD, ]; foreach ($types as $type) { try { AccountType::create(['type' => $type]); } catch (PDOException $e) { - Log::warning(sprintf('Could not create account type "%s". It might exist already.', $type)); + Log::warning(sprintf('Could not create account type "%s". It might exist already: %s', $type , $e->getMessage())); } } } diff --git a/docker-compose.yml b/docker-compose.yml index 28210fcf1b..8175a631d0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,13 +6,15 @@ services: firefly_iii_app: environment: - FF_DB_HOST=firefly_iii_db - - FF_DB_NAME=firefly_db - - FF_DB_USER=firefly_db - - FF_DB_PASSWORD=firefly_db_secret + - FF_DB_NAME=firefly + - FF_DB_USER=firefly + - FF_DB_PASSWORD=firefly - FF_APP_KEY=S0m3R@nd0mStr1ngOf32Ch@rsEx@ctly - FF_APP_ENV=local + - FF_DB_CONNECTION=pgsql - TZ=Europe/Amsterdam - image: jc5x/firefly-iii + - APP_LOG_LEVEL=debug + image: jc5x/firefly-iii:develop links: - firefly_iii_db networks: @@ -30,15 +32,13 @@ services: type: volume firefly_iii_db: environment: - - MYSQL_DATABASE=firefly_db - - MYSQL_USER=firefly_db - - MYSQL_PASSWORD=firefly_db_secret - - MYSQL_RANDOM_ROOT_PASSWORD=yes - image: "mariadb:latest" + - POSTGRES_PASSWORD=firefly + - POSTGRES_USER=firefly + image: "postgres:latest" networks: - firefly_iii_net volumes: - - "firefly_iii_db:/var/lib/mysql" + - "firefly_iii_db:/var/lib/postgresql/data" version: "3.2" volumes: firefly_iii_db: ~ diff --git a/nginx_app.conf b/nginx_app.conf index 97bdb62378..39dedf0155 100644 --- a/nginx_app.conf +++ b/nginx_app.conf @@ -2,6 +2,9 @@ fastcgi_param HTTP_PROXY ""; location / { + if ($http_x_forwarded_proto != "https") { + return 301 https://$host$request_uri; + } # try to serve file directly, fallback to rewrite try_files $uri @rewriteapp; } diff --git a/public/css/.htaccess b/public/css/.htaccess new file mode 100644 index 0000000000..45552cb63e --- /dev/null +++ b/public/css/.htaccess @@ -0,0 +1 @@ +Options -Indexes \ No newline at end of file diff --git a/public/css/app.css b/public/css/app.css deleted file mode 100644 index 99c3d14a5b..0000000000 --- a/public/css/app.css +++ /dev/null @@ -1,14 +0,0 @@ -/*! - * Bootstrap v3.3.7 (http://getbootstrap.com) - * Copyright 2011-2016 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - */ - -/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{font-size:2em;margin:.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{-webkit-box-sizing:content-box;box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type=checkbox],input[type=radio]{-webkit-box-sizing:border-box;box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-appearance:textfield;-webkit-box-sizing:content-box;box-sizing:content-box}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}textarea{overflow:auto}optgroup{font-weight:700}table{border-collapse:collapse;border-spacing:0}td,th{padding:0} - -/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */@media print{*,:after,:before{background:transparent!important;color:#000!important;-webkit-box-shadow:none!important;box-shadow:none!important;text-shadow:none!important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="#"]:after,a[href^="javascript:"]:after{content:""}blockquote,pre{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}img{max-width:100%!important}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}.navbar{display:none}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000!important}.label{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #ddd!important}}@font-face{font-family:Glyphicons Halflings;src:url(../fonts/vendor/bootstrap-sass/bootstrap/glyphicons-halflings-regular.eot?f4769f9bdb7466be65088239c12046d1);src:url(../fonts/vendor/bootstrap-sass/bootstrap/glyphicons-halflings-regular.eot?f4769f9bdb7466be65088239c12046d1?#iefix) format("embedded-opentype"),url(../fonts/vendor/bootstrap-sass/bootstrap/glyphicons-halflings-regular.woff2?448c34a56d699c29117adc64c43affeb) format("woff2"),url(../fonts/vendor/bootstrap-sass/bootstrap/glyphicons-halflings-regular.woff?fa2772327f55d8198301fdb8bcfc8158) format("woff"),url(../fonts/vendor/bootstrap-sass/bootstrap/glyphicons-halflings-regular.ttf?e18bbf611f2a2e43afc071aa2f4e1512) format("truetype"),url(../fonts/vendor/bootstrap-sass/bootstrap/glyphicons-halflings-regular.svg?89889688147bd7575d6327160d64e760#glyphicons_halflingsregular) format("svg")}.glyphicon{position:relative;top:1px;display:inline-block;font-family:Glyphicons Halflings;font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.glyphicon-asterisk:before{content:"*"}.glyphicon-plus:before{content:"+"}.glyphicon-eur:before,.glyphicon-euro:before{content:"\20AC"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270F"}.glyphicon-glass:before{content:"\E001"}.glyphicon-music:before{content:"\E002"}.glyphicon-search:before{content:"\E003"}.glyphicon-heart:before{content:"\E005"}.glyphicon-star:before{content:"\E006"}.glyphicon-star-empty:before{content:"\E007"}.glyphicon-user:before{content:"\E008"}.glyphicon-film:before{content:"\E009"}.glyphicon-th-large:before{content:"\E010"}.glyphicon-th:before{content:"\E011"}.glyphicon-th-list:before{content:"\E012"}.glyphicon-ok:before{content:"\E013"}.glyphicon-remove:before{content:"\E014"}.glyphicon-zoom-in:before{content:"\E015"}.glyphicon-zoom-out:before{content:"\E016"}.glyphicon-off:before{content:"\E017"}.glyphicon-signal:before{content:"\E018"}.glyphicon-cog:before{content:"\E019"}.glyphicon-trash:before{content:"\E020"}.glyphicon-home:before{content:"\E021"}.glyphicon-file:before{content:"\E022"}.glyphicon-time:before{content:"\E023"}.glyphicon-road:before{content:"\E024"}.glyphicon-download-alt:before{content:"\E025"}.glyphicon-download:before{content:"\E026"}.glyphicon-upload:before{content:"\E027"}.glyphicon-inbox:before{content:"\E028"}.glyphicon-play-circle:before{content:"\E029"}.glyphicon-repeat:before{content:"\E030"}.glyphicon-refresh:before{content:"\E031"}.glyphicon-list-alt:before{content:"\E032"}.glyphicon-lock:before{content:"\E033"}.glyphicon-flag:before{content:"\E034"}.glyphicon-headphones:before{content:"\E035"}.glyphicon-volume-off:before{content:"\E036"}.glyphicon-volume-down:before{content:"\E037"}.glyphicon-volume-up:before{content:"\E038"}.glyphicon-qrcode:before{content:"\E039"}.glyphicon-barcode:before{content:"\E040"}.glyphicon-tag:before{content:"\E041"}.glyphicon-tags:before{content:"\E042"}.glyphicon-book:before{content:"\E043"}.glyphicon-bookmark:before{content:"\E044"}.glyphicon-print:before{content:"\E045"}.glyphicon-camera:before{content:"\E046"}.glyphicon-font:before{content:"\E047"}.glyphicon-bold:before{content:"\E048"}.glyphicon-italic:before{content:"\E049"}.glyphicon-text-height:before{content:"\E050"}.glyphicon-text-width:before{content:"\E051"}.glyphicon-align-left:before{content:"\E052"}.glyphicon-align-center:before{content:"\E053"}.glyphicon-align-right:before{content:"\E054"}.glyphicon-align-justify:before{content:"\E055"}.glyphicon-list:before{content:"\E056"}.glyphicon-indent-left:before{content:"\E057"}.glyphicon-indent-right:before{content:"\E058"}.glyphicon-facetime-video:before{content:"\E059"}.glyphicon-picture:before{content:"\E060"}.glyphicon-map-marker:before{content:"\E062"}.glyphicon-adjust:before{content:"\E063"}.glyphicon-tint:before{content:"\E064"}.glyphicon-edit:before{content:"\E065"}.glyphicon-share:before{content:"\E066"}.glyphicon-check:before{content:"\E067"}.glyphicon-move:before{content:"\E068"}.glyphicon-step-backward:before{content:"\E069"}.glyphicon-fast-backward:before{content:"\E070"}.glyphicon-backward:before{content:"\E071"}.glyphicon-play:before{content:"\E072"}.glyphicon-pause:before{content:"\E073"}.glyphicon-stop:before{content:"\E074"}.glyphicon-forward:before{content:"\E075"}.glyphicon-fast-forward:before{content:"\E076"}.glyphicon-step-forward:before{content:"\E077"}.glyphicon-eject:before{content:"\E078"}.glyphicon-chevron-left:before{content:"\E079"}.glyphicon-chevron-right:before{content:"\E080"}.glyphicon-plus-sign:before{content:"\E081"}.glyphicon-minus-sign:before{content:"\E082"}.glyphicon-remove-sign:before{content:"\E083"}.glyphicon-ok-sign:before{content:"\E084"}.glyphicon-question-sign:before{content:"\E085"}.glyphicon-info-sign:before{content:"\E086"}.glyphicon-screenshot:before{content:"\E087"}.glyphicon-remove-circle:before{content:"\E088"}.glyphicon-ok-circle:before{content:"\E089"}.glyphicon-ban-circle:before{content:"\E090"}.glyphicon-arrow-left:before{content:"\E091"}.glyphicon-arrow-right:before{content:"\E092"}.glyphicon-arrow-up:before{content:"\E093"}.glyphicon-arrow-down:before{content:"\E094"}.glyphicon-share-alt:before{content:"\E095"}.glyphicon-resize-full:before{content:"\E096"}.glyphicon-resize-small:before{content:"\E097"}.glyphicon-exclamation-sign:before{content:"\E101"}.glyphicon-gift:before{content:"\E102"}.glyphicon-leaf:before{content:"\E103"}.glyphicon-fire:before{content:"\E104"}.glyphicon-eye-open:before{content:"\E105"}.glyphicon-eye-close:before{content:"\E106"}.glyphicon-warning-sign:before{content:"\E107"}.glyphicon-plane:before{content:"\E108"}.glyphicon-calendar:before{content:"\E109"}.glyphicon-random:before{content:"\E110"}.glyphicon-comment:before{content:"\E111"}.glyphicon-magnet:before{content:"\E112"}.glyphicon-chevron-up:before{content:"\E113"}.glyphicon-chevron-down:before{content:"\E114"}.glyphicon-retweet:before{content:"\E115"}.glyphicon-shopping-cart:before{content:"\E116"}.glyphicon-folder-close:before{content:"\E117"}.glyphicon-folder-open:before{content:"\E118"}.glyphicon-resize-vertical:before{content:"\E119"}.glyphicon-resize-horizontal:before{content:"\E120"}.glyphicon-hdd:before{content:"\E121"}.glyphicon-bullhorn:before{content:"\E122"}.glyphicon-bell:before{content:"\E123"}.glyphicon-certificate:before{content:"\E124"}.glyphicon-thumbs-up:before{content:"\E125"}.glyphicon-thumbs-down:before{content:"\E126"}.glyphicon-hand-right:before{content:"\E127"}.glyphicon-hand-left:before{content:"\E128"}.glyphicon-hand-up:before{content:"\E129"}.glyphicon-hand-down:before{content:"\E130"}.glyphicon-circle-arrow-right:before{content:"\E131"}.glyphicon-circle-arrow-left:before{content:"\E132"}.glyphicon-circle-arrow-up:before{content:"\E133"}.glyphicon-circle-arrow-down:before{content:"\E134"}.glyphicon-globe:before{content:"\E135"}.glyphicon-wrench:before{content:"\E136"}.glyphicon-tasks:before{content:"\E137"}.glyphicon-filter:before{content:"\E138"}.glyphicon-briefcase:before{content:"\E139"}.glyphicon-fullscreen:before{content:"\E140"}.glyphicon-dashboard:before{content:"\E141"}.glyphicon-paperclip:before{content:"\E142"}.glyphicon-heart-empty:before{content:"\E143"}.glyphicon-link:before{content:"\E144"}.glyphicon-phone:before{content:"\E145"}.glyphicon-pushpin:before{content:"\E146"}.glyphicon-usd:before{content:"\E148"}.glyphicon-gbp:before{content:"\E149"}.glyphicon-sort:before{content:"\E150"}.glyphicon-sort-by-alphabet:before{content:"\E151"}.glyphicon-sort-by-alphabet-alt:before{content:"\E152"}.glyphicon-sort-by-order:before{content:"\E153"}.glyphicon-sort-by-order-alt:before{content:"\E154"}.glyphicon-sort-by-attributes:before{content:"\E155"}.glyphicon-sort-by-attributes-alt:before{content:"\E156"}.glyphicon-unchecked:before{content:"\E157"}.glyphicon-expand:before{content:"\E158"}.glyphicon-collapse-down:before{content:"\E159"}.glyphicon-collapse-up:before{content:"\E160"}.glyphicon-log-in:before{content:"\E161"}.glyphicon-flash:before{content:"\E162"}.glyphicon-log-out:before{content:"\E163"}.glyphicon-new-window:before{content:"\E164"}.glyphicon-record:before{content:"\E165"}.glyphicon-save:before{content:"\E166"}.glyphicon-open:before{content:"\E167"}.glyphicon-saved:before{content:"\E168"}.glyphicon-import:before{content:"\E169"}.glyphicon-export:before{content:"\E170"}.glyphicon-send:before{content:"\E171"}.glyphicon-floppy-disk:before{content:"\E172"}.glyphicon-floppy-saved:before{content:"\E173"}.glyphicon-floppy-remove:before{content:"\E174"}.glyphicon-floppy-save:before{content:"\E175"}.glyphicon-floppy-open:before{content:"\E176"}.glyphicon-credit-card:before{content:"\E177"}.glyphicon-transfer:before{content:"\E178"}.glyphicon-cutlery:before{content:"\E179"}.glyphicon-header:before{content:"\E180"}.glyphicon-compressed:before{content:"\E181"}.glyphicon-earphone:before{content:"\E182"}.glyphicon-phone-alt:before{content:"\E183"}.glyphicon-tower:before{content:"\E184"}.glyphicon-stats:before{content:"\E185"}.glyphicon-sd-video:before{content:"\E186"}.glyphicon-hd-video:before{content:"\E187"}.glyphicon-subtitles:before{content:"\E188"}.glyphicon-sound-stereo:before{content:"\E189"}.glyphicon-sound-dolby:before{content:"\E190"}.glyphicon-sound-5-1:before{content:"\E191"}.glyphicon-sound-6-1:before{content:"\E192"}.glyphicon-sound-7-1:before{content:"\E193"}.glyphicon-copyright-mark:before{content:"\E194"}.glyphicon-registration-mark:before{content:"\E195"}.glyphicon-cloud-download:before{content:"\E197"}.glyphicon-cloud-upload:before{content:"\E198"}.glyphicon-tree-conifer:before{content:"\E199"}.glyphicon-tree-deciduous:before{content:"\E200"}.glyphicon-cd:before{content:"\E201"}.glyphicon-save-file:before{content:"\E202"}.glyphicon-open-file:before{content:"\E203"}.glyphicon-level-up:before{content:"\E204"}.glyphicon-copy:before{content:"\E205"}.glyphicon-paste:before{content:"\E206"}.glyphicon-alert:before{content:"\E209"}.glyphicon-equalizer:before{content:"\E210"}.glyphicon-king:before{content:"\E211"}.glyphicon-queen:before{content:"\E212"}.glyphicon-pawn:before{content:"\E213"}.glyphicon-bishop:before{content:"\E214"}.glyphicon-knight:before{content:"\E215"}.glyphicon-baby-formula:before{content:"\E216"}.glyphicon-tent:before{content:"\26FA"}.glyphicon-blackboard:before{content:"\E218"}.glyphicon-bed:before{content:"\E219"}.glyphicon-apple:before{content:"\F8FF"}.glyphicon-erase:before{content:"\E221"}.glyphicon-hourglass:before{content:"\231B"}.glyphicon-lamp:before{content:"\E223"}.glyphicon-duplicate:before{content:"\E224"}.glyphicon-piggy-bank:before{content:"\E225"}.glyphicon-scissors:before{content:"\E226"}.glyphicon-bitcoin:before,.glyphicon-btc:before,.glyphicon-xbt:before{content:"\E227"}.glyphicon-jpy:before,.glyphicon-yen:before{content:"\A5"}.glyphicon-rub:before,.glyphicon-ruble:before{content:"\20BD"}.glyphicon-scale:before{content:"\E230"}.glyphicon-ice-lolly:before{content:"\E231"}.glyphicon-ice-lolly-tasted:before{content:"\E232"}.glyphicon-education:before{content:"\E233"}.glyphicon-option-horizontal:before{content:"\E234"}.glyphicon-option-vertical:before{content:"\E235"}.glyphicon-menu-hamburger:before{content:"\E236"}.glyphicon-modal-window:before{content:"\E237"}.glyphicon-oil:before{content:"\E238"}.glyphicon-grain:before{content:"\E239"}.glyphicon-sunglasses:before{content:"\E240"}.glyphicon-text-size:before{content:"\E241"}.glyphicon-text-color:before{content:"\E242"}.glyphicon-text-background:before{content:"\E243"}.glyphicon-object-align-top:before{content:"\E244"}.glyphicon-object-align-bottom:before{content:"\E245"}.glyphicon-object-align-horizontal:before{content:"\E246"}.glyphicon-object-align-left:before{content:"\E247"}.glyphicon-object-align-vertical:before{content:"\E248"}.glyphicon-object-align-right:before{content:"\E249"}.glyphicon-triangle-right:before{content:"\E250"}.glyphicon-triangle-left:before{content:"\E251"}.glyphicon-triangle-bottom:before{content:"\E252"}.glyphicon-triangle-top:before{content:"\E253"}.glyphicon-console:before{content:"\E254"}.glyphicon-superscript:before{content:"\E255"}.glyphicon-subscript:before{content:"\E256"}.glyphicon-menu-left:before{content:"\E257"}.glyphicon-menu-right:before{content:"\E258"}.glyphicon-menu-down:before{content:"\E259"}.glyphicon-menu-up:before{content:"\E260"}*,:after,:before{-webkit-box-sizing:border-box;box-sizing:border-box}html{font-size:10px;-webkit-tap-highlight-color:transparent}body{font-family:Raleway,sans-serif;font-size:14px;line-height:1.6;color:#636b6f;background-color:#f5f8fa}button,input,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#3097d1;text-decoration:none}a:focus,a:hover{color:#216a94;text-decoration:underline}a:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}figure{margin:0}img{vertical-align:middle}.img-responsive{display:block;max-width:100%;height:auto}.img-rounded{border-radius:6px}.img-thumbnail{padding:4px;line-height:1.6;background-color:#f5f8fa;border:1px solid #ddd;border-radius:4px;-webkit-transition:all .2s ease-in-out;transition:all .2s ease-in-out;display:inline-block;max-width:100%;height:auto}.img-circle{border-radius:50%}hr{margin-top:22px;margin-bottom:22px;border:0;border-top:1px solid #eee}[role=button]{cursor:pointer}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{font-family:inherit;font-weight:500;line-height:1.1;color:inherit}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-weight:400;line-height:1;color:#777}.h1,.h2,.h3,h1,h2,h3{margin-top:22px;margin-bottom:11px}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small{font-size:65%}.h4,.h5,.h6,h4,h5,h6{margin-top:11px;margin-bottom:11px}.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-size:75%}.h1,h1{font-size:36px}.h2,h2{font-size:30px}.h3,h3{font-size:24px}.h4,h4{font-size:18px}.h5,h5{font-size:14px}.h6,h6{font-size:12px}p{margin:0 0 11px}.lead{margin-bottom:22px;font-size:16px;font-weight:300;line-height:1.4}@media (min-width:768px){.lead{font-size:21px}}.small,small{font-size:85%}.mark,mark{background-color:#fcf8e3;padding:.2em}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-nowrap{white-space:nowrap}.text-lowercase{text-transform:lowercase}.initialism,.text-uppercase{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-muted{color:#777}.text-primary{color:#3097d1}a.text-primary:focus,a.text-primary:hover{color:#2579a9}.text-success{color:#3c763d}a.text-success:focus,a.text-success:hover{color:#2b542c}.text-info{color:#31708f}a.text-info:focus,a.text-info:hover{color:#245269}.text-warning{color:#8a6d3b}a.text-warning:focus,a.text-warning:hover{color:#66512c}.text-danger{color:#a94442}a.text-danger:focus,a.text-danger:hover{color:#843534}.bg-primary{color:#fff;background-color:#3097d1}a.bg-primary:focus,a.bg-primary:hover{background-color:#2579a9}.bg-success{background-color:#dff0d8}a.bg-success:focus,a.bg-success:hover{background-color:#c1e2b3}.bg-info{background-color:#d9edf7}a.bg-info:focus,a.bg-info:hover{background-color:#afd9ee}.bg-warning{background-color:#fcf8e3}a.bg-warning:focus,a.bg-warning:hover{background-color:#f7ecb5}.bg-danger{background-color:#f2dede}a.bg-danger:focus,a.bg-danger:hover{background-color:#e4b9b9}.page-header{padding-bottom:10px;margin:44px 0 22px;border-bottom:1px solid #eee}ol,ul{margin-top:0;margin-bottom:11px}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}.list-inline,.list-unstyled{padding-left:0;list-style:none}.list-inline{margin-left:-5px}.list-inline>li{display:inline-block;padding-left:5px;padding-right:5px}dl{margin-top:0;margin-bottom:22px}dd,dt{line-height:1.6}dt{font-weight:700}dd{margin-left:0}.dl-horizontal dd:after,.dl-horizontal dd:before{content:" ";display:table}.dl-horizontal dd:after{clear:both}@media (min-width:768px){.dl-horizontal dt{float:left;width:160px;clear:left;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}}abbr[data-original-title],abbr[title]{cursor:help;border-bottom:1px dotted #777}.initialism{font-size:90%}blockquote{padding:11px 22px;margin:0 0 22px;font-size:17.5px;border-left:5px solid #eee}blockquote ol:last-child,blockquote p:last-child,blockquote ul:last-child{margin-bottom:0}blockquote .small,blockquote footer,blockquote small{display:block;font-size:80%;line-height:1.6;color:#777}blockquote .small:before,blockquote footer:before,blockquote small:before{content:"\2014 \A0"}.blockquote-reverse,blockquote.pull-right{padding-right:15px;padding-left:0;border-right:5px solid #eee;border-left:0;text-align:right}.blockquote-reverse .small:before,.blockquote-reverse footer:before,.blockquote-reverse small:before,blockquote.pull-right .small:before,blockquote.pull-right footer:before,blockquote.pull-right small:before{content:""}.blockquote-reverse .small:after,.blockquote-reverse footer:after,.blockquote-reverse small:after,blockquote.pull-right .small:after,blockquote.pull-right footer:after,blockquote.pull-right small:after{content:"\A0 \2014"}address{margin-bottom:22px;font-style:normal;line-height:1.6}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,Courier New,monospace}code{color:#c7254e;background-color:#f9f2f4;border-radius:4px}code,kbd{padding:2px 4px;font-size:90%}kbd{color:#fff;background-color:#333;border-radius:3px;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.25);box-shadow:inset 0 -1px 0 rgba(0,0,0,.25)}kbd kbd{padding:0;font-size:100%;font-weight:700;-webkit-box-shadow:none;box-shadow:none}pre{display:block;padding:10.5px;margin:0 0 11px;font-size:13px;line-height:1.6;word-break:break-all;word-wrap:break-word;color:#333;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{margin-right:auto;margin-left:auto;padding-left:15px;padding-right:15px}.container:after,.container:before{content:" ";display:table}.container:after{clear:both}@media (min-width:768px){.container{width:750px}}@media (min-width:992px){.container{width:970px}}@media (min-width:1200px){.container{width:1170px}}.container-fluid{margin-right:auto;margin-left:auto;padding-left:15px;padding-right:15px}.container-fluid:after,.container-fluid:before{content:" ";display:table}.container-fluid:after{clear:both}.row{margin-left:-15px;margin-right:-15px}.row:after,.row:before{content:" ";display:table}.row:after{clear:both}.col-lg-1,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-10,.col-lg-11,.col-lg-12,.col-md-1,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-10,.col-md-11,.col-md-12,.col-sm-1,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-10,.col-sm-11,.col-sm-12,.col-xs-1,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9,.col-xs-10,.col-xs-11,.col-xs-12{position:relative;min-height:1px;padding-left:15px;padding-right:15px}.col-xs-1,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9,.col-xs-10,.col-xs-11,.col-xs-12{float:left}.col-xs-1{width:8.33333333%}.col-xs-2{width:16.66666667%}.col-xs-3{width:25%}.col-xs-4{width:33.33333333%}.col-xs-5{width:41.66666667%}.col-xs-6{width:50%}.col-xs-7{width:58.33333333%}.col-xs-8{width:66.66666667%}.col-xs-9{width:75%}.col-xs-10{width:83.33333333%}.col-xs-11{width:91.66666667%}.col-xs-12{width:100%}.col-xs-pull-0{right:auto}.col-xs-pull-1{right:8.33333333%}.col-xs-pull-2{right:16.66666667%}.col-xs-pull-3{right:25%}.col-xs-pull-4{right:33.33333333%}.col-xs-pull-5{right:41.66666667%}.col-xs-pull-6{right:50%}.col-xs-pull-7{right:58.33333333%}.col-xs-pull-8{right:66.66666667%}.col-xs-pull-9{right:75%}.col-xs-pull-10{right:83.33333333%}.col-xs-pull-11{right:91.66666667%}.col-xs-pull-12{right:100%}.col-xs-push-0{left:auto}.col-xs-push-1{left:8.33333333%}.col-xs-push-2{left:16.66666667%}.col-xs-push-3{left:25%}.col-xs-push-4{left:33.33333333%}.col-xs-push-5{left:41.66666667%}.col-xs-push-6{left:50%}.col-xs-push-7{left:58.33333333%}.col-xs-push-8{left:66.66666667%}.col-xs-push-9{left:75%}.col-xs-push-10{left:83.33333333%}.col-xs-push-11{left:91.66666667%}.col-xs-push-12{left:100%}.col-xs-offset-0{margin-left:0}.col-xs-offset-1{margin-left:8.33333333%}.col-xs-offset-2{margin-left:16.66666667%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-4{margin-left:33.33333333%}.col-xs-offset-5{margin-left:41.66666667%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-7{margin-left:58.33333333%}.col-xs-offset-8{margin-left:66.66666667%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-10{margin-left:83.33333333%}.col-xs-offset-11{margin-left:91.66666667%}.col-xs-offset-12{margin-left:100%}@media (min-width:768px){.col-sm-1,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-10,.col-sm-11,.col-sm-12{float:left}.col-sm-1{width:8.33333333%}.col-sm-2{width:16.66666667%}.col-sm-3{width:25%}.col-sm-4{width:33.33333333%}.col-sm-5{width:41.66666667%}.col-sm-6{width:50%}.col-sm-7{width:58.33333333%}.col-sm-8{width:66.66666667%}.col-sm-9{width:75%}.col-sm-10{width:83.33333333%}.col-sm-11{width:91.66666667%}.col-sm-12{width:100%}.col-sm-pull-0{right:auto}.col-sm-pull-1{right:8.33333333%}.col-sm-pull-2{right:16.66666667%}.col-sm-pull-3{right:25%}.col-sm-pull-4{right:33.33333333%}.col-sm-pull-5{right:41.66666667%}.col-sm-pull-6{right:50%}.col-sm-pull-7{right:58.33333333%}.col-sm-pull-8{right:66.66666667%}.col-sm-pull-9{right:75%}.col-sm-pull-10{right:83.33333333%}.col-sm-pull-11{right:91.66666667%}.col-sm-pull-12{right:100%}.col-sm-push-0{left:auto}.col-sm-push-1{left:8.33333333%}.col-sm-push-2{left:16.66666667%}.col-sm-push-3{left:25%}.col-sm-push-4{left:33.33333333%}.col-sm-push-5{left:41.66666667%}.col-sm-push-6{left:50%}.col-sm-push-7{left:58.33333333%}.col-sm-push-8{left:66.66666667%}.col-sm-push-9{left:75%}.col-sm-push-10{left:83.33333333%}.col-sm-push-11{left:91.66666667%}.col-sm-push-12{left:100%}.col-sm-offset-0{margin-left:0}.col-sm-offset-1{margin-left:8.33333333%}.col-sm-offset-2{margin-left:16.66666667%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-4{margin-left:33.33333333%}.col-sm-offset-5{margin-left:41.66666667%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-7{margin-left:58.33333333%}.col-sm-offset-8{margin-left:66.66666667%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-10{margin-left:83.33333333%}.col-sm-offset-11{margin-left:91.66666667%}.col-sm-offset-12{margin-left:100%}}@media (min-width:992px){.col-md-1,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-10,.col-md-11,.col-md-12{float:left}.col-md-1{width:8.33333333%}.col-md-2{width:16.66666667%}.col-md-3{width:25%}.col-md-4{width:33.33333333%}.col-md-5{width:41.66666667%}.col-md-6{width:50%}.col-md-7{width:58.33333333%}.col-md-8{width:66.66666667%}.col-md-9{width:75%}.col-md-10{width:83.33333333%}.col-md-11{width:91.66666667%}.col-md-12{width:100%}.col-md-pull-0{right:auto}.col-md-pull-1{right:8.33333333%}.col-md-pull-2{right:16.66666667%}.col-md-pull-3{right:25%}.col-md-pull-4{right:33.33333333%}.col-md-pull-5{right:41.66666667%}.col-md-pull-6{right:50%}.col-md-pull-7{right:58.33333333%}.col-md-pull-8{right:66.66666667%}.col-md-pull-9{right:75%}.col-md-pull-10{right:83.33333333%}.col-md-pull-11{right:91.66666667%}.col-md-pull-12{right:100%}.col-md-push-0{left:auto}.col-md-push-1{left:8.33333333%}.col-md-push-2{left:16.66666667%}.col-md-push-3{left:25%}.col-md-push-4{left:33.33333333%}.col-md-push-5{left:41.66666667%}.col-md-push-6{left:50%}.col-md-push-7{left:58.33333333%}.col-md-push-8{left:66.66666667%}.col-md-push-9{left:75%}.col-md-push-10{left:83.33333333%}.col-md-push-11{left:91.66666667%}.col-md-push-12{left:100%}.col-md-offset-0{margin-left:0}.col-md-offset-1{margin-left:8.33333333%}.col-md-offset-2{margin-left:16.66666667%}.col-md-offset-3{margin-left:25%}.col-md-offset-4{margin-left:33.33333333%}.col-md-offset-5{margin-left:41.66666667%}.col-md-offset-6{margin-left:50%}.col-md-offset-7{margin-left:58.33333333%}.col-md-offset-8{margin-left:66.66666667%}.col-md-offset-9{margin-left:75%}.col-md-offset-10{margin-left:83.33333333%}.col-md-offset-11{margin-left:91.66666667%}.col-md-offset-12{margin-left:100%}}@media (min-width:1200px){.col-lg-1,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-10,.col-lg-11,.col-lg-12{float:left}.col-lg-1{width:8.33333333%}.col-lg-2{width:16.66666667%}.col-lg-3{width:25%}.col-lg-4{width:33.33333333%}.col-lg-5{width:41.66666667%}.col-lg-6{width:50%}.col-lg-7{width:58.33333333%}.col-lg-8{width:66.66666667%}.col-lg-9{width:75%}.col-lg-10{width:83.33333333%}.col-lg-11{width:91.66666667%}.col-lg-12{width:100%}.col-lg-pull-0{right:auto}.col-lg-pull-1{right:8.33333333%}.col-lg-pull-2{right:16.66666667%}.col-lg-pull-3{right:25%}.col-lg-pull-4{right:33.33333333%}.col-lg-pull-5{right:41.66666667%}.col-lg-pull-6{right:50%}.col-lg-pull-7{right:58.33333333%}.col-lg-pull-8{right:66.66666667%}.col-lg-pull-9{right:75%}.col-lg-pull-10{right:83.33333333%}.col-lg-pull-11{right:91.66666667%}.col-lg-pull-12{right:100%}.col-lg-push-0{left:auto}.col-lg-push-1{left:8.33333333%}.col-lg-push-2{left:16.66666667%}.col-lg-push-3{left:25%}.col-lg-push-4{left:33.33333333%}.col-lg-push-5{left:41.66666667%}.col-lg-push-6{left:50%}.col-lg-push-7{left:58.33333333%}.col-lg-push-8{left:66.66666667%}.col-lg-push-9{left:75%}.col-lg-push-10{left:83.33333333%}.col-lg-push-11{left:91.66666667%}.col-lg-push-12{left:100%}.col-lg-offset-0{margin-left:0}.col-lg-offset-1{margin-left:8.33333333%}.col-lg-offset-2{margin-left:16.66666667%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-4{margin-left:33.33333333%}.col-lg-offset-5{margin-left:41.66666667%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-7{margin-left:58.33333333%}.col-lg-offset-8{margin-left:66.66666667%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-10{margin-left:83.33333333%}.col-lg-offset-11{margin-left:91.66666667%}.col-lg-offset-12{margin-left:100%}}table{background-color:transparent}caption{padding-top:8px;padding-bottom:8px;color:#777}caption,th{text-align:left}.table{width:100%;max-width:100%;margin-bottom:22px}.table>tbody>tr>td,.table>tbody>tr>th,.table>tfoot>tr>td,.table>tfoot>tr>th,.table>thead>tr>td,.table>thead>tr>th{padding:8px;line-height:1.6;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>td,.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>td,.table>thead:first-child>tr:first-child>th{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#f5f8fa}.table-condensed>tbody>tr>td,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>td,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>thead>tr>th{padding:5px}.table-bordered,.table-bordered>tbody>tr>td,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border:1px solid #ddd}.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border-bottom-width:2px}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover{background-color:#f5f5f5}table col[class*=col-]{position:static;float:none;display:table-column}table td[class*=col-],table th[class*=col-]{position:static;float:none;display:table-cell}.table>tbody>tr.active>td,.table>tbody>tr.active>th,.table>tbody>tr>td.active,.table>tbody>tr>th.active,.table>tfoot>tr.active>td,.table>tfoot>tr.active>th,.table>tfoot>tr>td.active,.table>tfoot>tr>th.active,.table>thead>tr.active>td,.table>thead>tr.active>th,.table>thead>tr>td.active,.table>thead>tr>th.active{background-color:#f5f5f5}.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr.active:hover>th,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover{background-color:#e8e8e8}.table>tbody>tr.success>td,.table>tbody>tr.success>th,.table>tbody>tr>td.success,.table>tbody>tr>th.success,.table>tfoot>tr.success>td,.table>tfoot>tr.success>th,.table>tfoot>tr>td.success,.table>tfoot>tr>th.success,.table>thead>tr.success>td,.table>thead>tr.success>th,.table>thead>tr>td.success,.table>thead>tr>th.success{background-color:#dff0d8}.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr.success:hover>th,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover{background-color:#d0e9c6}.table>tbody>tr.info>td,.table>tbody>tr.info>th,.table>tbody>tr>td.info,.table>tbody>tr>th.info,.table>tfoot>tr.info>td,.table>tfoot>tr.info>th,.table>tfoot>tr>td.info,.table>tfoot>tr>th.info,.table>thead>tr.info>td,.table>thead>tr.info>th,.table>thead>tr>td.info,.table>thead>tr>th.info{background-color:#d9edf7}.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr.info:hover>th,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover{background-color:#c4e3f3}.table>tbody>tr.warning>td,.table>tbody>tr.warning>th,.table>tbody>tr>td.warning,.table>tbody>tr>th.warning,.table>tfoot>tr.warning>td,.table>tfoot>tr.warning>th,.table>tfoot>tr>td.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>thead>tr.warning>th,.table>thead>tr>td.warning,.table>thead>tr>th.warning{background-color:#fcf8e3}.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr.warning:hover>th,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover{background-color:#faf2cc}.table>tbody>tr.danger>td,.table>tbody>tr.danger>th,.table>tbody>tr>td.danger,.table>tbody>tr>th.danger,.table>tfoot>tr.danger>td,.table>tfoot>tr.danger>th,.table>tfoot>tr>td.danger,.table>tfoot>tr>th.danger,.table>thead>tr.danger>td,.table>thead>tr.danger>th,.table>thead>tr>td.danger,.table>thead>tr>th.danger{background-color:#f2dede}.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr.danger:hover>th,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover{background-color:#ebcccc}.table-responsive{overflow-x:auto;min-height:.01%}@media screen and (max-width:767px){.table-responsive{width:100%;margin-bottom:16.5px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ddd}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>td,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>thead>tr>th{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}}fieldset{margin:0;min-width:0}fieldset,legend{padding:0;border:0}legend{display:block;width:100%;margin-bottom:22px;font-size:21px;line-height:inherit;color:#333;border-bottom:1px solid #e5e5e5}label{display:inline-block;max-width:100%;margin-bottom:5px;font-weight:700}input[type=search]{-webkit-box-sizing:border-box;box-sizing:border-box}input[type=checkbox],input[type=radio]{margin:4px 0 0;margin-top:1px\9;line-height:normal}input[type=file]{display:block}input[type=range]{display:block;width:100%}select[multiple],select[size]{height:auto}input[type=checkbox]:focus,input[type=file]:focus,input[type=radio]:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}output{padding-top:7px}.form-control,output{display:block;font-size:14px;line-height:1.6;color:#555}.form-control{width:100%;height:36px;padding:6px 12px;background-color:#fff;background-image:none;border:1px solid #ccd0d2;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075);-webkit-transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-transition:border-color .15s ease-in-out,-webkit-box-shadow .15s ease-in-out;transition:border-color .15s ease-in-out,-webkit-box-shadow .15s ease-in-out;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out,-webkit-box-shadow .15s ease-in-out}.form-control:focus{border-color:#98cbe8;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(152,203,232,.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(152,203,232,.6)}.form-control::-moz-placeholder{color:#b1b7ba;opacity:1}.form-control:-ms-input-placeholder{color:#b1b7ba}.form-control::-webkit-input-placeholder{color:#b1b7ba}.form-control::-ms-expand{border:0;background-color:transparent}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{background-color:#eee;opacity:1}.form-control[disabled],fieldset[disabled] .form-control{cursor:not-allowed}textarea.form-control{height:auto}input[type=search]{-webkit-appearance:none}@media screen and (-webkit-min-device-pixel-ratio:0){input[type=date].form-control,input[type=datetime-local].form-control,input[type=month].form-control,input[type=time].form-control{line-height:36px}.input-group-sm>.input-group-btn>input[type=date].btn,.input-group-sm>.input-group-btn>input[type=datetime-local].btn,.input-group-sm>.input-group-btn>input[type=month].btn,.input-group-sm>.input-group-btn>input[type=time].btn,.input-group-sm>input[type=date].form-control,.input-group-sm>input[type=date].input-group-addon,.input-group-sm>input[type=datetime-local].form-control,.input-group-sm>input[type=datetime-local].input-group-addon,.input-group-sm>input[type=month].form-control,.input-group-sm>input[type=month].input-group-addon,.input-group-sm>input[type=time].form-control,.input-group-sm>input[type=time].input-group-addon,.input-group-sm input[type=date],.input-group-sm input[type=datetime-local],.input-group-sm input[type=month],.input-group-sm input[type=time],input[type=date].input-sm,input[type=datetime-local].input-sm,input[type=month].input-sm,input[type=time].input-sm{line-height:30px}.input-group-lg>.input-group-btn>input[type=date].btn,.input-group-lg>.input-group-btn>input[type=datetime-local].btn,.input-group-lg>.input-group-btn>input[type=month].btn,.input-group-lg>.input-group-btn>input[type=time].btn,.input-group-lg>input[type=date].form-control,.input-group-lg>input[type=date].input-group-addon,.input-group-lg>input[type=datetime-local].form-control,.input-group-lg>input[type=datetime-local].input-group-addon,.input-group-lg>input[type=month].form-control,.input-group-lg>input[type=month].input-group-addon,.input-group-lg>input[type=time].form-control,.input-group-lg>input[type=time].input-group-addon,.input-group-lg input[type=date],.input-group-lg input[type=datetime-local],.input-group-lg input[type=month],.input-group-lg input[type=time],input[type=date].input-lg,input[type=datetime-local].input-lg,input[type=month].input-lg,input[type=time].input-lg{line-height:46px}}.form-group{margin-bottom:15px}.checkbox,.radio{position:relative;display:block;margin-top:10px;margin-bottom:10px}.checkbox label,.radio label{min-height:22px;padding-left:20px;margin-bottom:0;font-weight:400;cursor:pointer}.checkbox-inline input[type=checkbox],.checkbox input[type=checkbox],.radio-inline input[type=radio],.radio input[type=radio]{position:absolute;margin-left:-20px;margin-top:4px\9}.checkbox+.checkbox,.radio+.radio{margin-top:-5px}.checkbox-inline,.radio-inline{position:relative;display:inline-block;padding-left:20px;margin-bottom:0;vertical-align:middle;font-weight:400;cursor:pointer}.checkbox-inline+.checkbox-inline,.radio-inline+.radio-inline{margin-top:0;margin-left:10px}.checkbox-inline.disabled,.checkbox.disabled label,.radio-inline.disabled,.radio.disabled label,fieldset[disabled] .checkbox-inline,fieldset[disabled] .checkbox label,fieldset[disabled] .radio-inline,fieldset[disabled] .radio label,fieldset[disabled] input[type=checkbox],fieldset[disabled] input[type=radio],input[type=checkbox].disabled,input[type=checkbox][disabled],input[type=radio].disabled,input[type=radio][disabled]{cursor:not-allowed}.form-control-static{padding-top:7px;padding-bottom:7px;margin-bottom:0;min-height:36px}.form-control-static.input-lg,.form-control-static.input-sm,.input-group-lg>.form-control-static.form-control,.input-group-lg>.form-control-static.input-group-addon,.input-group-lg>.input-group-btn>.form-control-static.btn,.input-group-sm>.form-control-static.form-control,.input-group-sm>.form-control-static.input-group-addon,.input-group-sm>.input-group-btn>.form-control-static.btn{padding-left:0;padding-right:0}.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn,.input-sm{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.input-group-sm>.input-group-btn>select.btn,.input-group-sm>select.form-control,.input-group-sm>select.input-group-addon,select.input-sm{height:30px;line-height:30px}.input-group-sm>.input-group-btn>select[multiple].btn,.input-group-sm>.input-group-btn>textarea.btn,.input-group-sm>select[multiple].form-control,.input-group-sm>select[multiple].input-group-addon,.input-group-sm>textarea.form-control,.input-group-sm>textarea.input-group-addon,select[multiple].input-sm,textarea.input-sm{height:auto}.form-group-sm .form-control{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.form-group-sm select.form-control{height:30px;line-height:30px}.form-group-sm select[multiple].form-control,.form-group-sm textarea.form-control{height:auto}.form-group-sm .form-control-static{height:30px;min-height:34px;padding:6px 10px;font-size:12px;line-height:1.5}.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn,.input-lg{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.input-group-lg>.input-group-btn>select.btn,.input-group-lg>select.form-control,.input-group-lg>select.input-group-addon,select.input-lg{height:46px;line-height:46px}.input-group-lg>.input-group-btn>select[multiple].btn,.input-group-lg>.input-group-btn>textarea.btn,.input-group-lg>select[multiple].form-control,.input-group-lg>select[multiple].input-group-addon,.input-group-lg>textarea.form-control,.input-group-lg>textarea.input-group-addon,select[multiple].input-lg,textarea.input-lg{height:auto}.form-group-lg .form-control{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.form-group-lg select.form-control{height:46px;line-height:46px}.form-group-lg select[multiple].form-control,.form-group-lg textarea.form-control{height:auto}.form-group-lg .form-control-static{height:46px;min-height:40px;padding:11px 16px;font-size:18px;line-height:1.3333333}.has-feedback{position:relative}.has-feedback .form-control{padding-right:45px}.form-control-feedback{position:absolute;top:0;right:0;z-index:2;display:block;width:36px;height:36px;line-height:36px;text-align:center;pointer-events:none}.form-group-lg .form-control+.form-control-feedback,.input-group-lg+.form-control-feedback,.input-group-lg>.form-control+.form-control-feedback,.input-group-lg>.input-group-addon+.form-control-feedback,.input-group-lg>.input-group-btn>.btn+.form-control-feedback,.input-lg+.form-control-feedback{width:46px;height:46px;line-height:46px}.form-group-sm .form-control+.form-control-feedback,.input-group-sm+.form-control-feedback,.input-group-sm>.form-control+.form-control-feedback,.input-group-sm>.input-group-addon+.form-control-feedback,.input-group-sm>.input-group-btn>.btn+.form-control-feedback,.input-sm+.form-control-feedback{width:30px;height:30px;line-height:30px}.has-success .checkbox,.has-success .checkbox-inline,.has-success.checkbox-inline label,.has-success.checkbox label,.has-success .control-label,.has-success .help-block,.has-success .radio,.has-success .radio-inline,.has-success.radio-inline label,.has-success.radio label{color:#3c763d}.has-success .form-control{border-color:#3c763d;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-success .form-control:focus{border-color:#2b542c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168}.has-success .input-group-addon{color:#3c763d;border-color:#3c763d;background-color:#dff0d8}.has-success .form-control-feedback{color:#3c763d}.has-warning .checkbox,.has-warning .checkbox-inline,.has-warning.checkbox-inline label,.has-warning.checkbox label,.has-warning .control-label,.has-warning .help-block,.has-warning .radio,.has-warning .radio-inline,.has-warning.radio-inline label,.has-warning.radio label{color:#8a6d3b}.has-warning .form-control{border-color:#8a6d3b;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-warning .form-control:focus{border-color:#66512c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b}.has-warning .input-group-addon{color:#8a6d3b;border-color:#8a6d3b;background-color:#fcf8e3}.has-warning .form-control-feedback{color:#8a6d3b}.has-error .checkbox,.has-error .checkbox-inline,.has-error.checkbox-inline label,.has-error.checkbox label,.has-error .control-label,.has-error .help-block,.has-error .radio,.has-error .radio-inline,.has-error.radio-inline label,.has-error.radio label{color:#a94442}.has-error .form-control{border-color:#a94442;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-error .form-control:focus{border-color:#843534;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483}.has-error .input-group-addon{color:#a94442;border-color:#a94442;background-color:#f2dede}.has-error .form-control-feedback{color:#a94442}.has-feedback label~.form-control-feedback{top:27px}.has-feedback label.sr-only~.form-control-feedback{top:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#a4aaae}@media (min-width:768px){.form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-static{display:inline-block}.form-inline .input-group{display:inline-table;vertical-align:middle}.form-inline .input-group .form-control,.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn{width:auto}.form-inline .input-group>.form-control{width:100%}.form-inline .control-label{margin-bottom:0;vertical-align:middle}.form-inline .checkbox,.form-inline .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.form-inline .checkbox label,.form-inline .radio label{padding-left:0}.form-inline .checkbox input[type=checkbox],.form-inline .radio input[type=radio]{position:relative;margin-left:0}.form-inline .has-feedback .form-control-feedback{top:0}}.form-horizontal .checkbox,.form-horizontal .checkbox-inline,.form-horizontal .radio,.form-horizontal .radio-inline{margin-top:0;margin-bottom:0;padding-top:7px}.form-horizontal .checkbox,.form-horizontal .radio{min-height:29px}.form-horizontal .form-group{margin-left:-15px;margin-right:-15px}.form-horizontal .form-group:after,.form-horizontal .form-group:before{content:" ";display:table}.form-horizontal .form-group:after{clear:both}@media (min-width:768px){.form-horizontal .control-label{text-align:right;margin-bottom:0;padding-top:7px}}.form-horizontal .has-feedback .form-control-feedback{right:15px}@media (min-width:768px){.form-horizontal .form-group-lg .control-label{padding-top:11px;font-size:18px}}@media (min-width:768px){.form-horizontal .form-group-sm .control-label{padding-top:6px;font-size:12px}}.btn{display:inline-block;margin-bottom:0;font-weight:400;text-align:center;vertical-align:middle;-ms-touch-action:manipulation;touch-action:manipulation;cursor:pointer;background-image:none;border:1px solid transparent;white-space:nowrap;padding:6px 12px;font-size:14px;line-height:1.6;border-radius:4px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.btn.active.focus,.btn.active:focus,.btn.focus,.btn:active.focus,.btn:active:focus,.btn:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn.focus,.btn:focus,.btn:hover{color:#636b6f;text-decoration:none}.btn.active,.btn:active{outline:0;background-image:none;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{cursor:not-allowed;opacity:.65;filter:alpha(opacity=65);-webkit-box-shadow:none;box-shadow:none}a.btn.disabled,fieldset[disabled] a.btn{pointer-events:none}.btn-default{color:#636b6f;background-color:#fff;border-color:#ccc}.btn-default.focus,.btn-default:focus{color:#636b6f;background-color:#e6e5e5;border-color:#8c8c8c}.btn-default.active,.btn-default:active,.btn-default:hover,.open>.btn-default.dropdown-toggle{color:#636b6f;background-color:#e6e5e5;border-color:#adadad}.btn-default.active.focus,.btn-default.active:focus,.btn-default.active:hover,.btn-default:active.focus,.btn-default:active:focus,.btn-default:active:hover,.open>.btn-default.dropdown-toggle.focus,.open>.btn-default.dropdown-toggle:focus,.open>.btn-default.dropdown-toggle:hover{color:#636b6f;background-color:#d4d4d4;border-color:#8c8c8c}.btn-default.active,.btn-default:active,.open>.btn-default.dropdown-toggle{background-image:none}.btn-default.disabled.focus,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled].focus,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#636b6f}.btn-primary{color:#fff;background-color:#3097d1;border-color:#2a88bd}.btn-primary.focus,.btn-primary:focus{color:#fff;background-color:#2579a9;border-color:#133d55}.btn-primary.active,.btn-primary:active,.btn-primary:hover,.open>.btn-primary.dropdown-toggle{color:#fff;background-color:#2579a9;border-color:#1f648b}.btn-primary.active.focus,.btn-primary.active:focus,.btn-primary.active:hover,.btn-primary:active.focus,.btn-primary:active:focus,.btn-primary:active:hover,.open>.btn-primary.dropdown-toggle.focus,.open>.btn-primary.dropdown-toggle:focus,.open>.btn-primary.dropdown-toggle:hover{color:#fff;background-color:#1f648b;border-color:#133d55}.btn-primary.active,.btn-primary:active,.open>.btn-primary.dropdown-toggle{background-image:none}.btn-primary.disabled.focus,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled].focus,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#3097d1;border-color:#2a88bd}.btn-primary .badge{color:#3097d1;background-color:#fff}.btn-success{color:#fff;background-color:#2ab27b;border-color:#259d6d}.btn-success.focus,.btn-success:focus{color:#fff;background-color:#20895e;border-color:#0d3625}.btn-success.active,.btn-success:active,.btn-success:hover,.open>.btn-success.dropdown-toggle{color:#fff;background-color:#20895e;border-color:#196c4b}.btn-success.active.focus,.btn-success.active:focus,.btn-success.active:hover,.btn-success:active.focus,.btn-success:active:focus,.btn-success:active:hover,.open>.btn-success.dropdown-toggle.focus,.open>.btn-success.dropdown-toggle:focus,.open>.btn-success.dropdown-toggle:hover{color:#fff;background-color:#196c4b;border-color:#0d3625}.btn-success.active,.btn-success:active,.open>.btn-success.dropdown-toggle{background-image:none}.btn-success.disabled.focus,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled].focus,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#2ab27b;border-color:#259d6d}.btn-success .badge{color:#2ab27b;background-color:#fff}.btn-info{color:#fff;background-color:#8eb4cb;border-color:#7da8c3}.btn-info.focus,.btn-info:focus{color:#fff;background-color:#6b9dbb;border-color:#3d6983}.btn-info.active,.btn-info:active,.btn-info:hover,.open>.btn-info.dropdown-toggle{color:#fff;background-color:#6b9dbb;border-color:#538db0}.btn-info.active.focus,.btn-info.active:focus,.btn-info.active:hover,.btn-info:active.focus,.btn-info:active:focus,.btn-info:active:hover,.open>.btn-info.dropdown-toggle.focus,.open>.btn-info.dropdown-toggle:focus,.open>.btn-info.dropdown-toggle:hover{color:#fff;background-color:#538db0;border-color:#3d6983}.btn-info.active,.btn-info:active,.open>.btn-info.dropdown-toggle{background-image:none}.btn-info.disabled.focus,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled].focus,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#8eb4cb;border-color:#7da8c3}.btn-info .badge{color:#8eb4cb;background-color:#fff}.btn-warning{color:#fff;background-color:#cbb956;border-color:#c5b143}.btn-warning.focus,.btn-warning:focus{color:#fff;background-color:#b6a338;border-color:#685d20}.btn-warning.active,.btn-warning:active,.btn-warning:hover,.open>.btn-warning.dropdown-toggle{color:#fff;background-color:#b6a338;border-color:#9b8a30}.btn-warning.active.focus,.btn-warning.active:focus,.btn-warning.active:hover,.btn-warning:active.focus,.btn-warning:active:focus,.btn-warning:active:hover,.open>.btn-warning.dropdown-toggle.focus,.open>.btn-warning.dropdown-toggle:focus,.open>.btn-warning.dropdown-toggle:hover{color:#fff;background-color:#9b8a30;border-color:#685d20}.btn-warning.active,.btn-warning:active,.open>.btn-warning.dropdown-toggle{background-image:none}.btn-warning.disabled.focus,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled].focus,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#cbb956;border-color:#c5b143}.btn-warning .badge{color:#cbb956;background-color:#fff}.btn-danger{color:#fff;background-color:#bf5329;border-color:#aa4a24}.btn-danger.focus,.btn-danger:focus{color:#fff;background-color:#954120;border-color:#411c0e}.btn-danger.active,.btn-danger:active,.btn-danger:hover,.open>.btn-danger.dropdown-toggle{color:#fff;background-color:#954120;border-color:#78341a}.btn-danger.active.focus,.btn-danger.active:focus,.btn-danger.active:hover,.btn-danger:active.focus,.btn-danger:active:focus,.btn-danger:active:hover,.open>.btn-danger.dropdown-toggle.focus,.open>.btn-danger.dropdown-toggle:focus,.open>.btn-danger.dropdown-toggle:hover{color:#fff;background-color:#78341a;border-color:#411c0e}.btn-danger.active,.btn-danger:active,.open>.btn-danger.dropdown-toggle{background-image:none}.btn-danger.disabled.focus,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled].focus,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#bf5329;border-color:#aa4a24}.btn-danger .badge{color:#bf5329;background-color:#fff}.btn-link{color:#3097d1;font-weight:400;border-radius:0}.btn-link,.btn-link.active,.btn-link:active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:active,.btn-link:focus,.btn-link:hover{border-color:transparent}.btn-link:focus,.btn-link:hover{color:#216a94;text-decoration:underline;background-color:transparent}.btn-link[disabled]:focus,.btn-link[disabled]:hover,fieldset[disabled] .btn-link:focus,fieldset[disabled] .btn-link:hover{color:#777;text-decoration:none}.btn-group-lg>.btn,.btn-lg{padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.btn-group-sm>.btn,.btn-sm{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-group-xs>.btn,.btn-xs{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:5px}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{opacity:0;-webkit-transition:opacity .15s linear;transition:opacity .15s linear}.fade.in{opacity:1}.collapse{display:none}.collapse.in{display:block}tr.collapse.in{display:table-row}tbody.collapse.in{display:table-row-group}.collapsing{position:relative;height:0;overflow:hidden;-webkit-transition-property:height,visibility;transition-property:height,visibility;-webkit-transition-duration:.35s;transition-duration:.35s;-webkit-transition-timing-function:ease;transition-timing-function:ease}.caret{display:inline-block;width:0;height:0;margin-left:2px;vertical-align:middle;border-top:4px dashed;border-top:4px solid\9;border-right:4px solid transparent;border-left:4px solid transparent}.dropdown,.dropup{position:relative}.dropdown-toggle:focus{outline:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;list-style:none;font-size:14px;text-align:left;background-color:#fff;border:1px solid #ccc;border:1px solid rgba(0,0,0,.15);border-radius:4px;-webkit-box-shadow:0 6px 12px rgba(0,0,0,.175);box-shadow:0 6px 12px rgba(0,0,0,.175);background-clip:padding-box}.dropdown-menu.pull-right{right:0;left:auto}.dropdown-menu .divider{height:1px;margin:10px 0;overflow:hidden;background-color:#e5e5e5}.dropdown-menu>li>a{display:block;padding:3px 20px;clear:both;font-weight:400;line-height:1.6;color:#333;white-space:nowrap}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{text-decoration:none;color:#262626;background-color:#f5f5f5}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{color:#fff;text-decoration:none;outline:0;background-color:#3097d1}.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{color:#777}.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{text-decoration:none;background-color:transparent;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);cursor:not-allowed}.open>.dropdown-menu{display:block}.open>a{outline:0}.dropdown-menu-right{left:auto;right:0}.dropdown-menu-left{left:0;right:auto}.dropdown-header{display:block;padding:3px 20px;font-size:12px;line-height:1.6;color:#777;white-space:nowrap}.dropdown-backdrop{position:fixed;left:0;right:0;bottom:0;top:0;z-index:990}.pull-right>.dropdown-menu{right:0;left:auto}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{border-top:0;border-bottom:4px dashed;border-bottom:4px solid\9;content:""}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{top:auto;bottom:100%;margin-bottom:2px}@media (min-width:768px){.navbar-right .dropdown-menu{right:0;left:auto}.navbar-right .dropdown-menu-left{left:0;right:auto}}.btn-group,.btn-group-vertical{position:relative;display:inline-block;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;float:left}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:2}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{margin-left:-1px}.btn-toolbar{margin-left:-5px}.btn-toolbar:after,.btn-toolbar:before{content:" ";display:table}.btn-toolbar:after{clear:both}.btn-toolbar .btn,.btn-toolbar .btn-group,.btn-toolbar .input-group{float:left}.btn-toolbar>.btn,.btn-toolbar>.btn-group,.btn-toolbar>.input-group{margin-left:5px}.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0}.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-top-right-radius:0}.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.btn-group>.btn-group{float:left}.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-top-right-radius:0}.btn-group>.btn-group:last-child:not(:first-child)>.btn:first-child{border-bottom-left-radius:0;border-top-left-radius:0}.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0}.btn-group>.btn+.dropdown-toggle{padding-left:8px;padding-right:8px}.btn-group-lg.btn-group>.btn+.dropdown-toggle,.btn-group>.btn-lg+.dropdown-toggle{padding-left:12px;padding-right:12px}.btn-group.open .dropdown-toggle{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-group.open .dropdown-toggle.btn-link{-webkit-box-shadow:none;box-shadow:none}.btn .caret{margin-left:0}.btn-group-lg>.btn .caret,.btn-lg .caret{border-width:5px 5px 0;border-bottom-width:0}.dropup .btn-group-lg>.btn .caret,.dropup .btn-lg .caret{border-width:0 5px 5px}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{display:block;float:none;width:100%;max-width:100%}.btn-group-vertical>.btn-group:after,.btn-group-vertical>.btn-group:before{content:" ";display:table}.btn-group-vertical>.btn-group:after{clear:both}.btn-group-vertical>.btn-group>.btn{float:none}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn:not(:first-child):not(:last-child){border-radius:0}.btn-group-vertical>.btn:first-child:not(:last-child){border-top-right-radius:4px;border-top-left-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:last-child:not(:first-child){border-top-right-radius:0;border-top-left-radius:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group-vertical>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group-vertical>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-right-radius:0;border-top-left-radius:0}.btn-group-justified{display:table;width:100%;table-layout:fixed;border-collapse:separate}.btn-group-justified>.btn,.btn-group-justified>.btn-group{float:none;display:table-cell;width:1%}.btn-group-justified>.btn-group .btn{width:100%}.btn-group-justified>.btn-group .dropdown-menu{left:auto}[data-toggle=buttons]>.btn-group>.btn input[type=checkbox],[data-toggle=buttons]>.btn-group>.btn input[type=radio],[data-toggle=buttons]>.btn input[type=checkbox],[data-toggle=buttons]>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:table;border-collapse:separate}.input-group[class*=col-]{float:none;padding-left:0;padding-right:0}.input-group .form-control{position:relative;z-index:2;float:left;width:100%;margin-bottom:0}.input-group .form-control:focus{z-index:3}.input-group-addon,.input-group-btn,.input-group .form-control{display:table-cell}.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child),.input-group .form-control:not(:first-child):not(:last-child){border-radius:0}.input-group-addon,.input-group-btn{width:1%;white-space:nowrap;vertical-align:middle}.input-group-addon{padding:6px 12px;font-size:14px;font-weight:400;line-height:1;color:#555;text-align:center;background-color:#eee;border:1px solid #ccd0d2;border-radius:4px}.input-group-addon.input-sm,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.input-group-addon.btn{padding:5px 10px;font-size:12px;border-radius:3px}.input-group-addon.input-lg,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.input-group-addon.btn{padding:10px 16px;font-size:18px;border-radius:6px}.input-group-addon input[type=checkbox],.input-group-addon input[type=radio]{margin-top:0}.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn-group:not(:last-child)>.btn,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle),.input-group .form-control:first-child{border-bottom-right-radius:0;border-top-right-radius:0}.input-group-addon:first-child{border-right:0}.input-group-addon:last-child,.input-group-btn:first-child>.btn-group:not(:first-child)>.btn,.input-group-btn:first-child>.btn:not(:first-child),.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group>.btn,.input-group-btn:last-child>.dropdown-toggle,.input-group .form-control:last-child{border-bottom-left-radius:0;border-top-left-radius:0}.input-group-addon:last-child{border-left:0}.input-group-btn{font-size:0;white-space:nowrap}.input-group-btn,.input-group-btn>.btn{position:relative}.input-group-btn>.btn+.btn{margin-left:-1px}.input-group-btn>.btn:active,.input-group-btn>.btn:focus,.input-group-btn>.btn:hover{z-index:2}.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group{margin-right:-1px}.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group{z-index:2;margin-left:-1px}.nav{margin-bottom:0;padding-left:0;list-style:none}.nav:after,.nav:before{content:" ";display:table}.nav:after{clear:both}.nav>li,.nav>li>a{position:relative;display:block}.nav>li>a{padding:10px 15px}.nav>li>a:focus,.nav>li>a:hover{text-decoration:none;background-color:#eee}.nav>li.disabled>a{color:#777}.nav>li.disabled>a:focus,.nav>li.disabled>a:hover{color:#777;text-decoration:none;background-color:transparent;cursor:not-allowed}.nav .open>a,.nav .open>a:focus,.nav .open>a:hover{background-color:#eee;border-color:#3097d1}.nav .nav-divider{height:1px;margin:10px 0;overflow:hidden;background-color:#e5e5e5}.nav>li>a>img{max-width:none}.nav-tabs{border-bottom:1px solid #ddd}.nav-tabs>li{float:left;margin-bottom:-1px}.nav-tabs>li>a{margin-right:2px;line-height:1.6;border:1px solid transparent;border-radius:4px 4px 0 0}.nav-tabs>li>a:hover{border-color:#eee #eee #ddd}.nav-tabs>li.active>a,.nav-tabs>li.active>a:focus,.nav-tabs>li.active>a:hover{color:#555;background-color:#f5f8fa;border:1px solid #ddd;border-bottom-color:transparent;cursor:default}.nav-pills>li{float:left}.nav-pills>li>a{border-radius:4px}.nav-pills>li+li{margin-left:2px}.nav-pills>li.active>a,.nav-pills>li.active>a:focus,.nav-pills>li.active>a:hover{color:#fff;background-color:#3097d1}.nav-stacked>li{float:none}.nav-stacked>li+li{margin-top:2px;margin-left:0}.nav-justified,.nav-tabs.nav-justified{width:100%}.nav-justified>li,.nav-tabs.nav-justified>li{float:none}.nav-justified>li>a,.nav-tabs.nav-justified>li>a{text-align:center;margin-bottom:5px}.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-justified>li,.nav-tabs.nav-justified>li{display:table-cell;width:1%}.nav-justified>li>a,.nav-tabs.nav-justified>li>a{margin-bottom:0}}.nav-tabs-justified,.nav-tabs.nav-justified{border-bottom:0}.nav-tabs-justified>li>a,.nav-tabs.nav-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover,.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs-justified>li>a,.nav-tabs.nav-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover,.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border-bottom-color:#f5f8fa}}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-right-radius:0;border-top-left-radius:0}.navbar{position:relative;min-height:50px;margin-bottom:22px;border:1px solid transparent}.navbar:after,.navbar:before{content:" ";display:table}.navbar:after{clear:both}@media (min-width:768px){.navbar{border-radius:4px}}.navbar-header:after,.navbar-header:before{content:" ";display:table}.navbar-header:after{clear:both}@media (min-width:768px){.navbar-header{float:left}}.navbar-collapse{overflow-x:visible;padding-right:15px;padding-left:15px;border-top:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 hsla(0,0%,100%,.1);box-shadow:inset 0 1px 0 hsla(0,0%,100%,.1);-webkit-overflow-scrolling:touch}.navbar-collapse:after,.navbar-collapse:before{content:" ";display:table}.navbar-collapse:after{clear:both}.navbar-collapse.in{overflow-y:auto}@media (min-width:768px){.navbar-collapse{width:auto;border-top:0;-webkit-box-shadow:none;box-shadow:none}.navbar-collapse.collapse{display:block!important;height:auto!important;padding-bottom:0;overflow:visible!important}.navbar-collapse.in{overflow-y:visible}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse{padding-left:0;padding-right:0}}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:340px}@media (max-device-width:480px) and (orientation:landscape){.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:200px}}.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:0;margin-left:0}}.navbar-static-top{z-index:1000;border-width:0 0 1px}@media (min-width:768px){.navbar-static-top{border-radius:0}}.navbar-fixed-bottom,.navbar-fixed-top{position:fixed;right:0;left:0;z-index:1030}@media (min-width:768px){.navbar-fixed-bottom,.navbar-fixed-top{border-radius:0}}.navbar-fixed-top{top:0;border-width:0 0 1px}.navbar-fixed-bottom{bottom:0;margin-bottom:0;border-width:1px 0 0}.navbar-brand{float:left;padding:14px 15px;font-size:18px;line-height:22px;height:50px}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-brand>img{display:block}@media (min-width:768px){.navbar>.container-fluid .navbar-brand,.navbar>.container .navbar-brand{margin-left:-15px}}.navbar-toggle{position:relative;float:right;margin-right:15px;padding:9px 10px;margin-top:8px;margin-bottom:8px;background-color:transparent;background-image:none;border:1px solid transparent;border-radius:4px}.navbar-toggle:focus{outline:0}.navbar-toggle .icon-bar{display:block;width:22px;height:2px;border-radius:1px}.navbar-toggle .icon-bar+.icon-bar{margin-top:4px}@media (min-width:768px){.navbar-toggle{display:none}}.navbar-nav{margin:7px -15px}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px;line-height:22px}@media (max-width:767px){.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;-webkit-box-shadow:none;box-shadow:none}.navbar-nav .open .dropdown-menu .dropdown-header,.navbar-nav .open .dropdown-menu>li>a{padding:5px 15px 5px 25px}.navbar-nav .open .dropdown-menu>li>a{line-height:22px}.navbar-nav .open .dropdown-menu>li>a:focus,.navbar-nav .open .dropdown-menu>li>a:hover{background-image:none}}@media (min-width:768px){.navbar-nav{float:left;margin:0}.navbar-nav>li{float:left}.navbar-nav>li>a{padding-top:14px;padding-bottom:14px}}.navbar-form{margin:7px -15px;padding:10px 15px;border-top:1px solid transparent;border-bottom:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 hsla(0,0%,100%,.1),0 1px 0 hsla(0,0%,100%,.1);box-shadow:inset 0 1px 0 hsla(0,0%,100%,.1),0 1px 0 hsla(0,0%,100%,.1)}@media (min-width:768px){.navbar-form .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.navbar-form .form-control{display:inline-block;width:auto;vertical-align:middle}.navbar-form .form-control-static{display:inline-block}.navbar-form .input-group{display:inline-table;vertical-align:middle}.navbar-form .input-group .form-control,.navbar-form .input-group .input-group-addon,.navbar-form .input-group .input-group-btn{width:auto}.navbar-form .input-group>.form-control{width:100%}.navbar-form .control-label{margin-bottom:0;vertical-align:middle}.navbar-form .checkbox,.navbar-form .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.navbar-form .checkbox label,.navbar-form .radio label{padding-left:0}.navbar-form .checkbox input[type=checkbox],.navbar-form .radio input[type=radio]{position:relative;margin-left:0}.navbar-form .has-feedback .form-control-feedback{top:0}}@media (max-width:767px){.navbar-form .form-group{margin-bottom:5px}.navbar-form .form-group:last-child{margin-bottom:0}}@media (min-width:768px){.navbar-form{width:auto;border:0;margin-left:0;margin-right:0;padding-top:0;padding-bottom:0;-webkit-box-shadow:none;box-shadow:none}}.navbar-nav>li>.dropdown-menu{margin-top:0;border-top-right-radius:0;border-top-left-radius:0}.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{margin-bottom:0;border-top-right-radius:4px;border-top-left-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.navbar-btn{margin-top:7px;margin-bottom:7px}.btn-group-sm>.navbar-btn.btn,.navbar-btn.btn-sm{margin-top:10px;margin-bottom:10px}.btn-group-xs>.navbar-btn.btn,.navbar-btn.btn-xs,.navbar-text{margin-top:14px;margin-bottom:14px}@media (min-width:768px){.navbar-text{float:left;margin-left:15px;margin-right:15px}}@media (min-width:768px){.navbar-left{float:left!important}.navbar-right{float:right!important;margin-right:-15px}.navbar-right~.navbar-right{margin-right:0}}.navbar-default{background-color:#fff;border-color:#d3e0e9}.navbar-default .navbar-brand{color:#777}.navbar-default .navbar-brand:focus,.navbar-default .navbar-brand:hover{color:#5e5d5d;background-color:transparent}.navbar-default .navbar-nav>li>a,.navbar-default .navbar-text{color:#777}.navbar-default .navbar-nav>li>a:focus,.navbar-default .navbar-nav>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:focus,.navbar-default .navbar-nav>.active>a:hover{color:#555;background-color:#eee}.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:focus,.navbar-default .navbar-nav>.disabled>a:hover{color:#ccc;background-color:transparent}.navbar-default .navbar-toggle{border-color:#ddd}.navbar-default .navbar-toggle:focus,.navbar-default .navbar-toggle:hover{background-color:#ddd}.navbar-default .navbar-toggle .icon-bar{background-color:#888}.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#d3e0e9}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:focus,.navbar-default .navbar-nav>.open>a:hover{background-color:#eee;color:#555}@media (max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#777}.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover{color:#555;background-color:#eee}.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#ccc;background-color:transparent}}.navbar-default .navbar-link{color:#777}.navbar-default .navbar-link:hover{color:#333}.navbar-default .btn-link{color:#777}.navbar-default .btn-link:focus,.navbar-default .btn-link:hover{color:#333}.navbar-default .btn-link[disabled]:focus,.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-default .btn-link:focus,fieldset[disabled] .navbar-default .btn-link:hover{color:#ccc}.navbar-inverse{background-color:#222;border-color:#090909}.navbar-inverse .navbar-brand{color:#9d9d9d}.navbar-inverse .navbar-brand:focus,.navbar-inverse .navbar-brand:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav>li>a,.navbar-inverse .navbar-text{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a:focus,.navbar-inverse .navbar-nav>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:focus,.navbar-inverse .navbar-nav>.active>a:hover{color:#fff;background-color:#090909}.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:focus,.navbar-inverse .navbar-nav>.disabled>a:hover{color:#444;background-color:transparent}.navbar-inverse .navbar-toggle{border-color:#333}.navbar-inverse .navbar-toggle:focus,.navbar-inverse .navbar-toggle:hover{background-color:#333}.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff}.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#101010}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:focus,.navbar-inverse .navbar-nav>.open>a:hover{background-color:#090909;color:#fff}@media (max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#090909}.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#090909}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-color:#090909}.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#444;background-color:transparent}}.navbar-inverse .navbar-link{color:#9d9d9d}.navbar-inverse .navbar-link:hover{color:#fff}.navbar-inverse .btn-link{color:#9d9d9d}.navbar-inverse .btn-link:focus,.navbar-inverse .btn-link:hover{color:#fff}.navbar-inverse .btn-link[disabled]:focus,.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-inverse .btn-link:focus,fieldset[disabled] .navbar-inverse .btn-link:hover{color:#444}.breadcrumb{padding:8px 15px;margin-bottom:22px;list-style:none;background-color:#f5f5f5;border-radius:4px}.breadcrumb>li{display:inline-block}.breadcrumb>li+li:before{content:"/\A0";padding:0 5px;color:#ccc}.breadcrumb>.active{color:#777}.pagination{display:inline-block;padding-left:0;margin:22px 0;border-radius:4px}.pagination>li{display:inline}.pagination>li>a,.pagination>li>span{position:relative;float:left;padding:6px 12px;line-height:1.6;text-decoration:none;color:#3097d1;background-color:#fff;border:1px solid #ddd;margin-left:-1px}.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-bottom-left-radius:4px;border-top-left-radius:4px}.pagination>li:last-child>a,.pagination>li:last-child>span{border-bottom-right-radius:4px;border-top-right-radius:4px}.pagination>li>a:focus,.pagination>li>a:hover,.pagination>li>span:focus,.pagination>li>span:hover{z-index:2;color:#216a94;background-color:#eee;border-color:#ddd}.pagination>.active>a,.pagination>.active>a:focus,.pagination>.active>a:hover,.pagination>.active>span,.pagination>.active>span:focus,.pagination>.active>span:hover{z-index:3;color:#fff;background-color:#3097d1;border-color:#3097d1;cursor:default}.pagination>.disabled>a,.pagination>.disabled>a:focus,.pagination>.disabled>a:hover,.pagination>.disabled>span,.pagination>.disabled>span:focus,.pagination>.disabled>span:hover{color:#777;background-color:#fff;border-color:#ddd;cursor:not-allowed}.pagination-lg>li>a,.pagination-lg>li>span{padding:10px 16px;font-size:18px;line-height:1.3333333}.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-bottom-left-radius:6px;border-top-left-radius:6px}.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-bottom-right-radius:6px;border-top-right-radius:6px}.pagination-sm>li>a,.pagination-sm>li>span{padding:5px 10px;font-size:12px;line-height:1.5}.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-bottom-left-radius:3px;border-top-left-radius:3px}.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-bottom-right-radius:3px;border-top-right-radius:3px}.pager{padding-left:0;margin:22px 0;list-style:none;text-align:center}.pager:after,.pager:before{content:" ";display:table}.pager:after{clear:both}.pager li{display:inline}.pager li>a,.pager li>span{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;border-radius:15px}.pager li>a:focus,.pager li>a:hover{text-decoration:none;background-color:#eee}.pager .next>a,.pager .next>span{float:right}.pager .previous>a,.pager .previous>span{float:left}.pager .disabled>a,.pager .disabled>a:focus,.pager .disabled>a:hover,.pager .disabled>span{color:#777;background-color:#fff;cursor:not-allowed}.label{display:inline;padding:.2em .6em .3em;font-size:75%;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25em}.label:empty{display:none}.btn .label{position:relative;top:-1px}a.label:focus,a.label:hover{color:#fff;text-decoration:none;cursor:pointer}.label-default{background-color:#777}.label-default[href]:focus,.label-default[href]:hover{background-color:#5e5e5e}.label-primary{background-color:#3097d1}.label-primary[href]:focus,.label-primary[href]:hover{background-color:#2579a9}.label-success{background-color:#2ab27b}.label-success[href]:focus,.label-success[href]:hover{background-color:#20895e}.label-info{background-color:#8eb4cb}.label-info[href]:focus,.label-info[href]:hover{background-color:#6b9dbb}.label-warning{background-color:#cbb956}.label-warning[href]:focus,.label-warning[href]:hover{background-color:#b6a338}.label-danger{background-color:#bf5329}.label-danger[href]:focus,.label-danger[href]:hover{background-color:#954120}.badge{display:inline-block;min-width:10px;padding:3px 7px;font-size:12px;font-weight:700;color:#fff;line-height:1;vertical-align:middle;white-space:nowrap;text-align:center;background-color:#777;border-radius:10px}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.btn-group-xs>.btn .badge,.btn-xs .badge{top:0;padding:1px 5px}.list-group-item.active>.badge,.nav-pills>.active>a>.badge{color:#3097d1;background-color:#fff}.list-group-item>.badge{float:right}.list-group-item>.badge+.badge{margin-right:5px}.nav-pills>li>a>.badge{margin-left:3px}a.badge:focus,a.badge:hover{color:#fff;text-decoration:none;cursor:pointer}.jumbotron{padding-top:30px;padding-bottom:30px;margin-bottom:30px;background-color:#eee}.jumbotron,.jumbotron .h1,.jumbotron h1{color:inherit}.jumbotron p{margin-bottom:15px;font-size:21px;font-weight:200}.jumbotron>hr{border-top-color:#d5d5d5}.container-fluid .jumbotron,.container .jumbotron{border-radius:6px;padding-left:15px;padding-right:15px}.jumbotron .container{max-width:100%}@media screen and (min-width:768px){.jumbotron{padding-top:48px;padding-bottom:48px}.container-fluid .jumbotron,.container .jumbotron{padding-left:60px;padding-right:60px}.jumbotron .h1,.jumbotron h1{font-size:63px}}.thumbnail{display:block;padding:4px;margin-bottom:22px;line-height:1.6;background-color:#f5f8fa;border:1px solid #ddd;border-radius:4px;-webkit-transition:border .2s ease-in-out;transition:border .2s ease-in-out}.thumbnail>img,.thumbnail a>img{display:block;max-width:100%;height:auto;margin-left:auto;margin-right:auto}.thumbnail .caption{padding:9px;color:#636b6f}a.thumbnail.active,a.thumbnail:focus,a.thumbnail:hover{border-color:#3097d1}.alert{padding:15px;margin-bottom:22px;border:1px solid transparent;border-radius:4px}.alert h4{margin-top:0;color:inherit}.alert .alert-link{font-weight:700}.alert>p,.alert>ul{margin-bottom:0}.alert>p+p{margin-top:5px}.alert-dismissable,.alert-dismissible{padding-right:35px}.alert-dismissable .close,.alert-dismissible .close{position:relative;top:-2px;right:-21px;color:inherit}.alert-success{background-color:#dff0d8;border-color:#d6e9c6;color:#3c763d}.alert-success hr{border-top-color:#c9e2b3}.alert-success .alert-link{color:#2b542c}.alert-info{background-color:#d9edf7;border-color:#bce8f1;color:#31708f}.alert-info hr{border-top-color:#a6e1ec}.alert-info .alert-link{color:#245269}.alert-warning{background-color:#fcf8e3;border-color:#faebcc;color:#8a6d3b}.alert-warning hr{border-top-color:#f7e1b5}.alert-warning .alert-link{color:#66512c}.alert-danger{background-color:#f2dede;border-color:#ebccd1;color:#a94442}.alert-danger hr{border-top-color:#e4b9c0}.alert-danger .alert-link{color:#843534}@-webkit-keyframes progress-bar-stripes{0%{background-position:40px 0}to{background-position:0 0}}@keyframes progress-bar-stripes{0%{background-position:40px 0}to{background-position:0 0}}.progress{overflow:hidden;height:22px;margin-bottom:22px;background-color:#f5f5f5;border-radius:4px;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}.progress-bar{float:left;width:0;height:100%;font-size:12px;line-height:22px;color:#fff;text-align:center;background-color:#3097d1;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);-webkit-transition:width .6s ease;transition:width .6s ease}.progress-bar-striped,.progress-striped .progress-bar{background-image:linear-gradient(45deg,hsla(0,0%,100%,.15) 25%,transparent 0,transparent 50%,hsla(0,0%,100%,.15) 0,hsla(0,0%,100%,.15) 75%,transparent 0,transparent);background-size:40px 40px}.progress-bar.active,.progress.active .progress-bar{-webkit-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite}.progress-bar-success{background-color:#2ab27b}.progress-striped .progress-bar-success{background-image:linear-gradient(45deg,hsla(0,0%,100%,.15) 25%,transparent 0,transparent 50%,hsla(0,0%,100%,.15) 0,hsla(0,0%,100%,.15) 75%,transparent 0,transparent)}.progress-bar-info{background-color:#8eb4cb}.progress-striped .progress-bar-info{background-image:linear-gradient(45deg,hsla(0,0%,100%,.15) 25%,transparent 0,transparent 50%,hsla(0,0%,100%,.15) 0,hsla(0,0%,100%,.15) 75%,transparent 0,transparent)}.progress-bar-warning{background-color:#cbb956}.progress-striped .progress-bar-warning{background-image:linear-gradient(45deg,hsla(0,0%,100%,.15) 25%,transparent 0,transparent 50%,hsla(0,0%,100%,.15) 0,hsla(0,0%,100%,.15) 75%,transparent 0,transparent)}.progress-bar-danger{background-color:#bf5329}.progress-striped .progress-bar-danger{background-image:linear-gradient(45deg,hsla(0,0%,100%,.15) 25%,transparent 0,transparent 50%,hsla(0,0%,100%,.15) 0,hsla(0,0%,100%,.15) 75%,transparent 0,transparent)}.media{margin-top:15px}.media:first-child{margin-top:0}.media,.media-body{zoom:1;overflow:hidden}.media-body{width:10000px}.media-object{display:block}.media-object.img-thumbnail{max-width:none}.media-right,.media>.pull-right{padding-left:10px}.media-left,.media>.pull-left{padding-right:10px}.media-body,.media-left,.media-right{display:table-cell;vertical-align:top}.media-middle{vertical-align:middle}.media-bottom{vertical-align:bottom}.media-heading{margin-top:0;margin-bottom:5px}.media-list{padding-left:0;list-style:none}.list-group{margin-bottom:20px;padding-left:0}.list-group-item{position:relative;display:block;padding:10px 15px;margin-bottom:-1px;background-color:#fff;border:1px solid #d3e0e9}.list-group-item:first-child{border-top-right-radius:4px;border-top-left-radius:4px}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}a.list-group-item,button.list-group-item{color:#555}a.list-group-item .list-group-item-heading,button.list-group-item .list-group-item-heading{color:#333}a.list-group-item:focus,a.list-group-item:hover,button.list-group-item:focus,button.list-group-item:hover{text-decoration:none;color:#555;background-color:#f5f5f5}button.list-group-item{width:100%;text-align:left}.list-group-item.disabled,.list-group-item.disabled:focus,.list-group-item.disabled:hover{background-color:#eee;color:#777;cursor:not-allowed}.list-group-item.disabled .list-group-item-heading,.list-group-item.disabled:focus .list-group-item-heading,.list-group-item.disabled:hover .list-group-item-heading{color:inherit}.list-group-item.disabled .list-group-item-text,.list-group-item.disabled:focus .list-group-item-text,.list-group-item.disabled:hover .list-group-item-text{color:#777}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{z-index:2;color:#fff;background-color:#3097d1;border-color:#3097d1}.list-group-item.active .list-group-item-heading,.list-group-item.active .list-group-item-heading>.small,.list-group-item.active .list-group-item-heading>small,.list-group-item.active:focus .list-group-item-heading,.list-group-item.active:focus .list-group-item-heading>.small,.list-group-item.active:focus .list-group-item-heading>small,.list-group-item.active:hover .list-group-item-heading,.list-group-item.active:hover .list-group-item-heading>.small,.list-group-item.active:hover .list-group-item-heading>small{color:inherit}.list-group-item.active .list-group-item-text,.list-group-item.active:focus .list-group-item-text,.list-group-item.active:hover .list-group-item-text{color:#d7ebf6}.list-group-item-success{color:#3c763d;background-color:#dff0d8}a.list-group-item-success,button.list-group-item-success{color:#3c763d}a.list-group-item-success .list-group-item-heading,button.list-group-item-success .list-group-item-heading{color:inherit}a.list-group-item-success:focus,a.list-group-item-success:hover,button.list-group-item-success:focus,button.list-group-item-success:hover{color:#3c763d;background-color:#d0e9c6}a.list-group-item-success.active,a.list-group-item-success.active:focus,a.list-group-item-success.active:hover,button.list-group-item-success.active,button.list-group-item-success.active:focus,button.list-group-item-success.active:hover{color:#fff;background-color:#3c763d;border-color:#3c763d}.list-group-item-info{color:#31708f;background-color:#d9edf7}a.list-group-item-info,button.list-group-item-info{color:#31708f}a.list-group-item-info .list-group-item-heading,button.list-group-item-info .list-group-item-heading{color:inherit}a.list-group-item-info:focus,a.list-group-item-info:hover,button.list-group-item-info:focus,button.list-group-item-info:hover{color:#31708f;background-color:#c4e3f3}a.list-group-item-info.active,a.list-group-item-info.active:focus,a.list-group-item-info.active:hover,button.list-group-item-info.active,button.list-group-item-info.active:focus,button.list-group-item-info.active:hover{color:#fff;background-color:#31708f;border-color:#31708f}.list-group-item-warning{color:#8a6d3b;background-color:#fcf8e3}a.list-group-item-warning,button.list-group-item-warning{color:#8a6d3b}a.list-group-item-warning .list-group-item-heading,button.list-group-item-warning .list-group-item-heading{color:inherit}a.list-group-item-warning:focus,a.list-group-item-warning:hover,button.list-group-item-warning:focus,button.list-group-item-warning:hover{color:#8a6d3b;background-color:#faf2cc}a.list-group-item-warning.active,a.list-group-item-warning.active:focus,a.list-group-item-warning.active:hover,button.list-group-item-warning.active,button.list-group-item-warning.active:focus,button.list-group-item-warning.active:hover{color:#fff;background-color:#8a6d3b;border-color:#8a6d3b}.list-group-item-danger{color:#a94442;background-color:#f2dede}a.list-group-item-danger,button.list-group-item-danger{color:#a94442}a.list-group-item-danger .list-group-item-heading,button.list-group-item-danger .list-group-item-heading{color:inherit}a.list-group-item-danger:focus,a.list-group-item-danger:hover,button.list-group-item-danger:focus,button.list-group-item-danger:hover{color:#a94442;background-color:#ebcccc}a.list-group-item-danger.active,a.list-group-item-danger.active:focus,a.list-group-item-danger.active:hover,button.list-group-item-danger.active,button.list-group-item-danger.active:focus,button.list-group-item-danger.active:hover{color:#fff;background-color:#a94442;border-color:#a94442}.list-group-item-heading{margin-top:0;margin-bottom:5px}.list-group-item-text{margin-bottom:0;line-height:1.3}.panel{margin-bottom:22px;background-color:#fff;border:1px solid transparent;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0,0,0,.05);box-shadow:0 1px 1px rgba(0,0,0,.05)}.panel-body{padding:15px}.panel-body:after,.panel-body:before{content:" ";display:table}.panel-body:after{clear:both}.panel-heading{padding:10px 15px;border-bottom:1px solid transparent;border-top-right-radius:3px;border-top-left-radius:3px}.panel-heading>.dropdown .dropdown-toggle,.panel-title{color:inherit}.panel-title{margin-top:0;margin-bottom:0;font-size:16px}.panel-title>.small,.panel-title>.small>a,.panel-title>a,.panel-title>small,.panel-title>small>a{color:inherit}.panel-footer{padding:10px 15px;background-color:#f5f5f5;border-top:1px solid #d3e0e9;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.list-group,.panel>.panel-collapse>.list-group{margin-bottom:0}.panel>.list-group .list-group-item,.panel>.panel-collapse>.list-group .list-group-item{border-width:1px 0;border-radius:0}.panel>.list-group:first-child .list-group-item:first-child,.panel>.panel-collapse>.list-group:first-child .list-group-item:first-child{border-top:0;border-top-right-radius:3px;border-top-left-radius:3px}.panel>.list-group:last-child .list-group-item:last-child,.panel>.panel-collapse>.list-group:last-child .list-group-item:last-child{border-bottom:0;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.panel-heading+.panel-collapse>.list-group .list-group-item:first-child{border-top-right-radius:0;border-top-left-radius:0}.list-group+.panel-footer,.panel-heading+.list-group .list-group-item:first-child{border-top-width:0}.panel>.panel-collapse>.table,.panel>.table,.panel>.table-responsive>.table{margin-bottom:0}.panel>.panel-collapse>.table caption,.panel>.table-responsive>.table caption,.panel>.table caption{padding-left:15px;padding-right:15px}.panel>.table-responsive:first-child>.table:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,.panel>.table:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child,.panel>.table:first-child>thead:first-child>tr:first-child{border-top-right-radius:3px;border-top-left-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table:first-child>thead:first-child>tr:first-child th:first-child{border-top-left-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table:first-child>thead:first-child>tr:first-child th:last-child{border-top-right-radius:3px}.panel>.table-responsive:last-child>.table:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child,.panel>.table:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child{border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child{border-bottom-right-radius:3px}.panel>.panel-body+.table,.panel>.panel-body+.table-responsive,.panel>.table+.panel-body,.panel>.table-responsive+.panel-body{border-top:1px solid #ddd}.panel>.table>tbody:first-child>tr:first-child td,.panel>.table>tbody:first-child>tr:first-child th{border-top:0}.panel>.table-bordered,.panel>.table-responsive>.table-bordered{border:0}.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.panel>.table-bordered>tbody>tr:first-child>td,.panel>.table-bordered>tbody>tr:first-child>th,.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-bordered>thead>tr:first-child>td,.panel>.table-bordered>thead>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>thead>tr:first-child>td,.panel>.table-responsive>.table-bordered>thead>tr:first-child>th{border-bottom:0}.panel>.table-responsive{border:0;margin-bottom:0}.panel-group{margin-bottom:22px}.panel-group .panel{margin-bottom:0;border-radius:4px}.panel-group .panel+.panel{margin-top:5px}.panel-group .panel-heading{border-bottom:0}.panel-group .panel-heading+.panel-collapse>.list-group,.panel-group .panel-heading+.panel-collapse>.panel-body{border-top:1px solid #d3e0e9}.panel-group .panel-footer{border-top:0}.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:1px solid #d3e0e9}.panel-default{border-color:#d3e0e9}.panel-default>.panel-heading{color:#333;background-color:#fff;border-color:#d3e0e9}.panel-default>.panel-heading+.panel-collapse>.panel-body{border-top-color:#d3e0e9}.panel-default>.panel-heading .badge{color:#fff;background-color:#333}.panel-default>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#d3e0e9}.panel-primary{border-color:#3097d1}.panel-primary>.panel-heading{color:#fff;background-color:#3097d1;border-color:#3097d1}.panel-primary>.panel-heading+.panel-collapse>.panel-body{border-top-color:#3097d1}.panel-primary>.panel-heading .badge{color:#3097d1;background-color:#fff}.panel-primary>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#3097d1}.panel-success{border-color:#d6e9c6}.panel-success>.panel-heading{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.panel-success>.panel-heading+.panel-collapse>.panel-body{border-top-color:#d6e9c6}.panel-success>.panel-heading .badge{color:#dff0d8;background-color:#3c763d}.panel-success>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#d6e9c6}.panel-info{border-color:#bce8f1}.panel-info>.panel-heading{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.panel-info>.panel-heading+.panel-collapse>.panel-body{border-top-color:#bce8f1}.panel-info>.panel-heading .badge{color:#d9edf7;background-color:#31708f}.panel-info>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#bce8f1}.panel-warning{border-color:#faebcc}.panel-warning>.panel-heading{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.panel-warning>.panel-heading+.panel-collapse>.panel-body{border-top-color:#faebcc}.panel-warning>.panel-heading .badge{color:#fcf8e3;background-color:#8a6d3b}.panel-warning>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#faebcc}.panel-danger{border-color:#ebccd1}.panel-danger>.panel-heading{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.panel-danger>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ebccd1}.panel-danger>.panel-heading .badge{color:#f2dede;background-color:#a94442}.panel-danger>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ebccd1}.embed-responsive{position:relative;display:block;height:0;padding:0;overflow:hidden}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;left:0;bottom:0;height:100%;width:100%;border:0}.embed-responsive-16by9{padding-bottom:56.25%}.embed-responsive-4by3{padding-bottom:75%}.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#f5f5f5;border:1px solid #e3e3e3;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.05);box-shadow:inset 0 1px 1px rgba(0,0,0,.05)}.well blockquote{border-color:#ddd;border-color:rgba(0,0,0,.15)}.well-lg{padding:24px;border-radius:6px}.well-sm{padding:9px;border-radius:3px}.close{float:right;font-size:21px;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;opacity:.2;filter:alpha(opacity=20)}.close:focus,.close:hover{color:#000;text-decoration:none;cursor:pointer;opacity:.5;filter:alpha(opacity=50)}button.close{padding:0;cursor:pointer;background:transparent;border:0;-webkit-appearance:none}.modal,.modal-open{overflow:hidden}.modal{display:none;position:fixed;top:0;right:0;bottom:0;left:0;z-index:1050;-webkit-overflow-scrolling:touch;outline:0}.modal.fade .modal-dialog{-webkit-transform:translateY(-25%);transform:translateY(-25%);-webkit-transition:-webkit-transform .3s ease-out;transition:-webkit-transform .3s ease-out;transition:transform .3s ease-out;transition:transform .3s ease-out,-webkit-transform .3s ease-out}.modal.in .modal-dialog{-webkit-transform:translate(0);transform:translate(0)}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal-dialog{position:relative;width:auto;margin:10px}.modal-content{position:relative;background-color:#fff;border:1px solid #999;border:1px solid rgba(0,0,0,.2);border-radius:6px;-webkit-box-shadow:0 3px 9px rgba(0,0,0,.5);box-shadow:0 3px 9px rgba(0,0,0,.5);background-clip:padding-box;outline:0}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000}.modal-backdrop.fade{opacity:0;filter:alpha(opacity=0)}.modal-backdrop.in{opacity:.5;filter:alpha(opacity=50)}.modal-header{padding:15px;border-bottom:1px solid #e5e5e5}.modal-header:after,.modal-header:before{content:" ";display:table}.modal-header:after{clear:both}.modal-header .close{margin-top:-2px}.modal-title{margin:0;line-height:1.6}.modal-body{position:relative;padding:15px}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}.modal-footer:after,.modal-footer:before{content:" ";display:table}.modal-footer:after{clear:both}.modal-footer .btn+.btn{margin-left:5px;margin-bottom:0}.modal-footer .btn-group .btn+.btn{margin-left:-1px}.modal-footer .btn-block+.btn-block{margin-left:0}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:768px){.modal-dialog{width:600px;margin:30px auto}.modal-content{-webkit-box-shadow:0 5px 15px rgba(0,0,0,.5);box-shadow:0 5px 15px rgba(0,0,0,.5)}.modal-sm{width:300px}}@media (min-width:992px){.modal-lg{width:900px}}.tooltip{position:absolute;z-index:1070;display:block;font-family:Raleway,sans-serif;font-style:normal;font-weight:400;letter-spacing:normal;line-break:auto;line-height:1.6;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;white-space:normal;word-break:normal;word-spacing:normal;word-wrap:normal;font-size:12px;opacity:0;filter:alpha(opacity=0)}.tooltip.in{opacity:.9;filter:alpha(opacity=90)}.tooltip.top{margin-top:-3px;padding:5px 0}.tooltip.right{margin-left:3px;padding:0 5px}.tooltip.bottom{margin-top:3px;padding:5px 0}.tooltip.left{margin-left:-3px;padding:0 5px}.tooltip-inner{max-width:200px;padding:3px 8px;color:#fff;text-align:center;background-color:#000;border-radius:4px}.tooltip-arrow{position:absolute;width:0;height:0;border-color:transparent;border-style:solid}.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-left .tooltip-arrow{right:5px}.tooltip.top-left .tooltip-arrow,.tooltip.top-right .tooltip-arrow{bottom:0;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-right .tooltip-arrow{left:5px}.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#000}.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#000}.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-left .tooltip-arrow{top:0;right:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-right .tooltip-arrow{top:0;left:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.popover{position:absolute;top:0;left:0;z-index:1060;display:none;max-width:276px;padding:1px;font-family:Raleway,sans-serif;font-style:normal;font-weight:400;letter-spacing:normal;line-break:auto;line-height:1.6;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;white-space:normal;word-break:normal;word-spacing:normal;word-wrap:normal;font-size:14px;background-color:#fff;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.2);border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,.2);box-shadow:0 5px 10px rgba(0,0,0,.2)}.popover.top{margin-top:-10px}.popover.right{margin-left:10px}.popover.bottom{margin-top:10px}.popover.left{margin-left:-10px}.popover-title{margin:0;padding:8px 14px;font-size:14px;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-radius:5px 5px 0 0}.popover-content{padding:9px 14px}.popover>.arrow,.popover>.arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.popover>.arrow{border-width:11px}.popover>.arrow:after{border-width:10px;content:""}.popover.top>.arrow{left:50%;margin-left:-11px;border-bottom-width:0;border-top-color:#999;border-top-color:rgba(0,0,0,.25);bottom:-11px}.popover.top>.arrow:after{content:" ";bottom:1px;margin-left:-10px;border-bottom-width:0;border-top-color:#fff}.popover.right>.arrow{top:50%;left:-11px;margin-top:-11px;border-left-width:0;border-right-color:#999;border-right-color:rgba(0,0,0,.25)}.popover.right>.arrow:after{content:" ";left:1px;bottom:-10px;border-left-width:0;border-right-color:#fff}.popover.bottom>.arrow{left:50%;margin-left:-11px;border-top-width:0;border-bottom-color:#999;border-bottom-color:rgba(0,0,0,.25);top:-11px}.popover.bottom>.arrow:after{content:" ";top:1px;margin-left:-10px;border-top-width:0;border-bottom-color:#fff}.popover.left>.arrow{top:50%;right:-11px;margin-top:-11px;border-right-width:0;border-left-color:#999;border-left-color:rgba(0,0,0,.25)}.popover.left>.arrow:after{content:" ";right:1px;border-right-width:0;border-left-color:#fff;bottom:-10px}.carousel,.carousel-inner{position:relative}.carousel-inner{overflow:hidden;width:100%}.carousel-inner>.item{display:none;position:relative;-webkit-transition:left .6s ease-in-out;transition:left .6s ease-in-out}.carousel-inner>.item>a>img,.carousel-inner>.item>img{display:block;max-width:100%;height:auto;line-height:1}@media (-webkit-transform-3d),(transform-3d){.carousel-inner>.item{-webkit-transition:-webkit-transform .6s ease-in-out;transition:-webkit-transform .6s ease-in-out;transition:transform .6s ease-in-out;transition:transform .6s ease-in-out,-webkit-transform .6s ease-in-out;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-perspective:1000px;perspective:1000px}.carousel-inner>.item.active.right,.carousel-inner>.item.next{-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0);left:0}.carousel-inner>.item.active.left,.carousel-inner>.item.prev{-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0);left:0}.carousel-inner>.item.active,.carousel-inner>.item.next.left,.carousel-inner>.item.prev.right{-webkit-transform:translateZ(0);transform:translateZ(0);left:0}}.carousel-inner>.active,.carousel-inner>.next,.carousel-inner>.prev{display:block}.carousel-inner>.active{left:0}.carousel-inner>.next,.carousel-inner>.prev{position:absolute;top:0;width:100%}.carousel-inner>.next{left:100%}.carousel-inner>.prev{left:-100%}.carousel-inner>.next.left,.carousel-inner>.prev.right{left:0}.carousel-inner>.active.left{left:-100%}.carousel-inner>.active.right{left:100%}.carousel-control{position:absolute;top:0;left:0;bottom:0;width:15%;opacity:.5;filter:alpha(opacity=50);font-size:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6);background-color:transparent}.carousel-control.left{background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.5)),to(rgba(0,0,0,.0001)));background-image:linear-gradient(90deg,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001));background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr="#80000000",endColorstr="#00000000",GradientType=1)}.carousel-control.right{left:auto;right:0;background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.0001)),to(rgba(0,0,0,.5)));background-image:linear-gradient(90deg,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5));background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr="#00000000",endColorstr="#80000000",GradientType=1)}.carousel-control:focus,.carousel-control:hover{outline:0;color:#fff;text-decoration:none;opacity:.9;filter:alpha(opacity=90)}.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{position:absolute;top:50%;margin-top:-10px;z-index:5;display:inline-block}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{left:50%;margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{right:50%;margin-right:-10px}.carousel-control .icon-next,.carousel-control .icon-prev{width:20px;height:20px;line-height:1;font-family:serif}.carousel-control .icon-prev:before{content:"\2039"}.carousel-control .icon-next:before{content:"\203A"}.carousel-indicators{position:absolute;bottom:10px;left:50%;z-index:15;width:60%;margin-left:-30%;padding-left:0;list-style:none;text-align:center}.carousel-indicators li{display:inline-block;width:10px;height:10px;margin:1px;text-indent:-999px;border:1px solid #fff;border-radius:10px;cursor:pointer;background-color:#000\9;background-color:transparent}.carousel-indicators .active{margin:0;width:12px;height:12px;background-color:#fff}.carousel-caption{position:absolute;left:15%;right:15%;bottom:20px;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6)}.carousel-caption .btn{text-shadow:none}@media screen and (min-width:768px){.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{width:30px;height:30px;margin-top:-10px;font-size:30px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{margin-right:-10px}.carousel-caption{left:20%;right:20%;padding-bottom:30px}.carousel-indicators{bottom:20px}}.clearfix:after,.clearfix:before{content:" ";display:table}.clearfix:after{clear:both}.center-block{display:block;margin-left:auto;margin-right:auto}.pull-right{float:right!important}.pull-left{float:left!important}.hide{display:none!important}.show{display:block!important}.invisible{visibility:hidden}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.hidden{display:none!important}.affix{position:fixed}@-ms-viewport{width:device-width}.visible-lg,.visible-lg-block,.visible-lg-inline,.visible-lg-inline-block,.visible-md,.visible-md-block,.visible-md-inline,.visible-md-inline-block,.visible-sm,.visible-sm-block,.visible-sm-inline,.visible-sm-inline-block,.visible-xs,.visible-xs-block,.visible-xs-inline,.visible-xs-inline-block{display:none!important}@media (max-width:767px){.visible-xs{display:block!important}table.visible-xs{display:table!important}tr.visible-xs{display:table-row!important}td.visible-xs,th.visible-xs{display:table-cell!important}}@media (max-width:767px){.visible-xs-block{display:block!important}}@media (max-width:767px){.visible-xs-inline{display:inline!important}}@media (max-width:767px){.visible-xs-inline-block{display:inline-block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm{display:block!important}table.visible-sm{display:table!important}tr.visible-sm{display:table-row!important}td.visible-sm,th.visible-sm{display:table-cell!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-block{display:block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline{display:inline!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline-block{display:inline-block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md{display:block!important}table.visible-md{display:table!important}tr.visible-md{display:table-row!important}td.visible-md,th.visible-md{display:table-cell!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-block{display:block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline{display:inline!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline-block{display:inline-block!important}}@media (min-width:1200px){.visible-lg{display:block!important}table.visible-lg{display:table!important}tr.visible-lg{display:table-row!important}td.visible-lg,th.visible-lg{display:table-cell!important}}@media (min-width:1200px){.visible-lg-block{display:block!important}}@media (min-width:1200px){.visible-lg-inline{display:inline!important}}@media (min-width:1200px){.visible-lg-inline-block{display:inline-block!important}}@media (max-width:767px){.hidden-xs{display:none!important}}@media (min-width:768px) and (max-width:991px){.hidden-sm{display:none!important}}@media (min-width:992px) and (max-width:1199px){.hidden-md{display:none!important}}@media (min-width:1200px){.hidden-lg{display:none!important}}.visible-print{display:none!important}@media print{.visible-print{display:block!important}table.visible-print{display:table!important}tr.visible-print{display:table-row!important}td.visible-print,th.visible-print{display:table-cell!important}}.visible-print-block{display:none!important}@media print{.visible-print-block{display:block!important}}.visible-print-inline{display:none!important}@media print{.visible-print-inline{display:inline!important}}.visible-print-inline-block{display:none!important}@media print{.visible-print-inline-block{display:inline-block!important}}@media print{.hidden-print{display:none!important}} - -/*! - * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome - * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) - */@font-face{font-family:FontAwesome;src:url(../fonts/vendor/font-awesome/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713);src:url(../fonts/vendor/font-awesome/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713) format("embedded-opentype"),url(../fonts/vendor/font-awesome/fontawesome-webfont.woff2?af7ae505a9eed503f8b8e6982036873e) format("woff2"),url(../fonts/vendor/font-awesome/fontawesome-webfont.woff?fee66e712a8a08eef5805a46892932ad) format("woff"),url(../fonts/vendor/font-awesome/fontawesome-webfont.ttf?b06871f281fee6b241d60582ae9369b9) format("truetype"),url(../fonts/vendor/font-awesome/fontawesome-webfont.svg?912ec66d7572ff821749319396470bde) format("svg");font-weight:400;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:.08em solid #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left{margin-right:.3em}.fa.fa-pull-right{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";-webkit-transform:scaleY(-1);transform:scaleY(-1)}:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270{-webkit-filter:none;filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\F000"}.fa-music:before{content:"\F001"}.fa-search:before{content:"\F002"}.fa-envelope-o:before{content:"\F003"}.fa-heart:before{content:"\F004"}.fa-star:before{content:"\F005"}.fa-star-o:before{content:"\F006"}.fa-user:before{content:"\F007"}.fa-film:before{content:"\F008"}.fa-th-large:before{content:"\F009"}.fa-th:before{content:"\F00A"}.fa-th-list:before{content:"\F00B"}.fa-check:before{content:"\F00C"}.fa-close:before,.fa-remove:before,.fa-times:before{content:"\F00D"}.fa-search-plus:before{content:"\F00E"}.fa-search-minus:before{content:"\F010"}.fa-power-off:before{content:"\F011"}.fa-signal:before{content:"\F012"}.fa-cog:before,.fa-gear:before{content:"\F013"}.fa-trash-o:before{content:"\F014"}.fa-home:before{content:"\F015"}.fa-file-o:before{content:"\F016"}.fa-clock-o:before{content:"\F017"}.fa-road:before{content:"\F018"}.fa-download:before{content:"\F019"}.fa-arrow-circle-o-down:before{content:"\F01A"}.fa-arrow-circle-o-up:before{content:"\F01B"}.fa-inbox:before{content:"\F01C"}.fa-play-circle-o:before{content:"\F01D"}.fa-repeat:before,.fa-rotate-right:before{content:"\F01E"}.fa-refresh:before{content:"\F021"}.fa-list-alt:before{content:"\F022"}.fa-lock:before{content:"\F023"}.fa-flag:before{content:"\F024"}.fa-headphones:before{content:"\F025"}.fa-volume-off:before{content:"\F026"}.fa-volume-down:before{content:"\F027"}.fa-volume-up:before{content:"\F028"}.fa-qrcode:before{content:"\F029"}.fa-barcode:before{content:"\F02A"}.fa-tag:before{content:"\F02B"}.fa-tags:before{content:"\F02C"}.fa-book:before{content:"\F02D"}.fa-bookmark:before{content:"\F02E"}.fa-print:before{content:"\F02F"}.fa-camera:before{content:"\F030"}.fa-font:before{content:"\F031"}.fa-bold:before{content:"\F032"}.fa-italic:before{content:"\F033"}.fa-text-height:before{content:"\F034"}.fa-text-width:before{content:"\F035"}.fa-align-left:before{content:"\F036"}.fa-align-center:before{content:"\F037"}.fa-align-right:before{content:"\F038"}.fa-align-justify:before{content:"\F039"}.fa-list:before{content:"\F03A"}.fa-dedent:before,.fa-outdent:before{content:"\F03B"}.fa-indent:before{content:"\F03C"}.fa-video-camera:before{content:"\F03D"}.fa-image:before,.fa-photo:before,.fa-picture-o:before{content:"\F03E"}.fa-pencil:before{content:"\F040"}.fa-map-marker:before{content:"\F041"}.fa-adjust:before{content:"\F042"}.fa-tint:before{content:"\F043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\F044"}.fa-share-square-o:before{content:"\F045"}.fa-check-square-o:before{content:"\F046"}.fa-arrows:before{content:"\F047"}.fa-step-backward:before{content:"\F048"}.fa-fast-backward:before{content:"\F049"}.fa-backward:before{content:"\F04A"}.fa-play:before{content:"\F04B"}.fa-pause:before{content:"\F04C"}.fa-stop:before{content:"\F04D"}.fa-forward:before{content:"\F04E"}.fa-fast-forward:before{content:"\F050"}.fa-step-forward:before{content:"\F051"}.fa-eject:before{content:"\F052"}.fa-chevron-left:before{content:"\F053"}.fa-chevron-right:before{content:"\F054"}.fa-plus-circle:before{content:"\F055"}.fa-minus-circle:before{content:"\F056"}.fa-times-circle:before{content:"\F057"}.fa-check-circle:before{content:"\F058"}.fa-question-circle:before{content:"\F059"}.fa-info-circle:before{content:"\F05A"}.fa-crosshairs:before{content:"\F05B"}.fa-times-circle-o:before{content:"\F05C"}.fa-check-circle-o:before{content:"\F05D"}.fa-ban:before{content:"\F05E"}.fa-arrow-left:before{content:"\F060"}.fa-arrow-right:before{content:"\F061"}.fa-arrow-up:before{content:"\F062"}.fa-arrow-down:before{content:"\F063"}.fa-mail-forward:before,.fa-share:before{content:"\F064"}.fa-expand:before{content:"\F065"}.fa-compress:before{content:"\F066"}.fa-plus:before{content:"\F067"}.fa-minus:before{content:"\F068"}.fa-asterisk:before{content:"\F069"}.fa-exclamation-circle:before{content:"\F06A"}.fa-gift:before{content:"\F06B"}.fa-leaf:before{content:"\F06C"}.fa-fire:before{content:"\F06D"}.fa-eye:before{content:"\F06E"}.fa-eye-slash:before{content:"\F070"}.fa-exclamation-triangle:before,.fa-warning:before{content:"\F071"}.fa-plane:before{content:"\F072"}.fa-calendar:before{content:"\F073"}.fa-random:before{content:"\F074"}.fa-comment:before{content:"\F075"}.fa-magnet:before{content:"\F076"}.fa-chevron-up:before{content:"\F077"}.fa-chevron-down:before{content:"\F078"}.fa-retweet:before{content:"\F079"}.fa-shopping-cart:before{content:"\F07A"}.fa-folder:before{content:"\F07B"}.fa-folder-open:before{content:"\F07C"}.fa-arrows-v:before{content:"\F07D"}.fa-arrows-h:before{content:"\F07E"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\F080"}.fa-twitter-square:before{content:"\F081"}.fa-facebook-square:before{content:"\F082"}.fa-camera-retro:before{content:"\F083"}.fa-key:before{content:"\F084"}.fa-cogs:before,.fa-gears:before{content:"\F085"}.fa-comments:before{content:"\F086"}.fa-thumbs-o-up:before{content:"\F087"}.fa-thumbs-o-down:before{content:"\F088"}.fa-star-half:before{content:"\F089"}.fa-heart-o:before{content:"\F08A"}.fa-sign-out:before{content:"\F08B"}.fa-linkedin-square:before{content:"\F08C"}.fa-thumb-tack:before{content:"\F08D"}.fa-external-link:before{content:"\F08E"}.fa-sign-in:before{content:"\F090"}.fa-trophy:before{content:"\F091"}.fa-github-square:before{content:"\F092"}.fa-upload:before{content:"\F093"}.fa-lemon-o:before{content:"\F094"}.fa-phone:before{content:"\F095"}.fa-square-o:before{content:"\F096"}.fa-bookmark-o:before{content:"\F097"}.fa-phone-square:before{content:"\F098"}.fa-twitter:before{content:"\F099"}.fa-facebook-f:before,.fa-facebook:before{content:"\F09A"}.fa-github:before{content:"\F09B"}.fa-unlock:before{content:"\F09C"}.fa-credit-card:before{content:"\F09D"}.fa-feed:before,.fa-rss:before{content:"\F09E"}.fa-hdd-o:before{content:"\F0A0"}.fa-bullhorn:before{content:"\F0A1"}.fa-bell:before{content:"\F0F3"}.fa-certificate:before{content:"\F0A3"}.fa-hand-o-right:before{content:"\F0A4"}.fa-hand-o-left:before{content:"\F0A5"}.fa-hand-o-up:before{content:"\F0A6"}.fa-hand-o-down:before{content:"\F0A7"}.fa-arrow-circle-left:before{content:"\F0A8"}.fa-arrow-circle-right:before{content:"\F0A9"}.fa-arrow-circle-up:before{content:"\F0AA"}.fa-arrow-circle-down:before{content:"\F0AB"}.fa-globe:before{content:"\F0AC"}.fa-wrench:before{content:"\F0AD"}.fa-tasks:before{content:"\F0AE"}.fa-filter:before{content:"\F0B0"}.fa-briefcase:before{content:"\F0B1"}.fa-arrows-alt:before{content:"\F0B2"}.fa-group:before,.fa-users:before{content:"\F0C0"}.fa-chain:before,.fa-link:before{content:"\F0C1"}.fa-cloud:before{content:"\F0C2"}.fa-flask:before{content:"\F0C3"}.fa-cut:before,.fa-scissors:before{content:"\F0C4"}.fa-copy:before,.fa-files-o:before{content:"\F0C5"}.fa-paperclip:before{content:"\F0C6"}.fa-floppy-o:before,.fa-save:before{content:"\F0C7"}.fa-square:before{content:"\F0C8"}.fa-bars:before,.fa-navicon:before,.fa-reorder:before{content:"\F0C9"}.fa-list-ul:before{content:"\F0CA"}.fa-list-ol:before{content:"\F0CB"}.fa-strikethrough:before{content:"\F0CC"}.fa-underline:before{content:"\F0CD"}.fa-table:before{content:"\F0CE"}.fa-magic:before{content:"\F0D0"}.fa-truck:before{content:"\F0D1"}.fa-pinterest:before{content:"\F0D2"}.fa-pinterest-square:before{content:"\F0D3"}.fa-google-plus-square:before{content:"\F0D4"}.fa-google-plus:before{content:"\F0D5"}.fa-money:before{content:"\F0D6"}.fa-caret-down:before{content:"\F0D7"}.fa-caret-up:before{content:"\F0D8"}.fa-caret-left:before{content:"\F0D9"}.fa-caret-right:before{content:"\F0DA"}.fa-columns:before{content:"\F0DB"}.fa-sort:before,.fa-unsorted:before{content:"\F0DC"}.fa-sort-desc:before,.fa-sort-down:before{content:"\F0DD"}.fa-sort-asc:before,.fa-sort-up:before{content:"\F0DE"}.fa-envelope:before{content:"\F0E0"}.fa-linkedin:before{content:"\F0E1"}.fa-rotate-left:before,.fa-undo:before{content:"\F0E2"}.fa-gavel:before,.fa-legal:before{content:"\F0E3"}.fa-dashboard:before,.fa-tachometer:before{content:"\F0E4"}.fa-comment-o:before{content:"\F0E5"}.fa-comments-o:before{content:"\F0E6"}.fa-bolt:before,.fa-flash:before{content:"\F0E7"}.fa-sitemap:before{content:"\F0E8"}.fa-umbrella:before{content:"\F0E9"}.fa-clipboard:before,.fa-paste:before{content:"\F0EA"}.fa-lightbulb-o:before{content:"\F0EB"}.fa-exchange:before{content:"\F0EC"}.fa-cloud-download:before{content:"\F0ED"}.fa-cloud-upload:before{content:"\F0EE"}.fa-user-md:before{content:"\F0F0"}.fa-stethoscope:before{content:"\F0F1"}.fa-suitcase:before{content:"\F0F2"}.fa-bell-o:before{content:"\F0A2"}.fa-coffee:before{content:"\F0F4"}.fa-cutlery:before{content:"\F0F5"}.fa-file-text-o:before{content:"\F0F6"}.fa-building-o:before{content:"\F0F7"}.fa-hospital-o:before{content:"\F0F8"}.fa-ambulance:before{content:"\F0F9"}.fa-medkit:before{content:"\F0FA"}.fa-fighter-jet:before{content:"\F0FB"}.fa-beer:before{content:"\F0FC"}.fa-h-square:before{content:"\F0FD"}.fa-plus-square:before{content:"\F0FE"}.fa-angle-double-left:before{content:"\F100"}.fa-angle-double-right:before{content:"\F101"}.fa-angle-double-up:before{content:"\F102"}.fa-angle-double-down:before{content:"\F103"}.fa-angle-left:before{content:"\F104"}.fa-angle-right:before{content:"\F105"}.fa-angle-up:before{content:"\F106"}.fa-angle-down:before{content:"\F107"}.fa-desktop:before{content:"\F108"}.fa-laptop:before{content:"\F109"}.fa-tablet:before{content:"\F10A"}.fa-mobile-phone:before,.fa-mobile:before{content:"\F10B"}.fa-circle-o:before{content:"\F10C"}.fa-quote-left:before{content:"\F10D"}.fa-quote-right:before{content:"\F10E"}.fa-spinner:before{content:"\F110"}.fa-circle:before{content:"\F111"}.fa-mail-reply:before,.fa-reply:before{content:"\F112"}.fa-github-alt:before{content:"\F113"}.fa-folder-o:before{content:"\F114"}.fa-folder-open-o:before{content:"\F115"}.fa-smile-o:before{content:"\F118"}.fa-frown-o:before{content:"\F119"}.fa-meh-o:before{content:"\F11A"}.fa-gamepad:before{content:"\F11B"}.fa-keyboard-o:before{content:"\F11C"}.fa-flag-o:before{content:"\F11D"}.fa-flag-checkered:before{content:"\F11E"}.fa-terminal:before{content:"\F120"}.fa-code:before{content:"\F121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\F122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\F123"}.fa-location-arrow:before{content:"\F124"}.fa-crop:before{content:"\F125"}.fa-code-fork:before{content:"\F126"}.fa-chain-broken:before,.fa-unlink:before{content:"\F127"}.fa-question:before{content:"\F128"}.fa-info:before{content:"\F129"}.fa-exclamation:before{content:"\F12A"}.fa-superscript:before{content:"\F12B"}.fa-subscript:before{content:"\F12C"}.fa-eraser:before{content:"\F12D"}.fa-puzzle-piece:before{content:"\F12E"}.fa-microphone:before{content:"\F130"}.fa-microphone-slash:before{content:"\F131"}.fa-shield:before{content:"\F132"}.fa-calendar-o:before{content:"\F133"}.fa-fire-extinguisher:before{content:"\F134"}.fa-rocket:before{content:"\F135"}.fa-maxcdn:before{content:"\F136"}.fa-chevron-circle-left:before{content:"\F137"}.fa-chevron-circle-right:before{content:"\F138"}.fa-chevron-circle-up:before{content:"\F139"}.fa-chevron-circle-down:before{content:"\F13A"}.fa-html5:before{content:"\F13B"}.fa-css3:before{content:"\F13C"}.fa-anchor:before{content:"\F13D"}.fa-unlock-alt:before{content:"\F13E"}.fa-bullseye:before{content:"\F140"}.fa-ellipsis-h:before{content:"\F141"}.fa-ellipsis-v:before{content:"\F142"}.fa-rss-square:before{content:"\F143"}.fa-play-circle:before{content:"\F144"}.fa-ticket:before{content:"\F145"}.fa-minus-square:before{content:"\F146"}.fa-minus-square-o:before{content:"\F147"}.fa-level-up:before{content:"\F148"}.fa-level-down:before{content:"\F149"}.fa-check-square:before{content:"\F14A"}.fa-pencil-square:before{content:"\F14B"}.fa-external-link-square:before{content:"\F14C"}.fa-share-square:before{content:"\F14D"}.fa-compass:before{content:"\F14E"}.fa-caret-square-o-down:before,.fa-toggle-down:before{content:"\F150"}.fa-caret-square-o-up:before,.fa-toggle-up:before{content:"\F151"}.fa-caret-square-o-right:before,.fa-toggle-right:before{content:"\F152"}.fa-eur:before,.fa-euro:before{content:"\F153"}.fa-gbp:before{content:"\F154"}.fa-dollar:before,.fa-usd:before{content:"\F155"}.fa-inr:before,.fa-rupee:before{content:"\F156"}.fa-cny:before,.fa-jpy:before,.fa-rmb:before,.fa-yen:before{content:"\F157"}.fa-rouble:before,.fa-rub:before,.fa-ruble:before{content:"\F158"}.fa-krw:before,.fa-won:before{content:"\F159"}.fa-bitcoin:before,.fa-btc:before{content:"\F15A"}.fa-file:before{content:"\F15B"}.fa-file-text:before{content:"\F15C"}.fa-sort-alpha-asc:before{content:"\F15D"}.fa-sort-alpha-desc:before{content:"\F15E"}.fa-sort-amount-asc:before{content:"\F160"}.fa-sort-amount-desc:before{content:"\F161"}.fa-sort-numeric-asc:before{content:"\F162"}.fa-sort-numeric-desc:before{content:"\F163"}.fa-thumbs-up:before{content:"\F164"}.fa-thumbs-down:before{content:"\F165"}.fa-youtube-square:before{content:"\F166"}.fa-youtube:before{content:"\F167"}.fa-xing:before{content:"\F168"}.fa-xing-square:before{content:"\F169"}.fa-youtube-play:before{content:"\F16A"}.fa-dropbox:before{content:"\F16B"}.fa-stack-overflow:before{content:"\F16C"}.fa-instagram:before{content:"\F16D"}.fa-flickr:before{content:"\F16E"}.fa-adn:before{content:"\F170"}.fa-bitbucket:before{content:"\F171"}.fa-bitbucket-square:before{content:"\F172"}.fa-tumblr:before{content:"\F173"}.fa-tumblr-square:before{content:"\F174"}.fa-long-arrow-down:before{content:"\F175"}.fa-long-arrow-up:before{content:"\F176"}.fa-long-arrow-left:before{content:"\F177"}.fa-long-arrow-right:before{content:"\F178"}.fa-apple:before{content:"\F179"}.fa-windows:before{content:"\F17A"}.fa-android:before{content:"\F17B"}.fa-linux:before{content:"\F17C"}.fa-dribbble:before{content:"\F17D"}.fa-skype:before{content:"\F17E"}.fa-foursquare:before{content:"\F180"}.fa-trello:before{content:"\F181"}.fa-female:before{content:"\F182"}.fa-male:before{content:"\F183"}.fa-gittip:before,.fa-gratipay:before{content:"\F184"}.fa-sun-o:before{content:"\F185"}.fa-moon-o:before{content:"\F186"}.fa-archive:before{content:"\F187"}.fa-bug:before{content:"\F188"}.fa-vk:before{content:"\F189"}.fa-weibo:before{content:"\F18A"}.fa-renren:before{content:"\F18B"}.fa-pagelines:before{content:"\F18C"}.fa-stack-exchange:before{content:"\F18D"}.fa-arrow-circle-o-right:before{content:"\F18E"}.fa-arrow-circle-o-left:before{content:"\F190"}.fa-caret-square-o-left:before,.fa-toggle-left:before{content:"\F191"}.fa-dot-circle-o:before{content:"\F192"}.fa-wheelchair:before{content:"\F193"}.fa-vimeo-square:before{content:"\F194"}.fa-try:before,.fa-turkish-lira:before{content:"\F195"}.fa-plus-square-o:before{content:"\F196"}.fa-space-shuttle:before{content:"\F197"}.fa-slack:before{content:"\F198"}.fa-envelope-square:before{content:"\F199"}.fa-wordpress:before{content:"\F19A"}.fa-openid:before{content:"\F19B"}.fa-bank:before,.fa-institution:before,.fa-university:before{content:"\F19C"}.fa-graduation-cap:before,.fa-mortar-board:before{content:"\F19D"}.fa-yahoo:before{content:"\F19E"}.fa-google:before{content:"\F1A0"}.fa-reddit:before{content:"\F1A1"}.fa-reddit-square:before{content:"\F1A2"}.fa-stumbleupon-circle:before{content:"\F1A3"}.fa-stumbleupon:before{content:"\F1A4"}.fa-delicious:before{content:"\F1A5"}.fa-digg:before{content:"\F1A6"}.fa-pied-piper-pp:before{content:"\F1A7"}.fa-pied-piper-alt:before{content:"\F1A8"}.fa-drupal:before{content:"\F1A9"}.fa-joomla:before{content:"\F1AA"}.fa-language:before{content:"\F1AB"}.fa-fax:before{content:"\F1AC"}.fa-building:before{content:"\F1AD"}.fa-child:before{content:"\F1AE"}.fa-paw:before{content:"\F1B0"}.fa-spoon:before{content:"\F1B1"}.fa-cube:before{content:"\F1B2"}.fa-cubes:before{content:"\F1B3"}.fa-behance:before{content:"\F1B4"}.fa-behance-square:before{content:"\F1B5"}.fa-steam:before{content:"\F1B6"}.fa-steam-square:before{content:"\F1B7"}.fa-recycle:before{content:"\F1B8"}.fa-automobile:before,.fa-car:before{content:"\F1B9"}.fa-cab:before,.fa-taxi:before{content:"\F1BA"}.fa-tree:before{content:"\F1BB"}.fa-spotify:before{content:"\F1BC"}.fa-deviantart:before{content:"\F1BD"}.fa-soundcloud:before{content:"\F1BE"}.fa-database:before{content:"\F1C0"}.fa-file-pdf-o:before{content:"\F1C1"}.fa-file-word-o:before{content:"\F1C2"}.fa-file-excel-o:before{content:"\F1C3"}.fa-file-powerpoint-o:before{content:"\F1C4"}.fa-file-image-o:before,.fa-file-photo-o:before,.fa-file-picture-o:before{content:"\F1C5"}.fa-file-archive-o:before,.fa-file-zip-o:before{content:"\F1C6"}.fa-file-audio-o:before,.fa-file-sound-o:before{content:"\F1C7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\F1C8"}.fa-file-code-o:before{content:"\F1C9"}.fa-vine:before{content:"\F1CA"}.fa-codepen:before{content:"\F1CB"}.fa-jsfiddle:before{content:"\F1CC"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-ring:before,.fa-life-saver:before,.fa-support:before{content:"\F1CD"}.fa-circle-o-notch:before{content:"\F1CE"}.fa-ra:before,.fa-rebel:before,.fa-resistance:before{content:"\F1D0"}.fa-empire:before,.fa-ge:before{content:"\F1D1"}.fa-git-square:before{content:"\F1D2"}.fa-git:before{content:"\F1D3"}.fa-hacker-news:before,.fa-y-combinator-square:before,.fa-yc-square:before{content:"\F1D4"}.fa-tencent-weibo:before{content:"\F1D5"}.fa-qq:before{content:"\F1D6"}.fa-wechat:before,.fa-weixin:before{content:"\F1D7"}.fa-paper-plane:before,.fa-send:before{content:"\F1D8"}.fa-paper-plane-o:before,.fa-send-o:before{content:"\F1D9"}.fa-history:before{content:"\F1DA"}.fa-circle-thin:before{content:"\F1DB"}.fa-header:before{content:"\F1DC"}.fa-paragraph:before{content:"\F1DD"}.fa-sliders:before{content:"\F1DE"}.fa-share-alt:before{content:"\F1E0"}.fa-share-alt-square:before{content:"\F1E1"}.fa-bomb:before{content:"\F1E2"}.fa-futbol-o:before,.fa-soccer-ball-o:before{content:"\F1E3"}.fa-tty:before{content:"\F1E4"}.fa-binoculars:before{content:"\F1E5"}.fa-plug:before{content:"\F1E6"}.fa-slideshare:before{content:"\F1E7"}.fa-twitch:before{content:"\F1E8"}.fa-yelp:before{content:"\F1E9"}.fa-newspaper-o:before{content:"\F1EA"}.fa-wifi:before{content:"\F1EB"}.fa-calculator:before{content:"\F1EC"}.fa-paypal:before{content:"\F1ED"}.fa-google-wallet:before{content:"\F1EE"}.fa-cc-visa:before{content:"\F1F0"}.fa-cc-mastercard:before{content:"\F1F1"}.fa-cc-discover:before{content:"\F1F2"}.fa-cc-amex:before{content:"\F1F3"}.fa-cc-paypal:before{content:"\F1F4"}.fa-cc-stripe:before{content:"\F1F5"}.fa-bell-slash:before{content:"\F1F6"}.fa-bell-slash-o:before{content:"\F1F7"}.fa-trash:before{content:"\F1F8"}.fa-copyright:before{content:"\F1F9"}.fa-at:before{content:"\F1FA"}.fa-eyedropper:before{content:"\F1FB"}.fa-paint-brush:before{content:"\F1FC"}.fa-birthday-cake:before{content:"\F1FD"}.fa-area-chart:before{content:"\F1FE"}.fa-pie-chart:before{content:"\F200"}.fa-line-chart:before{content:"\F201"}.fa-lastfm:before{content:"\F202"}.fa-lastfm-square:before{content:"\F203"}.fa-toggle-off:before{content:"\F204"}.fa-toggle-on:before{content:"\F205"}.fa-bicycle:before{content:"\F206"}.fa-bus:before{content:"\F207"}.fa-ioxhost:before{content:"\F208"}.fa-angellist:before{content:"\F209"}.fa-cc:before{content:"\F20A"}.fa-ils:before,.fa-shekel:before,.fa-sheqel:before{content:"\F20B"}.fa-meanpath:before{content:"\F20C"}.fa-buysellads:before{content:"\F20D"}.fa-connectdevelop:before{content:"\F20E"}.fa-dashcube:before{content:"\F210"}.fa-forumbee:before{content:"\F211"}.fa-leanpub:before{content:"\F212"}.fa-sellsy:before{content:"\F213"}.fa-shirtsinbulk:before{content:"\F214"}.fa-simplybuilt:before{content:"\F215"}.fa-skyatlas:before{content:"\F216"}.fa-cart-plus:before{content:"\F217"}.fa-cart-arrow-down:before{content:"\F218"}.fa-diamond:before{content:"\F219"}.fa-ship:before{content:"\F21A"}.fa-user-secret:before{content:"\F21B"}.fa-motorcycle:before{content:"\F21C"}.fa-street-view:before{content:"\F21D"}.fa-heartbeat:before{content:"\F21E"}.fa-venus:before{content:"\F221"}.fa-mars:before{content:"\F222"}.fa-mercury:before{content:"\F223"}.fa-intersex:before,.fa-transgender:before{content:"\F224"}.fa-transgender-alt:before{content:"\F225"}.fa-venus-double:before{content:"\F226"}.fa-mars-double:before{content:"\F227"}.fa-venus-mars:before{content:"\F228"}.fa-mars-stroke:before{content:"\F229"}.fa-mars-stroke-v:before{content:"\F22A"}.fa-mars-stroke-h:before{content:"\F22B"}.fa-neuter:before{content:"\F22C"}.fa-genderless:before{content:"\F22D"}.fa-facebook-official:before{content:"\F230"}.fa-pinterest-p:before{content:"\F231"}.fa-whatsapp:before{content:"\F232"}.fa-server:before{content:"\F233"}.fa-user-plus:before{content:"\F234"}.fa-user-times:before{content:"\F235"}.fa-bed:before,.fa-hotel:before{content:"\F236"}.fa-viacoin:before{content:"\F237"}.fa-train:before{content:"\F238"}.fa-subway:before{content:"\F239"}.fa-medium:before{content:"\F23A"}.fa-y-combinator:before,.fa-yc:before{content:"\F23B"}.fa-optin-monster:before{content:"\F23C"}.fa-opencart:before{content:"\F23D"}.fa-expeditedssl:before{content:"\F23E"}.fa-battery-4:before,.fa-battery-full:before,.fa-battery:before{content:"\F240"}.fa-battery-3:before,.fa-battery-three-quarters:before{content:"\F241"}.fa-battery-2:before,.fa-battery-half:before{content:"\F242"}.fa-battery-1:before,.fa-battery-quarter:before{content:"\F243"}.fa-battery-0:before,.fa-battery-empty:before{content:"\F244"}.fa-mouse-pointer:before{content:"\F245"}.fa-i-cursor:before{content:"\F246"}.fa-object-group:before{content:"\F247"}.fa-object-ungroup:before{content:"\F248"}.fa-sticky-note:before{content:"\F249"}.fa-sticky-note-o:before{content:"\F24A"}.fa-cc-jcb:before{content:"\F24B"}.fa-cc-diners-club:before{content:"\F24C"}.fa-clone:before{content:"\F24D"}.fa-balance-scale:before{content:"\F24E"}.fa-hourglass-o:before{content:"\F250"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\F251"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\F252"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\F253"}.fa-hourglass:before{content:"\F254"}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:"\F255"}.fa-hand-paper-o:before,.fa-hand-stop-o:before{content:"\F256"}.fa-hand-scissors-o:before{content:"\F257"}.fa-hand-lizard-o:before{content:"\F258"}.fa-hand-spock-o:before{content:"\F259"}.fa-hand-pointer-o:before{content:"\F25A"}.fa-hand-peace-o:before{content:"\F25B"}.fa-trademark:before{content:"\F25C"}.fa-registered:before{content:"\F25D"}.fa-creative-commons:before{content:"\F25E"}.fa-gg:before{content:"\F260"}.fa-gg-circle:before{content:"\F261"}.fa-tripadvisor:before{content:"\F262"}.fa-odnoklassniki:before{content:"\F263"}.fa-odnoklassniki-square:before{content:"\F264"}.fa-get-pocket:before{content:"\F265"}.fa-wikipedia-w:before{content:"\F266"}.fa-safari:before{content:"\F267"}.fa-chrome:before{content:"\F268"}.fa-firefox:before{content:"\F269"}.fa-opera:before{content:"\F26A"}.fa-internet-explorer:before{content:"\F26B"}.fa-television:before,.fa-tv:before{content:"\F26C"}.fa-contao:before{content:"\F26D"}.fa-500px:before{content:"\F26E"}.fa-amazon:before{content:"\F270"}.fa-calendar-plus-o:before{content:"\F271"}.fa-calendar-minus-o:before{content:"\F272"}.fa-calendar-times-o:before{content:"\F273"}.fa-calendar-check-o:before{content:"\F274"}.fa-industry:before{content:"\F275"}.fa-map-pin:before{content:"\F276"}.fa-map-signs:before{content:"\F277"}.fa-map-o:before{content:"\F278"}.fa-map:before{content:"\F279"}.fa-commenting:before{content:"\F27A"}.fa-commenting-o:before{content:"\F27B"}.fa-houzz:before{content:"\F27C"}.fa-vimeo:before{content:"\F27D"}.fa-black-tie:before{content:"\F27E"}.fa-fonticons:before{content:"\F280"}.fa-reddit-alien:before{content:"\F281"}.fa-edge:before{content:"\F282"}.fa-credit-card-alt:before{content:"\F283"}.fa-codiepie:before{content:"\F284"}.fa-modx:before{content:"\F285"}.fa-fort-awesome:before{content:"\F286"}.fa-usb:before{content:"\F287"}.fa-product-hunt:before{content:"\F288"}.fa-mixcloud:before{content:"\F289"}.fa-scribd:before{content:"\F28A"}.fa-pause-circle:before{content:"\F28B"}.fa-pause-circle-o:before{content:"\F28C"}.fa-stop-circle:before{content:"\F28D"}.fa-stop-circle-o:before{content:"\F28E"}.fa-shopping-bag:before{content:"\F290"}.fa-shopping-basket:before{content:"\F291"}.fa-hashtag:before{content:"\F292"}.fa-bluetooth:before{content:"\F293"}.fa-bluetooth-b:before{content:"\F294"}.fa-percent:before{content:"\F295"}.fa-gitlab:before{content:"\F296"}.fa-wpbeginner:before{content:"\F297"}.fa-wpforms:before{content:"\F298"}.fa-envira:before{content:"\F299"}.fa-universal-access:before{content:"\F29A"}.fa-wheelchair-alt:before{content:"\F29B"}.fa-question-circle-o:before{content:"\F29C"}.fa-blind:before{content:"\F29D"}.fa-audio-description:before{content:"\F29E"}.fa-volume-control-phone:before{content:"\F2A0"}.fa-braille:before{content:"\F2A1"}.fa-assistive-listening-systems:before{content:"\F2A2"}.fa-american-sign-language-interpreting:before,.fa-asl-interpreting:before{content:"\F2A3"}.fa-deaf:before,.fa-deafness:before,.fa-hard-of-hearing:before{content:"\F2A4"}.fa-glide:before{content:"\F2A5"}.fa-glide-g:before{content:"\F2A6"}.fa-sign-language:before,.fa-signing:before{content:"\F2A7"}.fa-low-vision:before{content:"\F2A8"}.fa-viadeo:before{content:"\F2A9"}.fa-viadeo-square:before{content:"\F2AA"}.fa-snapchat:before{content:"\F2AB"}.fa-snapchat-ghost:before{content:"\F2AC"}.fa-snapchat-square:before{content:"\F2AD"}.fa-pied-piper:before{content:"\F2AE"}.fa-first-order:before{content:"\F2B0"}.fa-yoast:before{content:"\F2B1"}.fa-themeisle:before{content:"\F2B2"}.fa-google-plus-circle:before,.fa-google-plus-official:before{content:"\F2B3"}.fa-fa:before,.fa-font-awesome:before{content:"\F2B4"}.fa-handshake-o:before{content:"\F2B5"}.fa-envelope-open:before{content:"\F2B6"}.fa-envelope-open-o:before{content:"\F2B7"}.fa-linode:before{content:"\F2B8"}.fa-address-book:before{content:"\F2B9"}.fa-address-book-o:before{content:"\F2BA"}.fa-address-card:before,.fa-vcard:before{content:"\F2BB"}.fa-address-card-o:before,.fa-vcard-o:before{content:"\F2BC"}.fa-user-circle:before{content:"\F2BD"}.fa-user-circle-o:before{content:"\F2BE"}.fa-user-o:before{content:"\F2C0"}.fa-id-badge:before{content:"\F2C1"}.fa-drivers-license:before,.fa-id-card:before{content:"\F2C2"}.fa-drivers-license-o:before,.fa-id-card-o:before{content:"\F2C3"}.fa-quora:before{content:"\F2C4"}.fa-free-code-camp:before{content:"\F2C5"}.fa-telegram:before{content:"\F2C6"}.fa-thermometer-4:before,.fa-thermometer-full:before,.fa-thermometer:before{content:"\F2C7"}.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:"\F2C8"}.fa-thermometer-2:before,.fa-thermometer-half:before{content:"\F2C9"}.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:"\F2CA"}.fa-thermometer-0:before,.fa-thermometer-empty:before{content:"\F2CB"}.fa-shower:before{content:"\F2CC"}.fa-bath:before,.fa-bathtub:before,.fa-s15:before{content:"\F2CD"}.fa-podcast:before{content:"\F2CE"}.fa-window-maximize:before{content:"\F2D0"}.fa-window-minimize:before{content:"\F2D1"}.fa-window-restore:before{content:"\F2D2"}.fa-times-rectangle:before,.fa-window-close:before{content:"\F2D3"}.fa-times-rectangle-o:before,.fa-window-close-o:before{content:"\F2D4"}.fa-bandcamp:before{content:"\F2D5"}.fa-grav:before{content:"\F2D6"}.fa-etsy:before{content:"\F2D7"}.fa-imdb:before{content:"\F2D8"}.fa-ravelry:before{content:"\F2D9"}.fa-eercast:before{content:"\F2DA"}.fa-microchip:before{content:"\F2DB"}.fa-snowflake-o:before{content:"\F2DC"}.fa-superpowers:before{content:"\F2DD"}.fa-wpexplorer:before{content:"\F2DE"}.fa-meetup:before{content:"\F2E0"}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto} \ No newline at end of file diff --git a/public/css/jquery-ui/.htaccess b/public/css/jquery-ui/.htaccess new file mode 100644 index 0000000000..45552cb63e --- /dev/null +++ b/public/css/jquery-ui/.htaccess @@ -0,0 +1 @@ +Options -Indexes \ No newline at end of file diff --git a/public/css/jquery-ui/images/.htaccess b/public/css/jquery-ui/images/.htaccess new file mode 100644 index 0000000000..45552cb63e --- /dev/null +++ b/public/css/jquery-ui/images/.htaccess @@ -0,0 +1 @@ +Options -Indexes \ No newline at end of file diff --git a/public/fonts/.htaccess b/public/fonts/.htaccess new file mode 100644 index 0000000000..45552cb63e --- /dev/null +++ b/public/fonts/.htaccess @@ -0,0 +1 @@ +Options -Indexes \ No newline at end of file diff --git a/public/fonts/vendor/.htaccess b/public/fonts/vendor/.htaccess new file mode 100644 index 0000000000..45552cb63e --- /dev/null +++ b/public/fonts/vendor/.htaccess @@ -0,0 +1 @@ +Options -Indexes \ No newline at end of file diff --git a/public/fonts/vendor/bootstrap-sass/.htaccess b/public/fonts/vendor/bootstrap-sass/.htaccess new file mode 100644 index 0000000000..45552cb63e --- /dev/null +++ b/public/fonts/vendor/bootstrap-sass/.htaccess @@ -0,0 +1 @@ +Options -Indexes \ No newline at end of file diff --git a/public/fonts/vendor/bootstrap-sass/bootstrap/.htaccess b/public/fonts/vendor/bootstrap-sass/bootstrap/.htaccess new file mode 100644 index 0000000000..45552cb63e --- /dev/null +++ b/public/fonts/vendor/bootstrap-sass/bootstrap/.htaccess @@ -0,0 +1 @@ +Options -Indexes \ No newline at end of file diff --git a/public/fonts/vendor/font-awesome/.htaccess b/public/fonts/vendor/font-awesome/.htaccess new file mode 100644 index 0000000000..45552cb63e --- /dev/null +++ b/public/fonts/vendor/font-awesome/.htaccess @@ -0,0 +1 @@ +Options -Indexes \ No newline at end of file diff --git a/public/images/.htaccess b/public/images/.htaccess new file mode 100644 index 0000000000..45552cb63e --- /dev/null +++ b/public/images/.htaccess @@ -0,0 +1 @@ +Options -Indexes \ No newline at end of file diff --git a/public/images/flags/.htaccess b/public/images/flags/.htaccess new file mode 100644 index 0000000000..45552cb63e --- /dev/null +++ b/public/images/flags/.htaccess @@ -0,0 +1 @@ +Options -Indexes \ No newline at end of file diff --git a/public/images/flags/de_DE.png b/public/images/flags/de_DE.png new file mode 100755 index 0000000000..ac4a977362 Binary files /dev/null and b/public/images/flags/de_DE.png differ diff --git a/public/images/flags/es_ES.png b/public/images/flags/es_ES.png new file mode 100755 index 0000000000..c2de2d7111 Binary files /dev/null and b/public/images/flags/es_ES.png differ diff --git a/public/images/flags/fr_FR.png b/public/images/flags/fr_FR.png new file mode 100755 index 0000000000..8332c4ec23 Binary files /dev/null and b/public/images/flags/fr_FR.png differ diff --git a/public/images/flags/id_ID.png b/public/images/flags/id_ID.png new file mode 100755 index 0000000000..c6bc0fafac Binary files /dev/null and b/public/images/flags/id_ID.png differ diff --git a/public/images/flags/it_IT.png b/public/images/flags/it_IT.png new file mode 100755 index 0000000000..89692f74f0 Binary files /dev/null and b/public/images/flags/it_IT.png differ diff --git a/public/images/flags/nl_NL.png b/public/images/flags/nl_NL.png new file mode 100755 index 0000000000..fe44791e32 Binary files /dev/null and b/public/images/flags/nl_NL.png differ diff --git a/public/images/flags/pl_PL.png b/public/images/flags/pl_PL.png new file mode 100755 index 0000000000..d413d010b5 Binary files /dev/null and b/public/images/flags/pl_PL.png differ diff --git a/public/images/flags/pt_BR.png b/public/images/flags/pt_BR.png new file mode 100755 index 0000000000..9b1a5538b2 Binary files /dev/null and b/public/images/flags/pt_BR.png differ diff --git a/public/images/flags/ru_RU.png b/public/images/flags/ru_RU.png new file mode 100755 index 0000000000..47da4214fd Binary files /dev/null and b/public/images/flags/ru_RU.png differ diff --git a/public/images/flags/tr_TR.png b/public/images/flags/tr_TR.png new file mode 100755 index 0000000000..be32f77e99 Binary files /dev/null and b/public/images/flags/tr_TR.png differ diff --git a/public/images/logos/.htaccess b/public/images/logos/.htaccess new file mode 100644 index 0000000000..45552cb63e --- /dev/null +++ b/public/images/logos/.htaccess @@ -0,0 +1 @@ +Options -Indexes \ No newline at end of file diff --git a/public/images/logos/ynab.png b/public/images/logos/ynab.png new file mode 100644 index 0000000000..289ea469f5 Binary files /dev/null and b/public/images/logos/ynab.png differ diff --git a/public/js/.htaccess b/public/js/.htaccess new file mode 100644 index 0000000000..45552cb63e --- /dev/null +++ b/public/js/.htaccess @@ -0,0 +1 @@ +Options -Indexes \ No newline at end of file diff --git a/public/js/app.js b/public/js/app.js index 3b1cd0f4ee..16deb1ca6c 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1 +1 @@ -!function(t){var e={};function n(r){if(e[r])return e[r].exports;var i=e[r]={i:r,l:!1,exports:{}};return t[r].call(i.exports,i,i.exports,n),i.l=!0,i.exports}n.m=t,n.c=e,n.d=function(t,e,r){n.o(t,e)||Object.defineProperty(t,e,{configurable:!1,enumerable:!0,get:r})},n.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return n.d(e,"a",e),e},n.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},n.p="",n(n.s=12)}([function(t,e,n){"use strict";var r=n(6),i=n(21),o=Object.prototype.toString;function a(t){return"[object Array]"===o.call(t)}function s(t){return null!==t&&"object"==typeof t}function u(t){return"[object Function]"===o.call(t)}function c(t,e){if(null!==t&&void 0!==t)if("object"!=typeof t&&(t=[t]),a(t))for(var n=0,r=t.length;n=200&&t<300}};u.headers={common:{Accept:"application/json, text/plain, */*"}},r.forEach(["delete","get","head"],function(t){u.headers[t]={}}),r.forEach(["post","put","patch"],function(t){u.headers[t]=r.merge(o)}),t.exports=u}).call(e,n(7))},function(t,e){t.exports=function(t){var e=[];return e.toString=function(){return this.map(function(e){var n=function(t,e){var n=t[1]||"",r=t[3];if(!r)return n;if(e&&"function"==typeof btoa){var i=(a=r,"/*# sourceMappingURL=data:application/json;charset=utf-8;base64,"+btoa(unescape(encodeURIComponent(JSON.stringify(a))))+" */"),o=r.sources.map(function(t){return"/*# sourceURL="+r.sourceRoot+t+" */"});return[n].concat(o).concat([i]).join("\n")}var a;return[n].join("\n")}(e,t);return e[2]?"@media "+e[2]+"{"+n+"}":n}).join("")},e.i=function(t,n){"string"==typeof t&&(t=[[null,t,""]]);for(var r={},i=0;in.parts.length&&(r.parts.length=n.parts.length)}else{var a=[];for(i=0;i1)for(var n=1;n>>1,q=[["ary",T],["bind",g],["bindKey",y],["curry",_],["curryRight",w],["flip",$],["partial",x],["partialRight",C],["rearg",k]],B="[object Arguments]",H="[object Array]",U="[object AsyncFunction]",W="[object Boolean]",z="[object Date]",V="[object DOMException]",X="[object Error]",K="[object Function]",J="[object GeneratorFunction]",G="[object Map]",Q="[object Number]",Y="[object Null]",Z="[object Object]",tt="[object Proxy]",et="[object RegExp]",nt="[object Set]",rt="[object String]",it="[object Symbol]",ot="[object Undefined]",at="[object WeakMap]",st="[object WeakSet]",ut="[object ArrayBuffer]",ct="[object DataView]",lt="[object Float32Array]",ft="[object Float64Array]",pt="[object Int8Array]",dt="[object Int16Array]",ht="[object Int32Array]",vt="[object Uint8Array]",mt="[object Uint8ClampedArray]",gt="[object Uint16Array]",yt="[object Uint32Array]",bt=/\b__p \+= '';/g,_t=/\b(__p \+=) '' \+/g,wt=/(__e\(.*?\)|\b__t\)) \+\n'';/g,xt=/&(?:amp|lt|gt|quot|#39);/g,Ct=/[&<>"']/g,Tt=RegExp(xt.source),kt=RegExp(Ct.source),$t=/<%-([\s\S]+?)%>/g,At=/<%([\s\S]+?)%>/g,St=/<%=([\s\S]+?)%>/g,Et=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,Ot=/^\w*$/,jt=/^\./,Nt=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g,Dt=/[\\^$.*+?()[\]{}|]/g,It=RegExp(Dt.source),Lt=/^\s+|\s+$/g,Rt=/^\s+/,Pt=/\s+$/,Ft=/\{(?:\n\/\* \[wrapped with .+\] \*\/)?\n?/,Mt=/\{\n\/\* \[wrapped with (.+)\] \*/,qt=/,? & /,Bt=/[^\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\x7f]+/g,Ht=/\\(\\)?/g,Ut=/\$\{([^\\}]*(?:\\.[^\\}]*)*)\}/g,Wt=/\w*$/,zt=/^[-+]0x[0-9a-f]+$/i,Vt=/^0b[01]+$/i,Xt=/^\[object .+?Constructor\]$/,Kt=/^0o[0-7]+$/i,Jt=/^(?:0|[1-9]\d*)$/,Gt=/[\xc0-\xd6\xd8-\xf6\xf8-\xff\u0100-\u017f]/g,Qt=/($^)/,Yt=/['\n\r\u2028\u2029\\]/g,Zt="\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff",te="\\xac\\xb1\\xd7\\xf7\\x00-\\x2f\\x3a-\\x40\\x5b-\\x60\\x7b-\\xbf\\u2000-\\u206f \\t\\x0b\\f\\xa0\\ufeff\\n\\r\\u2028\\u2029\\u1680\\u180e\\u2000\\u2001\\u2002\\u2003\\u2004\\u2005\\u2006\\u2007\\u2008\\u2009\\u200a\\u202f\\u205f\\u3000",ee="[\\ud800-\\udfff]",ne="["+te+"]",re="["+Zt+"]",ie="\\d+",oe="[\\u2700-\\u27bf]",ae="[a-z\\xdf-\\xf6\\xf8-\\xff]",se="[^\\ud800-\\udfff"+te+ie+"\\u2700-\\u27bfa-z\\xdf-\\xf6\\xf8-\\xffA-Z\\xc0-\\xd6\\xd8-\\xde]",ue="\\ud83c[\\udffb-\\udfff]",ce="[^\\ud800-\\udfff]",le="(?:\\ud83c[\\udde6-\\uddff]){2}",fe="[\\ud800-\\udbff][\\udc00-\\udfff]",pe="[A-Z\\xc0-\\xd6\\xd8-\\xde]",de="(?:"+ae+"|"+se+")",he="(?:"+pe+"|"+se+")",ve="(?:"+re+"|"+ue+")"+"?",me="[\\ufe0e\\ufe0f]?"+ve+("(?:\\u200d(?:"+[ce,le,fe].join("|")+")[\\ufe0e\\ufe0f]?"+ve+")*"),ge="(?:"+[oe,le,fe].join("|")+")"+me,ye="(?:"+[ce+re+"?",re,le,fe,ee].join("|")+")",be=RegExp("['’]","g"),_e=RegExp(re,"g"),we=RegExp(ue+"(?="+ue+")|"+ye+me,"g"),xe=RegExp([pe+"?"+ae+"+(?:['’](?:d|ll|m|re|s|t|ve))?(?="+[ne,pe,"$"].join("|")+")",he+"+(?:['’](?:D|LL|M|RE|S|T|VE))?(?="+[ne,pe+de,"$"].join("|")+")",pe+"?"+de+"+(?:['’](?:d|ll|m|re|s|t|ve))?",pe+"+(?:['’](?:D|LL|M|RE|S|T|VE))?","\\d*(?:(?:1ST|2ND|3RD|(?![123])\\dTH)\\b)","\\d*(?:(?:1st|2nd|3rd|(?![123])\\dth)\\b)",ie,ge].join("|"),"g"),Ce=RegExp("[\\u200d\\ud800-\\udfff"+Zt+"\\ufe0e\\ufe0f]"),Te=/[a-z][A-Z]|[A-Z]{2,}[a-z]|[0-9][a-zA-Z]|[a-zA-Z][0-9]|[^a-zA-Z0-9 ]/,ke=["Array","Buffer","DataView","Date","Error","Float32Array","Float64Array","Function","Int8Array","Int16Array","Int32Array","Map","Math","Object","Promise","RegExp","Set","String","Symbol","TypeError","Uint8Array","Uint8ClampedArray","Uint16Array","Uint32Array","WeakMap","_","clearTimeout","isFinite","parseInt","setTimeout"],$e=-1,Ae={};Ae[lt]=Ae[ft]=Ae[pt]=Ae[dt]=Ae[ht]=Ae[vt]=Ae[mt]=Ae[gt]=Ae[yt]=!0,Ae[B]=Ae[H]=Ae[ut]=Ae[W]=Ae[ct]=Ae[z]=Ae[X]=Ae[K]=Ae[G]=Ae[Q]=Ae[Z]=Ae[et]=Ae[nt]=Ae[rt]=Ae[at]=!1;var Se={};Se[B]=Se[H]=Se[ut]=Se[ct]=Se[W]=Se[z]=Se[lt]=Se[ft]=Se[pt]=Se[dt]=Se[ht]=Se[G]=Se[Q]=Se[Z]=Se[et]=Se[nt]=Se[rt]=Se[it]=Se[vt]=Se[mt]=Se[gt]=Se[yt]=!0,Se[X]=Se[K]=Se[at]=!1;var Ee={"\\":"\\","'":"'","\n":"n","\r":"r","\u2028":"u2028","\u2029":"u2029"},Oe=parseFloat,je=parseInt,Ne="object"==typeof t&&t&&t.Object===Object&&t,De="object"==typeof self&&self&&self.Object===Object&&self,Ie=Ne||De||Function("return this")(),Le="object"==typeof e&&e&&!e.nodeType&&e,Re=Le&&"object"==typeof r&&r&&!r.nodeType&&r,Pe=Re&&Re.exports===Le,Fe=Pe&&Ne.process,Me=function(){try{return Fe&&Fe.binding&&Fe.binding("util")}catch(t){}}(),qe=Me&&Me.isArrayBuffer,Be=Me&&Me.isDate,He=Me&&Me.isMap,Ue=Me&&Me.isRegExp,We=Me&&Me.isSet,ze=Me&&Me.isTypedArray;function Ve(t,e){return t.set(e[0],e[1]),t}function Xe(t,e){return t.add(e),t}function Ke(t,e,n){switch(n.length){case 0:return t.call(e);case 1:return t.call(e,n[0]);case 2:return t.call(e,n[0],n[1]);case 3:return t.call(e,n[0],n[1],n[2])}return t.apply(e,n)}function Je(t,e,n,r){for(var i=-1,o=null==t?0:t.length;++i-1}function en(t,e,n){for(var r=-1,i=null==t?0:t.length;++r-1;);return n}function Tn(t,e){for(var n=t.length;n--&&fn(e,t[n],0)>-1;);return n}var kn=mn({"À":"A","Á":"A","Â":"A","Ã":"A","Ä":"A","Å":"A","à":"a","á":"a","â":"a","ã":"a","ä":"a","å":"a","Ç":"C","ç":"c","Ð":"D","ð":"d","È":"E","É":"E","Ê":"E","Ë":"E","è":"e","é":"e","ê":"e","ë":"e","Ì":"I","Í":"I","Î":"I","Ï":"I","ì":"i","í":"i","î":"i","ï":"i","Ñ":"N","ñ":"n","Ò":"O","Ó":"O","Ô":"O","Õ":"O","Ö":"O","Ø":"O","ò":"o","ó":"o","ô":"o","õ":"o","ö":"o","ø":"o","Ù":"U","Ú":"U","Û":"U","Ü":"U","ù":"u","ú":"u","û":"u","ü":"u","Ý":"Y","ý":"y","ÿ":"y","Æ":"Ae","æ":"ae","Þ":"Th","þ":"th","ß":"ss","Ā":"A","Ă":"A","Ą":"A","ā":"a","ă":"a","ą":"a","Ć":"C","Ĉ":"C","Ċ":"C","Č":"C","ć":"c","ĉ":"c","ċ":"c","č":"c","Ď":"D","Đ":"D","ď":"d","đ":"d","Ē":"E","Ĕ":"E","Ė":"E","Ę":"E","Ě":"E","ē":"e","ĕ":"e","ė":"e","ę":"e","ě":"e","Ĝ":"G","Ğ":"G","Ġ":"G","Ģ":"G","ĝ":"g","ğ":"g","ġ":"g","ģ":"g","Ĥ":"H","Ħ":"H","ĥ":"h","ħ":"h","Ĩ":"I","Ī":"I","Ĭ":"I","Į":"I","İ":"I","ĩ":"i","ī":"i","ĭ":"i","į":"i","ı":"i","Ĵ":"J","ĵ":"j","Ķ":"K","ķ":"k","ĸ":"k","Ĺ":"L","Ļ":"L","Ľ":"L","Ŀ":"L","Ł":"L","ĺ":"l","ļ":"l","ľ":"l","ŀ":"l","ł":"l","Ń":"N","Ņ":"N","Ň":"N","Ŋ":"N","ń":"n","ņ":"n","ň":"n","ŋ":"n","Ō":"O","Ŏ":"O","Ő":"O","ō":"o","ŏ":"o","ő":"o","Ŕ":"R","Ŗ":"R","Ř":"R","ŕ":"r","ŗ":"r","ř":"r","Ś":"S","Ŝ":"S","Ş":"S","Š":"S","ś":"s","ŝ":"s","ş":"s","š":"s","Ţ":"T","Ť":"T","Ŧ":"T","ţ":"t","ť":"t","ŧ":"t","Ũ":"U","Ū":"U","Ŭ":"U","Ů":"U","Ű":"U","Ų":"U","ũ":"u","ū":"u","ŭ":"u","ů":"u","ű":"u","ų":"u","Ŵ":"W","ŵ":"w","Ŷ":"Y","ŷ":"y","Ÿ":"Y","Ź":"Z","Ż":"Z","Ž":"Z","ź":"z","ż":"z","ž":"z","IJ":"IJ","ij":"ij","Œ":"Oe","œ":"oe","ʼn":"'n","ſ":"s"}),$n=mn({"&":"&","<":"<",">":">",'"':""","'":"'"});function An(t){return"\\"+Ee[t]}function Sn(t){return Ce.test(t)}function En(t){var e=-1,n=Array(t.size);return t.forEach(function(t,r){n[++e]=[r,t]}),n}function On(t,e){return function(n){return t(e(n))}}function jn(t,e){for(var n=-1,r=t.length,i=0,o=[];++n",""":'"',"'":"'"});var Pn=function t(e){var n,r=(e=null==e?Ie:Pn.defaults(Ie.Object(),e,Pn.pick(Ie,ke))).Array,i=e.Date,Zt=e.Error,te=e.Function,ee=e.Math,ne=e.Object,re=e.RegExp,ie=e.String,oe=e.TypeError,ae=r.prototype,se=te.prototype,ue=ne.prototype,ce=e["__core-js_shared__"],le=se.toString,fe=ue.hasOwnProperty,pe=0,de=(n=/[^.]+$/.exec(ce&&ce.keys&&ce.keys.IE_PROTO||""))?"Symbol(src)_1."+n:"",he=ue.toString,ve=le.call(ne),me=Ie._,ge=re("^"+le.call(fe).replace(Dt,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$"),ye=Pe?e.Buffer:o,we=e.Symbol,Ce=e.Uint8Array,Ee=ye?ye.allocUnsafe:o,Ne=On(ne.getPrototypeOf,ne),De=ne.create,Le=ue.propertyIsEnumerable,Re=ae.splice,Fe=we?we.isConcatSpreadable:o,Me=we?we.iterator:o,un=we?we.toStringTag:o,mn=function(){try{var t=Ho(ne,"defineProperty");return t({},"",{}),t}catch(t){}}(),Fn=e.clearTimeout!==Ie.clearTimeout&&e.clearTimeout,Mn=i&&i.now!==Ie.Date.now&&i.now,qn=e.setTimeout!==Ie.setTimeout&&e.setTimeout,Bn=ee.ceil,Hn=ee.floor,Un=ne.getOwnPropertySymbols,Wn=ye?ye.isBuffer:o,zn=e.isFinite,Vn=ae.join,Xn=On(ne.keys,ne),Kn=ee.max,Jn=ee.min,Gn=i.now,Qn=e.parseInt,Yn=ee.random,Zn=ae.reverse,tr=Ho(e,"DataView"),er=Ho(e,"Map"),nr=Ho(e,"Promise"),rr=Ho(e,"Set"),ir=Ho(e,"WeakMap"),or=Ho(ne,"create"),ar=ir&&new ir,sr={},ur=da(tr),cr=da(er),lr=da(nr),fr=da(rr),pr=da(ir),dr=we?we.prototype:o,hr=dr?dr.valueOf:o,vr=dr?dr.toString:o;function mr(t){if(Os(t)&&!bs(t)&&!(t instanceof _r)){if(t instanceof br)return t;if(fe.call(t,"__wrapped__"))return ha(t)}return new br(t)}var gr=function(){function t(){}return function(e){if(!Es(e))return{};if(De)return De(e);t.prototype=e;var n=new t;return t.prototype=o,n}}();function yr(){}function br(t,e){this.__wrapped__=t,this.__actions__=[],this.__chain__=!!e,this.__index__=0,this.__values__=o}function _r(t){this.__wrapped__=t,this.__actions__=[],this.__dir__=1,this.__filtered__=!1,this.__iteratees__=[],this.__takeCount__=P,this.__views__=[]}function wr(t){var e=-1,n=null==t?0:t.length;for(this.clear();++e=e?t:e)),t}function Fr(t,e,n,r,i,a){var s,u=e&p,c=e&d,l=e&h;if(n&&(s=i?n(t,r,i,a):n(t)),s!==o)return s;if(!Es(t))return t;var f=bs(t);if(f){if(s=function(t){var e=t.length,n=t.constructor(e);return e&&"string"==typeof t[0]&&fe.call(t,"index")&&(n.index=t.index,n.input=t.input),n}(t),!u)return oo(t,s)}else{var v=zo(t),m=v==K||v==J;if(Cs(t))return Zi(t,u);if(v==Z||v==B||m&&!i){if(s=c||m?{}:Xo(t),!u)return c?function(t,e){return ao(t,Wo(t),e)}(t,function(t,e){return t&&ao(e,su(e),t)}(s,t)):function(t,e){return ao(t,Uo(t),e)}(t,Ir(s,t))}else{if(!Se[v])return i?t:{};s=function(t,e,n,r){var i,o,a,s=t.constructor;switch(e){case ut:return to(t);case W:case z:return new s(+t);case ct:return function(t,e){var n=e?to(t.buffer):t.buffer;return new t.constructor(n,t.byteOffset,t.byteLength)}(t,r);case lt:case ft:case pt:case dt:case ht:case vt:case mt:case gt:case yt:return eo(t,r);case G:return function(t,e,n){return on(e?n(En(t),p):En(t),Ve,new t.constructor)}(t,r,n);case Q:case rt:return new s(t);case et:return(a=new(o=t).constructor(o.source,Wt.exec(o))).lastIndex=o.lastIndex,a;case nt:return function(t,e,n){return on(e?n(Nn(t),p):Nn(t),Xe,new t.constructor)}(t,r,n);case it:return i=t,hr?ne(hr.call(i)):{}}}(t,v,Fr,u)}}a||(a=new kr);var g=a.get(t);if(g)return g;a.set(t,s);var y=f?o:(l?c?Lo:Io:c?su:au)(t);return Ge(y||t,function(r,i){y&&(r=t[i=r]),jr(s,i,Fr(r,e,n,i,t,a))}),s}function Mr(t,e,n){var r=n.length;if(null==t)return!r;for(t=ne(t);r--;){var i=n[r],a=e[i],s=t[i];if(s===o&&!(i in t)||!a(s))return!1}return!0}function qr(t,e,n){if("function"!=typeof t)throw new oe(u);return aa(function(){t.apply(o,n)},e)}function Br(t,e,n,r){var i=-1,o=tn,s=!0,u=t.length,c=[],l=e.length;if(!u)return c;n&&(e=nn(e,_n(n))),r?(o=en,s=!1):e.length>=a&&(o=xn,s=!1,e=new Tr(e));t:for(;++i-1},xr.prototype.set=function(t,e){var n=this.__data__,r=Nr(n,t);return r<0?(++this.size,n.push([t,e])):n[r][1]=e,this},Cr.prototype.clear=function(){this.size=0,this.__data__={hash:new wr,map:new(er||xr),string:new wr}},Cr.prototype.delete=function(t){var e=qo(this,t).delete(t);return this.size-=e?1:0,e},Cr.prototype.get=function(t){return qo(this,t).get(t)},Cr.prototype.has=function(t){return qo(this,t).has(t)},Cr.prototype.set=function(t,e){var n=qo(this,t),r=n.size;return n.set(t,e),this.size+=n.size==r?0:1,this},Tr.prototype.add=Tr.prototype.push=function(t){return this.__data__.set(t,c),this},Tr.prototype.has=function(t){return this.__data__.has(t)},kr.prototype.clear=function(){this.__data__=new xr,this.size=0},kr.prototype.delete=function(t){var e=this.__data__,n=e.delete(t);return this.size=e.size,n},kr.prototype.get=function(t){return this.__data__.get(t)},kr.prototype.has=function(t){return this.__data__.has(t)},kr.prototype.set=function(t,e){var n=this.__data__;if(n instanceof xr){var r=n.__data__;if(!er||r.length0&&n(s)?e>1?Xr(s,e-1,n,r,i):rn(i,s):r||(i[i.length]=s)}return i}var Kr=lo(),Jr=lo(!0);function Gr(t,e){return t&&Kr(t,e,au)}function Qr(t,e){return t&&Jr(t,e,au)}function Yr(t,e){return Ze(e,function(e){return $s(t[e])})}function Zr(t,e){for(var n=0,r=(e=Ji(e,t)).length;null!=t&&ne}function ri(t,e){return null!=t&&fe.call(t,e)}function ii(t,e){return null!=t&&e in ne(t)}function oi(t,e,n){for(var i=n?en:tn,a=t[0].length,s=t.length,u=s,c=r(s),l=1/0,f=[];u--;){var p=t[u];u&&e&&(p=nn(p,_n(e))),l=Jn(p.length,l),c[u]=!n&&(e||a>=120&&p.length>=120)?new Tr(u&&p):o}p=t[0];var d=-1,h=c[0];t:for(;++d=s)return u;var c=n[r];return u*("desc"==c?-1:1)}}return t.index-e.index}(t,e,n)})}function wi(t,e,n){for(var r=-1,i=e.length,o={};++r-1;)s!==t&&Re.call(s,u,1),Re.call(t,u,1);return t}function Ci(t,e){for(var n=t?e.length:0,r=n-1;n--;){var i=e[n];if(n==r||i!==o){var o=i;Jo(i)?Re.call(t,i,1):Bi(t,i)}}return t}function Ti(t,e){return t+Hn(Yn()*(e-t+1))}function ki(t,e){var n="";if(!t||e<1||e>I)return n;do{e%2&&(n+=t),(e=Hn(e/2))&&(t+=t)}while(e);return n}function $i(t,e){return sa(ra(t,e,Nu),t+"")}function Ai(t){return Ar(vu(t))}function Si(t,e){var n=vu(t);return la(n,Pr(e,0,n.length))}function Ei(t,e,n,r){if(!Es(t))return t;for(var i=-1,a=(e=Ji(e,t)).length,s=a-1,u=t;null!=u&&++io?0:o+e),(n=n>o?o:n)<0&&(n+=o),o=e>n?0:n-e>>>0,e>>>=0;for(var a=r(o);++i>>1,a=t[o];null!==a&&!Ps(a)&&(n?a<=e:a=a){var l=e?null:$o(t);if(l)return Nn(l);s=!1,i=xn,c=new Tr}else c=e?[]:u;t:for(;++r=r?t:Di(t,e,n)}var Yi=Fn||function(t){return Ie.clearTimeout(t)};function Zi(t,e){if(e)return t.slice();var n=t.length,r=Ee?Ee(n):new t.constructor(n);return t.copy(r),r}function to(t){var e=new t.constructor(t.byteLength);return new Ce(e).set(new Ce(t)),e}function eo(t,e){var n=e?to(t.buffer):t.buffer;return new t.constructor(n,t.byteOffset,t.length)}function no(t,e){if(t!==e){var n=t!==o,r=null===t,i=t==t,a=Ps(t),s=e!==o,u=null===e,c=e==e,l=Ps(e);if(!u&&!l&&!a&&t>e||a&&s&&c&&!u&&!l||r&&s&&c||!n&&c||!i)return 1;if(!r&&!a&&!l&&t1?n[i-1]:o,s=i>2?n[2]:o;for(a=t.length>3&&"function"==typeof a?(i--,a):o,s&&Go(n[0],n[1],s)&&(a=i<3?o:a,i=1),e=ne(e);++r-1?i[a?e[s]:s]:o}}function mo(t){return Do(function(e){var n=e.length,r=n,i=br.prototype.thru;for(t&&e.reverse();r--;){var a=e[r];if("function"!=typeof a)throw new oe(u);if(i&&!s&&"wrapper"==Po(a))var s=new br([],!0)}for(r=s?r:n;++r1&&_.reverse(),p&&lu))return!1;var l=a.get(t);if(l&&a.get(e))return l==e;var f=-1,p=!0,d=n&m?new Tr:o;for(a.set(t,e),a.set(e,t);++f-1&&t%1==0&&t1?"& ":"")+e[r],e=e.join(n>2?", ":" "),t.replace(Ft,"{\n/* [wrapped with "+e+"] */\n")}(r,function(t,e){return Ge(q,function(n){var r="_."+n[0];e&n[1]&&!tn(t,r)&&t.push(r)}),t.sort()}(function(t){var e=t.match(Mt);return e?e[1].split(qt):[]}(r),n)))}function ca(t){var e=0,n=0;return function(){var r=Gn(),i=O-(r-n);if(n=r,i>0){if(++e>=E)return arguments[0]}else e=0;return t.apply(o,arguments)}}function la(t,e){var n=-1,r=t.length,i=r-1;for(e=e===o?r:e;++n1?t[e-1]:o;return Ia(t,n="function"==typeof n?(t.pop(),n):o)});function Ba(t){var e=mr(t);return e.__chain__=!0,e}function Ha(t,e){return e(t)}var Ua=Do(function(t){var e=t.length,n=e?t[0]:0,r=this.__wrapped__,i=function(e){return Rr(e,t)};return!(e>1||this.__actions__.length)&&r instanceof _r&&Jo(n)?((r=r.slice(n,+n+(e?1:0))).__actions__.push({func:Ha,args:[i],thisArg:o}),new br(r,this.__chain__).thru(function(t){return e&&!t.length&&t.push(o),t})):this.thru(i)});var Wa=so(function(t,e,n){fe.call(t,n)?++t[n]:Lr(t,n,1)});var za=vo(ya),Va=vo(ba);function Xa(t,e){return(bs(t)?Ge:Hr)(t,Mo(e,3))}function Ka(t,e){return(bs(t)?Qe:Ur)(t,Mo(e,3))}var Ja=so(function(t,e,n){fe.call(t,n)?t[n].push(e):Lr(t,n,[e])});var Ga=$i(function(t,e,n){var i=-1,o="function"==typeof e,a=ws(t)?r(t.length):[];return Hr(t,function(t){a[++i]=o?Ke(e,t,n):ai(t,e,n)}),a}),Qa=so(function(t,e,n){Lr(t,n,e)});function Ya(t,e){return(bs(t)?nn:vi)(t,Mo(e,3))}var Za=so(function(t,e,n){t[n?0:1].push(e)},function(){return[[],[]]});var ts=$i(function(t,e){if(null==t)return[];var n=e.length;return n>1&&Go(t,e[0],e[1])?e=[]:n>2&&Go(e[0],e[1],e[2])&&(e=[e[0]]),_i(t,Xr(e,1),[])}),es=Mn||function(){return Ie.Date.now()};function ns(t,e,n){return e=n?o:e,e=t&&null==e?t.length:e,So(t,T,o,o,o,o,e)}function rs(t,e){var n;if("function"!=typeof e)throw new oe(u);return t=Us(t),function(){return--t>0&&(n=e.apply(this,arguments)),t<=1&&(e=o),n}}var is=$i(function(t,e,n){var r=g;if(n.length){var i=jn(n,Fo(is));r|=x}return So(t,r,e,n,i)}),os=$i(function(t,e,n){var r=g|y;if(n.length){var i=jn(n,Fo(os));r|=x}return So(e,r,t,n,i)});function as(t,e,n){var r,i,a,s,c,l,f=0,p=!1,d=!1,h=!0;if("function"!=typeof t)throw new oe(u);function v(e){var n=r,a=i;return r=i=o,f=e,s=t.apply(a,n)}function m(t){var n=t-l;return l===o||n>=e||n<0||d&&t-f>=a}function g(){var t=es();if(m(t))return y(t);c=aa(g,function(t){var n=e-(t-l);return d?Jn(n,a-(t-f)):n}(t))}function y(t){return c=o,h&&r?v(t):(r=i=o,s)}function b(){var t=es(),n=m(t);if(r=arguments,i=this,l=t,n){if(c===o)return function(t){return f=t,c=aa(g,e),p?v(t):s}(l);if(d)return c=aa(g,e),v(l)}return c===o&&(c=aa(g,e)),s}return e=zs(e)||0,Es(n)&&(p=!!n.leading,a=(d="maxWait"in n)?Kn(zs(n.maxWait)||0,e):a,h="trailing"in n?!!n.trailing:h),b.cancel=function(){c!==o&&Yi(c),f=0,r=l=i=c=o},b.flush=function(){return c===o?s:y(es())},b}var ss=$i(function(t,e){return qr(t,1,e)}),us=$i(function(t,e,n){return qr(t,zs(e)||0,n)});function cs(t,e){if("function"!=typeof t||null!=e&&"function"!=typeof e)throw new oe(u);var n=function(){var r=arguments,i=e?e.apply(this,r):r[0],o=n.cache;if(o.has(i))return o.get(i);var a=t.apply(this,r);return n.cache=o.set(i,a)||o,a};return n.cache=new(cs.Cache||Cr),n}function ls(t){if("function"!=typeof t)throw new oe(u);return function(){var e=arguments;switch(e.length){case 0:return!t.call(this);case 1:return!t.call(this,e[0]);case 2:return!t.call(this,e[0],e[1]);case 3:return!t.call(this,e[0],e[1],e[2])}return!t.apply(this,e)}}cs.Cache=Cr;var fs=Gi(function(t,e){var n=(e=1==e.length&&bs(e[0])?nn(e[0],_n(Mo())):nn(Xr(e,1),_n(Mo()))).length;return $i(function(r){for(var i=-1,o=Jn(r.length,n);++i=e}),ys=si(function(){return arguments}())?si:function(t){return Os(t)&&fe.call(t,"callee")&&!Le.call(t,"callee")},bs=r.isArray,_s=qe?_n(qe):function(t){return Os(t)&&ei(t)==ut};function ws(t){return null!=t&&Ss(t.length)&&!$s(t)}function xs(t){return Os(t)&&ws(t)}var Cs=Wn||zu,Ts=Be?_n(Be):function(t){return Os(t)&&ei(t)==z};function ks(t){if(!Os(t))return!1;var e=ei(t);return e==X||e==V||"string"==typeof t.message&&"string"==typeof t.name&&!Ds(t)}function $s(t){if(!Es(t))return!1;var e=ei(t);return e==K||e==J||e==U||e==tt}function As(t){return"number"==typeof t&&t==Us(t)}function Ss(t){return"number"==typeof t&&t>-1&&t%1==0&&t<=I}function Es(t){var e=typeof t;return null!=t&&("object"==e||"function"==e)}function Os(t){return null!=t&&"object"==typeof t}var js=He?_n(He):function(t){return Os(t)&&zo(t)==G};function Ns(t){return"number"==typeof t||Os(t)&&ei(t)==Q}function Ds(t){if(!Os(t)||ei(t)!=Z)return!1;var e=Ne(t);if(null===e)return!0;var n=fe.call(e,"constructor")&&e.constructor;return"function"==typeof n&&n instanceof n&&le.call(n)==ve}var Is=Ue?_n(Ue):function(t){return Os(t)&&ei(t)==et};var Ls=We?_n(We):function(t){return Os(t)&&zo(t)==nt};function Rs(t){return"string"==typeof t||!bs(t)&&Os(t)&&ei(t)==rt}function Ps(t){return"symbol"==typeof t||Os(t)&&ei(t)==it}var Fs=ze?_n(ze):function(t){return Os(t)&&Ss(t.length)&&!!Ae[ei(t)]};var Ms=Co(hi),qs=Co(function(t,e){return t<=e});function Bs(t){if(!t)return[];if(ws(t))return Rs(t)?Ln(t):oo(t);if(Me&&t[Me])return function(t){for(var e,n=[];!(e=t.next()).done;)n.push(e.value);return n}(t[Me]());var e=zo(t);return(e==G?En:e==nt?Nn:vu)(t)}function Hs(t){return t?(t=zs(t))===D||t===-D?(t<0?-1:1)*L:t==t?t:0:0===t?t:0}function Us(t){var e=Hs(t),n=e%1;return e==e?n?e-n:e:0}function Ws(t){return t?Pr(Us(t),0,P):0}function zs(t){if("number"==typeof t)return t;if(Ps(t))return R;if(Es(t)){var e="function"==typeof t.valueOf?t.valueOf():t;t=Es(e)?e+"":e}if("string"!=typeof t)return 0===t?t:+t;t=t.replace(Lt,"");var n=Vt.test(t);return n||Kt.test(t)?je(t.slice(2),n?2:8):zt.test(t)?R:+t}function Vs(t){return ao(t,su(t))}function Xs(t){return null==t?"":Mi(t)}var Ks=uo(function(t,e){if(ta(e)||ws(e))ao(e,au(e),t);else for(var n in e)fe.call(e,n)&&jr(t,n,e[n])}),Js=uo(function(t,e){ao(e,su(e),t)}),Gs=uo(function(t,e,n,r){ao(e,su(e),t,r)}),Qs=uo(function(t,e,n,r){ao(e,au(e),t,r)}),Ys=Do(Rr);var Zs=$i(function(t){return t.push(o,Eo),Ke(Gs,o,t)}),tu=$i(function(t){return t.push(o,Oo),Ke(cu,o,t)});function eu(t,e,n){var r=null==t?o:Zr(t,e);return r===o?n:r}function nu(t,e){return null!=t&&Vo(t,e,ii)}var ru=yo(function(t,e,n){t[e]=n},Eu(Nu)),iu=yo(function(t,e,n){fe.call(t,e)?t[e].push(n):t[e]=[n]},Mo),ou=$i(ai);function au(t){return ws(t)?$r(t):pi(t)}function su(t){return ws(t)?$r(t,!0):di(t)}var uu=uo(function(t,e,n){yi(t,e,n)}),cu=uo(function(t,e,n,r){yi(t,e,n,r)}),lu=Do(function(t,e){var n={};if(null==t)return n;var r=!1;e=nn(e,function(e){return e=Ji(e,t),r||(r=e.length>1),e}),ao(t,Lo(t),n),r&&(n=Fr(n,p|d|h,jo));for(var i=e.length;i--;)Bi(n,e[i]);return n});var fu=Do(function(t,e){return null==t?{}:function(t,e){return wi(t,e,function(e,n){return nu(t,n)})}(t,e)});function pu(t,e){if(null==t)return{};var n=nn(Lo(t),function(t){return[t]});return e=Mo(e),wi(t,n,function(t,n){return e(t,n[0])})}var du=Ao(au),hu=Ao(su);function vu(t){return null==t?[]:wn(t,au(t))}var mu=po(function(t,e,n){return e=e.toLowerCase(),t+(n?gu(e):e)});function gu(t){return ku(Xs(t).toLowerCase())}function yu(t){return(t=Xs(t))&&t.replace(Gt,kn).replace(_e,"")}var bu=po(function(t,e,n){return t+(n?"-":"")+e.toLowerCase()}),_u=po(function(t,e,n){return t+(n?" ":"")+e.toLowerCase()}),wu=fo("toLowerCase");var xu=po(function(t,e,n){return t+(n?"_":"")+e.toLowerCase()});var Cu=po(function(t,e,n){return t+(n?" ":"")+ku(e)});var Tu=po(function(t,e,n){return t+(n?" ":"")+e.toUpperCase()}),ku=fo("toUpperCase");function $u(t,e,n){return t=Xs(t),(e=n?o:e)===o?function(t){return Te.test(t)}(t)?function(t){return t.match(xe)||[]}(t):function(t){return t.match(Bt)||[]}(t):t.match(e)||[]}var Au=$i(function(t,e){try{return Ke(t,o,e)}catch(t){return ks(t)?t:new Zt(t)}}),Su=Do(function(t,e){return Ge(e,function(e){e=pa(e),Lr(t,e,is(t[e],t))}),t});function Eu(t){return function(){return t}}var Ou=mo(),ju=mo(!0);function Nu(t){return t}function Du(t){return fi("function"==typeof t?t:Fr(t,p))}var Iu=$i(function(t,e){return function(n){return ai(n,t,e)}}),Lu=$i(function(t,e){return function(n){return ai(t,n,e)}});function Ru(t,e,n){var r=au(e),i=Yr(e,r);null!=n||Es(e)&&(i.length||!r.length)||(n=e,e=t,t=this,i=Yr(e,au(e)));var o=!(Es(n)&&"chain"in n&&!n.chain),a=$s(t);return Ge(i,function(n){var r=e[n];t[n]=r,a&&(t.prototype[n]=function(){var e=this.__chain__;if(o||e){var n=t(this.__wrapped__);return(n.__actions__=oo(this.__actions__)).push({func:r,args:arguments,thisArg:t}),n.__chain__=e,n}return r.apply(t,rn([this.value()],arguments))})}),t}function Pu(){}var Fu=_o(nn),Mu=_o(Ye),qu=_o(sn);function Bu(t){return Qo(t)?vn(pa(t)):function(t){return function(e){return Zr(e,t)}}(t)}var Hu=xo(),Uu=xo(!0);function Wu(){return[]}function zu(){return!1}var Vu=bo(function(t,e){return t+e},0),Xu=ko("ceil"),Ku=bo(function(t,e){return t/e},1),Ju=ko("floor");var Gu,Qu=bo(function(t,e){return t*e},1),Yu=ko("round"),Zu=bo(function(t,e){return t-e},0);return mr.after=function(t,e){if("function"!=typeof e)throw new oe(u);return t=Us(t),function(){if(--t<1)return e.apply(this,arguments)}},mr.ary=ns,mr.assign=Ks,mr.assignIn=Js,mr.assignInWith=Gs,mr.assignWith=Qs,mr.at=Ys,mr.before=rs,mr.bind=is,mr.bindAll=Su,mr.bindKey=os,mr.castArray=function(){if(!arguments.length)return[];var t=arguments[0];return bs(t)?t:[t]},mr.chain=Ba,mr.chunk=function(t,e,n){e=(n?Go(t,e,n):e===o)?1:Kn(Us(e),0);var i=null==t?0:t.length;if(!i||e<1)return[];for(var a=0,s=0,u=r(Bn(i/e));ai?0:i+n),(r=r===o||r>i?i:Us(r))<0&&(r+=i),r=n>r?0:Ws(r);n>>0)?(t=Xs(t))&&("string"==typeof e||null!=e&&!Is(e))&&!(e=Mi(e))&&Sn(t)?Qi(Ln(t),0,n):t.split(e,n):[]},mr.spread=function(t,e){if("function"!=typeof t)throw new oe(u);return e=null==e?0:Kn(Us(e),0),$i(function(n){var r=n[e],i=Qi(n,0,e);return r&&rn(i,r),Ke(t,this,i)})},mr.tail=function(t){var e=null==t?0:t.length;return e?Di(t,1,e):[]},mr.take=function(t,e,n){return t&&t.length?Di(t,0,(e=n||e===o?1:Us(e))<0?0:e):[]},mr.takeRight=function(t,e,n){var r=null==t?0:t.length;return r?Di(t,(e=r-(e=n||e===o?1:Us(e)))<0?0:e,r):[]},mr.takeRightWhile=function(t,e){return t&&t.length?Ui(t,Mo(e,3),!1,!0):[]},mr.takeWhile=function(t,e){return t&&t.length?Ui(t,Mo(e,3)):[]},mr.tap=function(t,e){return e(t),t},mr.throttle=function(t,e,n){var r=!0,i=!0;if("function"!=typeof t)throw new oe(u);return Es(n)&&(r="leading"in n?!!n.leading:r,i="trailing"in n?!!n.trailing:i),as(t,e,{leading:r,maxWait:e,trailing:i})},mr.thru=Ha,mr.toArray=Bs,mr.toPairs=du,mr.toPairsIn=hu,mr.toPath=function(t){return bs(t)?nn(t,pa):Ps(t)?[t]:oo(fa(Xs(t)))},mr.toPlainObject=Vs,mr.transform=function(t,e,n){var r=bs(t),i=r||Cs(t)||Fs(t);if(e=Mo(e,4),null==n){var o=t&&t.constructor;n=i?r?new o:[]:Es(t)&&$s(o)?gr(Ne(t)):{}}return(i?Ge:Gr)(t,function(t,r,i){return e(n,t,r,i)}),n},mr.unary=function(t){return ns(t,1)},mr.union=Oa,mr.unionBy=ja,mr.unionWith=Na,mr.uniq=function(t){return t&&t.length?qi(t):[]},mr.uniqBy=function(t,e){return t&&t.length?qi(t,Mo(e,2)):[]},mr.uniqWith=function(t,e){return e="function"==typeof e?e:o,t&&t.length?qi(t,o,e):[]},mr.unset=function(t,e){return null==t||Bi(t,e)},mr.unzip=Da,mr.unzipWith=Ia,mr.update=function(t,e,n){return null==t?t:Hi(t,e,Ki(n))},mr.updateWith=function(t,e,n,r){return r="function"==typeof r?r:o,null==t?t:Hi(t,e,Ki(n),r)},mr.values=vu,mr.valuesIn=function(t){return null==t?[]:wn(t,su(t))},mr.without=La,mr.words=$u,mr.wrap=function(t,e){return ps(Ki(e),t)},mr.xor=Ra,mr.xorBy=Pa,mr.xorWith=Fa,mr.zip=Ma,mr.zipObject=function(t,e){return Vi(t||[],e||[],jr)},mr.zipObjectDeep=function(t,e){return Vi(t||[],e||[],Ei)},mr.zipWith=qa,mr.entries=du,mr.entriesIn=hu,mr.extend=Js,mr.extendWith=Gs,Ru(mr,mr),mr.add=Vu,mr.attempt=Au,mr.camelCase=mu,mr.capitalize=gu,mr.ceil=Xu,mr.clamp=function(t,e,n){return n===o&&(n=e,e=o),n!==o&&(n=(n=zs(n))==n?n:0),e!==o&&(e=(e=zs(e))==e?e:0),Pr(zs(t),e,n)},mr.clone=function(t){return Fr(t,h)},mr.cloneDeep=function(t){return Fr(t,p|h)},mr.cloneDeepWith=function(t,e){return Fr(t,p|h,e="function"==typeof e?e:o)},mr.cloneWith=function(t,e){return Fr(t,h,e="function"==typeof e?e:o)},mr.conformsTo=function(t,e){return null==e||Mr(t,e,au(e))},mr.deburr=yu,mr.defaultTo=function(t,e){return null==t||t!=t?e:t},mr.divide=Ku,mr.endsWith=function(t,e,n){t=Xs(t),e=Mi(e);var r=t.length,i=n=n===o?r:Pr(Us(n),0,r);return(n-=e.length)>=0&&t.slice(n,i)==e},mr.eq=vs,mr.escape=function(t){return(t=Xs(t))&&kt.test(t)?t.replace(Ct,$n):t},mr.escapeRegExp=function(t){return(t=Xs(t))&&It.test(t)?t.replace(Dt,"\\$&"):t},mr.every=function(t,e,n){var r=bs(t)?Ye:Wr;return n&&Go(t,e,n)&&(e=o),r(t,Mo(e,3))},mr.find=za,mr.findIndex=ya,mr.findKey=function(t,e){return cn(t,Mo(e,3),Gr)},mr.findLast=Va,mr.findLastIndex=ba,mr.findLastKey=function(t,e){return cn(t,Mo(e,3),Qr)},mr.floor=Ju,mr.forEach=Xa,mr.forEachRight=Ka,mr.forIn=function(t,e){return null==t?t:Kr(t,Mo(e,3),su)},mr.forInRight=function(t,e){return null==t?t:Jr(t,Mo(e,3),su)},mr.forOwn=function(t,e){return t&&Gr(t,Mo(e,3))},mr.forOwnRight=function(t,e){return t&&Qr(t,Mo(e,3))},mr.get=eu,mr.gt=ms,mr.gte=gs,mr.has=function(t,e){return null!=t&&Vo(t,e,ri)},mr.hasIn=nu,mr.head=wa,mr.identity=Nu,mr.includes=function(t,e,n,r){t=ws(t)?t:vu(t),n=n&&!r?Us(n):0;var i=t.length;return n<0&&(n=Kn(i+n,0)),Rs(t)?n<=i&&t.indexOf(e,n)>-1:!!i&&fn(t,e,n)>-1},mr.indexOf=function(t,e,n){var r=null==t?0:t.length;if(!r)return-1;var i=null==n?0:Us(n);return i<0&&(i=Kn(r+i,0)),fn(t,e,i)},mr.inRange=function(t,e,n){return e=Hs(e),n===o?(n=e,e=0):n=Hs(n),function(t,e,n){return t>=Jn(e,n)&&t=-I&&t<=I},mr.isSet=Ls,mr.isString=Rs,mr.isSymbol=Ps,mr.isTypedArray=Fs,mr.isUndefined=function(t){return t===o},mr.isWeakMap=function(t){return Os(t)&&zo(t)==at},mr.isWeakSet=function(t){return Os(t)&&ei(t)==st},mr.join=function(t,e){return null==t?"":Vn.call(t,e)},mr.kebabCase=bu,mr.last=ka,mr.lastIndexOf=function(t,e,n){var r=null==t?0:t.length;if(!r)return-1;var i=r;return n!==o&&(i=(i=Us(n))<0?Kn(r+i,0):Jn(i,r-1)),e==e?function(t,e,n){for(var r=n+1;r--;)if(t[r]===e)return r;return r}(t,e,i):ln(t,dn,i,!0)},mr.lowerCase=_u,mr.lowerFirst=wu,mr.lt=Ms,mr.lte=qs,mr.max=function(t){return t&&t.length?zr(t,Nu,ni):o},mr.maxBy=function(t,e){return t&&t.length?zr(t,Mo(e,2),ni):o},mr.mean=function(t){return hn(t,Nu)},mr.meanBy=function(t,e){return hn(t,Mo(e,2))},mr.min=function(t){return t&&t.length?zr(t,Nu,hi):o},mr.minBy=function(t,e){return t&&t.length?zr(t,Mo(e,2),hi):o},mr.stubArray=Wu,mr.stubFalse=zu,mr.stubObject=function(){return{}},mr.stubString=function(){return""},mr.stubTrue=function(){return!0},mr.multiply=Qu,mr.nth=function(t,e){return t&&t.length?bi(t,Us(e)):o},mr.noConflict=function(){return Ie._===this&&(Ie._=me),this},mr.noop=Pu,mr.now=es,mr.pad=function(t,e,n){t=Xs(t);var r=(e=Us(e))?In(t):0;if(!e||r>=e)return t;var i=(e-r)/2;return wo(Hn(i),n)+t+wo(Bn(i),n)},mr.padEnd=function(t,e,n){t=Xs(t);var r=(e=Us(e))?In(t):0;return e&&re){var r=t;t=e,e=r}if(n||t%1||e%1){var i=Yn();return Jn(t+i*(e-t+Oe("1e-"+((i+"").length-1))),e)}return Ti(t,e)},mr.reduce=function(t,e,n){var r=bs(t)?on:gn,i=arguments.length<3;return r(t,Mo(e,4),n,i,Hr)},mr.reduceRight=function(t,e,n){var r=bs(t)?an:gn,i=arguments.length<3;return r(t,Mo(e,4),n,i,Ur)},mr.repeat=function(t,e,n){return e=(n?Go(t,e,n):e===o)?1:Us(e),ki(Xs(t),e)},mr.replace=function(){var t=arguments,e=Xs(t[0]);return t.length<3?e:e.replace(t[1],t[2])},mr.result=function(t,e,n){var r=-1,i=(e=Ji(e,t)).length;for(i||(i=1,t=o);++rI)return[];var n=P,r=Jn(t,P);e=Mo(e),t-=P;for(var i=bn(r,e);++n=a)return t;var u=n-In(r);if(u<1)return r;var c=s?Qi(s,0,u).join(""):t.slice(0,u);if(i===o)return c+r;if(s&&(u+=c.length-u),Is(i)){if(t.slice(u).search(i)){var l,f=c;for(i.global||(i=re(i.source,Xs(Wt.exec(i))+"g")),i.lastIndex=0;l=i.exec(f);)var p=l.index;c=c.slice(0,p===o?u:p)}}else if(t.indexOf(Mi(i),u)!=u){var d=c.lastIndexOf(i);d>-1&&(c=c.slice(0,d))}return c+r},mr.unescape=function(t){return(t=Xs(t))&&Tt.test(t)?t.replace(xt,Rn):t},mr.uniqueId=function(t){var e=++pe;return Xs(t)+e},mr.upperCase=Tu,mr.upperFirst=ku,mr.each=Xa,mr.eachRight=Ka,mr.first=wa,Ru(mr,(Gu={},Gr(mr,function(t,e){fe.call(mr.prototype,e)||(Gu[e]=t)}),Gu),{chain:!1}),mr.VERSION="4.17.4",Ge(["bind","bindKey","curry","curryRight","partial","partialRight"],function(t){mr[t].placeholder=mr}),Ge(["drop","take"],function(t,e){_r.prototype[t]=function(n){n=n===o?1:Kn(Us(n),0);var r=this.__filtered__&&!e?new _r(this):this.clone();return r.__filtered__?r.__takeCount__=Jn(n,r.__takeCount__):r.__views__.push({size:Jn(n,P),type:t+(r.__dir__<0?"Right":"")}),r},_r.prototype[t+"Right"]=function(e){return this.reverse()[t](e).reverse()}}),Ge(["filter","map","takeWhile"],function(t,e){var n=e+1,r=n==j||3==n;_r.prototype[t]=function(t){var e=this.clone();return e.__iteratees__.push({iteratee:Mo(t,3),type:n}),e.__filtered__=e.__filtered__||r,e}}),Ge(["head","last"],function(t,e){var n="take"+(e?"Right":"");_r.prototype[t]=function(){return this[n](1).value()[0]}}),Ge(["initial","tail"],function(t,e){var n="drop"+(e?"":"Right");_r.prototype[t]=function(){return this.__filtered__?new _r(this):this[n](1)}}),_r.prototype.compact=function(){return this.filter(Nu)},_r.prototype.find=function(t){return this.filter(t).head()},_r.prototype.findLast=function(t){return this.reverse().find(t)},_r.prototype.invokeMap=$i(function(t,e){return"function"==typeof t?new _r(this):this.map(function(n){return ai(n,t,e)})}),_r.prototype.reject=function(t){return this.filter(ls(Mo(t)))},_r.prototype.slice=function(t,e){t=Us(t);var n=this;return n.__filtered__&&(t>0||e<0)?new _r(n):(t<0?n=n.takeRight(-t):t&&(n=n.drop(t)),e!==o&&(n=(e=Us(e))<0?n.dropRight(-e):n.take(e-t)),n)},_r.prototype.takeRightWhile=function(t){return this.reverse().takeWhile(t).reverse()},_r.prototype.toArray=function(){return this.take(P)},Gr(_r.prototype,function(t,e){var n=/^(?:filter|find|map|reject)|While$/.test(e),r=/^(?:head|last)$/.test(e),i=mr[r?"take"+("last"==e?"Right":""):e],a=r||/^find/.test(e);i&&(mr.prototype[e]=function(){var e=this.__wrapped__,s=r?[1]:arguments,u=e instanceof _r,c=s[0],l=u||bs(e),f=function(t){var e=i.apply(mr,rn([t],s));return r&&p?e[0]:e};l&&n&&"function"==typeof c&&1!=c.length&&(u=l=!1);var p=this.__chain__,d=!!this.__actions__.length,h=a&&!p,v=u&&!d;if(!a&&l){e=v?e:new _r(this);var m=t.apply(e,s);return m.__actions__.push({func:Ha,args:[f],thisArg:o}),new br(m,p)}return h&&v?t.apply(this,s):(m=this.thru(f),h?r?m.value()[0]:m.value():m)})}),Ge(["pop","push","shift","sort","splice","unshift"],function(t){var e=ae[t],n=/^(?:push|sort|unshift)$/.test(t)?"tap":"thru",r=/^(?:pop|shift)$/.test(t);mr.prototype[t]=function(){var t=arguments;if(r&&!this.__chain__){var i=this.value();return e.apply(bs(i)?i:[],t)}return this[n](function(n){return e.apply(bs(n)?n:[],t)})}}),Gr(_r.prototype,function(t,e){var n=mr[e];if(n){var r=n.name+"";(sr[r]||(sr[r]=[])).push({name:e,func:n})}}),sr[go(o,y).name]=[{name:"wrapper",func:o}],_r.prototype.clone=function(){var t=new _r(this.__wrapped__);return t.__actions__=oo(this.__actions__),t.__dir__=this.__dir__,t.__filtered__=this.__filtered__,t.__iteratees__=oo(this.__iteratees__),t.__takeCount__=this.__takeCount__,t.__views__=oo(this.__views__),t},_r.prototype.reverse=function(){if(this.__filtered__){var t=new _r(this);t.__dir__=-1,t.__filtered__=!0}else(t=this.clone()).__dir__*=-1;return t},_r.prototype.value=function(){var t=this.__wrapped__.value(),e=this.__dir__,n=bs(t),r=e<0,i=n?t.length:0,o=function(t,e,n){for(var r=-1,i=n.length;++r=this.__values__.length;return{done:t,value:t?o:this.__values__[this.__index__++]}},mr.prototype.plant=function(t){for(var e,n=this;n instanceof yr;){var r=ha(n);r.__index__=0,r.__values__=o,e?i.__wrapped__=r:e=r;var i=r;n=n.__wrapped__}return i.__wrapped__=t,e},mr.prototype.reverse=function(){var t=this.__wrapped__;if(t instanceof _r){var e=t;return this.__actions__.length&&(e=new _r(this)),(e=e.reverse()).__actions__.push({func:Ha,args:[Ea],thisArg:o}),new br(e,this.__chain__)}return this.thru(Ea)},mr.prototype.toJSON=mr.prototype.valueOf=mr.prototype.value=function(){return Wi(this.__wrapped__,this.__actions__)},mr.prototype.first=mr.prototype.head,Me&&(mr.prototype[Me]=function(){return this}),mr}();Ie._=Pn,(i=function(){return Pn}.call(e,n,e,r))===o||(r.exports=i)}).call(this)}).call(e,n(1),n(16)(t))},function(t,e){t.exports=function(t){return t.webpackPolyfill||(t.deprecate=function(){},t.paths=[],t.children||(t.children=[]),Object.defineProperty(t,"loaded",{enumerable:!0,get:function(){return t.l}}),Object.defineProperty(t,"id",{enumerable:!0,get:function(){return t.i}}),t.webpackPolyfill=1),t}},function(t,e,n){var r;!function(e,n){"use strict";"object"==typeof t&&"object"==typeof t.exports?t.exports=e.document?n(e,!0):function(t){if(!t.document)throw new Error("jQuery requires a window with a document");return n(t)}:n(e)}("undefined"!=typeof window?window:this,function(n,i){"use strict";var o=[],a=n.document,s=Object.getPrototypeOf,u=o.slice,c=o.concat,l=o.push,f=o.indexOf,p={},d=p.toString,h=p.hasOwnProperty,v=h.toString,m=v.call(Object),g={},y=function(t){return"function"==typeof t&&"number"!=typeof t.nodeType},b=function(t){return null!=t&&t===t.window},_={type:!0,src:!0,noModule:!0};function w(t,e,n){var r,i=(e=e||a).createElement("script");if(i.text=t,n)for(r in _)n[r]&&(i[r]=n[r]);e.head.appendChild(i).parentNode.removeChild(i)}function x(t){return null==t?t+"":"object"==typeof t||"function"==typeof t?p[d.call(t)]||"object":typeof t}var C=function(t,e){return new C.fn.init(t,e)},T=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g;function k(t){var e=!!t&&"length"in t&&t.length,n=x(t);return!y(t)&&!b(t)&&("array"===n||0===e||"number"==typeof e&&e>0&&e-1 in t)}C.fn=C.prototype={jquery:"3.3.1",constructor:C,length:0,toArray:function(){return u.call(this)},get:function(t){return null==t?u.call(this):t<0?this[t+this.length]:this[t]},pushStack:function(t){var e=C.merge(this.constructor(),t);return e.prevObject=this,e},each:function(t){return C.each(this,t)},map:function(t){return this.pushStack(C.map(this,function(e,n){return t.call(e,n,e)}))},slice:function(){return this.pushStack(u.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(t){var e=this.length,n=+t+(t<0?e:0);return this.pushStack(n>=0&&n+~]|"+R+")"+R+"*"),W=new RegExp("="+R+"*([^\\]'\"]*?)"+R+"*\\]","g"),z=new RegExp(M),V=new RegExp("^"+P+"$"),X={ID:new RegExp("^#("+P+")"),CLASS:new RegExp("^\\.("+P+")"),TAG:new RegExp("^("+P+"|[*])"),ATTR:new RegExp("^"+F),PSEUDO:new RegExp("^"+M),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+R+"*(even|odd|(([+-]|)(\\d*)n|)"+R+"*(?:([+-]|)"+R+"*(\\d+)|))"+R+"*\\)|)","i"),bool:new RegExp("^(?:"+L+")$","i"),needsContext:new RegExp("^"+R+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+R+"*((?:-\\d)?\\d*)"+R+"*\\)|)(?=[^-]|$)","i")},K=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,G=/^[^{]+\{\s*\[native \w/,Q=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,Y=/[+~]/,Z=new RegExp("\\\\([\\da-f]{1,6}"+R+"?|("+R+")|.)","ig"),tt=function(t,e,n){var r="0x"+e-65536;return r!=r||n?e:r<0?String.fromCharCode(r+65536):String.fromCharCode(r>>10|55296,1023&r|56320)},et=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,nt=function(t,e){return e?"\0"===t?"�":t.slice(0,-1)+"\\"+t.charCodeAt(t.length-1).toString(16)+" ":"\\"+t},rt=function(){p()},it=yt(function(t){return!0===t.disabled&&("form"in t||"label"in t)},{dir:"parentNode",next:"legend"});try{N.apply(E=D.call(w.childNodes),w.childNodes),E[w.childNodes.length].nodeType}catch(t){N={apply:E.length?function(t,e){j.apply(t,D.call(e))}:function(t,e){for(var n=t.length,r=0;t[n++]=e[r++];);t.length=n-1}}}function ot(t,e,r,i){var o,s,c,l,f,h,g,y=e&&e.ownerDocument,x=e?e.nodeType:9;if(r=r||[],"string"!=typeof t||!t||1!==x&&9!==x&&11!==x)return r;if(!i&&((e?e.ownerDocument||e:w)!==d&&p(e),e=e||d,v)){if(11!==x&&(f=Q.exec(t)))if(o=f[1]){if(9===x){if(!(c=e.getElementById(o)))return r;if(c.id===o)return r.push(c),r}else if(y&&(c=y.getElementById(o))&&b(e,c)&&c.id===o)return r.push(c),r}else{if(f[2])return N.apply(r,e.getElementsByTagName(t)),r;if((o=f[3])&&n.getElementsByClassName&&e.getElementsByClassName)return N.apply(r,e.getElementsByClassName(o)),r}if(n.qsa&&!$[t+" "]&&(!m||!m.test(t))){if(1!==x)y=e,g=t;else if("object"!==e.nodeName.toLowerCase()){for((l=e.getAttribute("id"))?l=l.replace(et,nt):e.setAttribute("id",l=_),s=(h=a(t)).length;s--;)h[s]="#"+l+" "+gt(h[s]);g=h.join(","),y=Y.test(t)&&vt(e.parentNode)||e}if(g)try{return N.apply(r,y.querySelectorAll(g)),r}catch(t){}finally{l===_&&e.removeAttribute("id")}}}return u(t.replace(B,"$1"),e,r,i)}function at(){var t=[];return function e(n,i){return t.push(n+" ")>r.cacheLength&&delete e[t.shift()],e[n+" "]=i}}function st(t){return t[_]=!0,t}function ut(t){var e=d.createElement("fieldset");try{return!!t(e)}catch(t){return!1}finally{e.parentNode&&e.parentNode.removeChild(e),e=null}}function ct(t,e){for(var n=t.split("|"),i=n.length;i--;)r.attrHandle[n[i]]=e}function lt(t,e){var n=e&&t,r=n&&1===t.nodeType&&1===e.nodeType&&t.sourceIndex-e.sourceIndex;if(r)return r;if(n)for(;n=n.nextSibling;)if(n===e)return-1;return t?1:-1}function ft(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function pt(t){return function(e){var n=e.nodeName.toLowerCase();return("input"===n||"button"===n)&&e.type===t}}function dt(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&it(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ht(t){return st(function(e){return e=+e,st(function(n,r){for(var i,o=t([],n.length,e),a=o.length;a--;)n[i=o[a]]&&(n[i]=!(r[i]=n[i]))})})}function vt(t){return t&&void 0!==t.getElementsByTagName&&t}for(e in n=ot.support={},o=ot.isXML=function(t){var e=t&&(t.ownerDocument||t).documentElement;return!!e&&"HTML"!==e.nodeName},p=ot.setDocument=function(t){var e,i,a=t?t.ownerDocument||t:w;return a!==d&&9===a.nodeType&&a.documentElement?(h=(d=a).documentElement,v=!o(d),w!==d&&(i=d.defaultView)&&i.top!==i&&(i.addEventListener?i.addEventListener("unload",rt,!1):i.attachEvent&&i.attachEvent("onunload",rt)),n.attributes=ut(function(t){return t.className="i",!t.getAttribute("className")}),n.getElementsByTagName=ut(function(t){return t.appendChild(d.createComment("")),!t.getElementsByTagName("*").length}),n.getElementsByClassName=G.test(d.getElementsByClassName),n.getById=ut(function(t){return h.appendChild(t).id=_,!d.getElementsByName||!d.getElementsByName(_).length}),n.getById?(r.filter.ID=function(t){var e=t.replace(Z,tt);return function(t){return t.getAttribute("id")===e}},r.find.ID=function(t,e){if(void 0!==e.getElementById&&v){var n=e.getElementById(t);return n?[n]:[]}}):(r.filter.ID=function(t){var e=t.replace(Z,tt);return function(t){var n=void 0!==t.getAttributeNode&&t.getAttributeNode("id");return n&&n.value===e}},r.find.ID=function(t,e){if(void 0!==e.getElementById&&v){var n,r,i,o=e.getElementById(t);if(o){if((n=o.getAttributeNode("id"))&&n.value===t)return[o];for(i=e.getElementsByName(t),r=0;o=i[r++];)if((n=o.getAttributeNode("id"))&&n.value===t)return[o]}return[]}}),r.find.TAG=n.getElementsByTagName?function(t,e){return void 0!==e.getElementsByTagName?e.getElementsByTagName(t):n.qsa?e.querySelectorAll(t):void 0}:function(t,e){var n,r=[],i=0,o=e.getElementsByTagName(t);if("*"===t){for(;n=o[i++];)1===n.nodeType&&r.push(n);return r}return o},r.find.CLASS=n.getElementsByClassName&&function(t,e){if(void 0!==e.getElementsByClassName&&v)return e.getElementsByClassName(t)},g=[],m=[],(n.qsa=G.test(d.querySelectorAll))&&(ut(function(t){h.appendChild(t).innerHTML="",t.querySelectorAll("[msallowcapture^='']").length&&m.push("[*^$]="+R+"*(?:''|\"\")"),t.querySelectorAll("[selected]").length||m.push("\\["+R+"*(?:value|"+L+")"),t.querySelectorAll("[id~="+_+"-]").length||m.push("~="),t.querySelectorAll(":checked").length||m.push(":checked"),t.querySelectorAll("a#"+_+"+*").length||m.push(".#.+[+~]")}),ut(function(t){t.innerHTML="";var e=d.createElement("input");e.setAttribute("type","hidden"),t.appendChild(e).setAttribute("name","D"),t.querySelectorAll("[name=d]").length&&m.push("name"+R+"*[*^$|!~]?="),2!==t.querySelectorAll(":enabled").length&&m.push(":enabled",":disabled"),h.appendChild(t).disabled=!0,2!==t.querySelectorAll(":disabled").length&&m.push(":enabled",":disabled"),t.querySelectorAll("*,:x"),m.push(",.*:")})),(n.matchesSelector=G.test(y=h.matches||h.webkitMatchesSelector||h.mozMatchesSelector||h.oMatchesSelector||h.msMatchesSelector))&&ut(function(t){n.disconnectedMatch=y.call(t,"*"),y.call(t,"[s!='']:x"),g.push("!=",M)}),m=m.length&&new RegExp(m.join("|")),g=g.length&&new RegExp(g.join("|")),e=G.test(h.compareDocumentPosition),b=e||G.test(h.contains)?function(t,e){var n=9===t.nodeType?t.documentElement:t,r=e&&e.parentNode;return t===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):t.compareDocumentPosition&&16&t.compareDocumentPosition(r)))}:function(t,e){if(e)for(;e=e.parentNode;)if(e===t)return!0;return!1},A=e?function(t,e){if(t===e)return f=!0,0;var r=!t.compareDocumentPosition-!e.compareDocumentPosition;return r||(1&(r=(t.ownerDocument||t)===(e.ownerDocument||e)?t.compareDocumentPosition(e):1)||!n.sortDetached&&e.compareDocumentPosition(t)===r?t===d||t.ownerDocument===w&&b(w,t)?-1:e===d||e.ownerDocument===w&&b(w,e)?1:l?I(l,t)-I(l,e):0:4&r?-1:1)}:function(t,e){if(t===e)return f=!0,0;var n,r=0,i=t.parentNode,o=e.parentNode,a=[t],s=[e];if(!i||!o)return t===d?-1:e===d?1:i?-1:o?1:l?I(l,t)-I(l,e):0;if(i===o)return lt(t,e);for(n=t;n=n.parentNode;)a.unshift(n);for(n=e;n=n.parentNode;)s.unshift(n);for(;a[r]===s[r];)r++;return r?lt(a[r],s[r]):a[r]===w?-1:s[r]===w?1:0},d):d},ot.matches=function(t,e){return ot(t,null,null,e)},ot.matchesSelector=function(t,e){if((t.ownerDocument||t)!==d&&p(t),e=e.replace(W,"='$1']"),n.matchesSelector&&v&&!$[e+" "]&&(!g||!g.test(e))&&(!m||!m.test(e)))try{var r=y.call(t,e);if(r||n.disconnectedMatch||t.document&&11!==t.document.nodeType)return r}catch(t){}return ot(e,d,null,[t]).length>0},ot.contains=function(t,e){return(t.ownerDocument||t)!==d&&p(t),b(t,e)},ot.attr=function(t,e){(t.ownerDocument||t)!==d&&p(t);var i=r.attrHandle[e.toLowerCase()],o=i&&S.call(r.attrHandle,e.toLowerCase())?i(t,e,!v):void 0;return void 0!==o?o:n.attributes||!v?t.getAttribute(e):(o=t.getAttributeNode(e))&&o.specified?o.value:null},ot.escape=function(t){return(t+"").replace(et,nt)},ot.error=function(t){throw new Error("Syntax error, unrecognized expression: "+t)},ot.uniqueSort=function(t){var e,r=[],i=0,o=0;if(f=!n.detectDuplicates,l=!n.sortStable&&t.slice(0),t.sort(A),f){for(;e=t[o++];)e===t[o]&&(i=r.push(o));for(;i--;)t.splice(r[i],1)}return l=null,t},i=ot.getText=function(t){var e,n="",r=0,o=t.nodeType;if(o){if(1===o||9===o||11===o){if("string"==typeof t.textContent)return t.textContent;for(t=t.firstChild;t;t=t.nextSibling)n+=i(t)}else if(3===o||4===o)return t.nodeValue}else for(;e=t[r++];)n+=i(e);return n},(r=ot.selectors={cacheLength:50,createPseudo:st,match:X,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(t){return t[1]=t[1].replace(Z,tt),t[3]=(t[3]||t[4]||t[5]||"").replace(Z,tt),"~="===t[2]&&(t[3]=" "+t[3]+" "),t.slice(0,4)},CHILD:function(t){return t[1]=t[1].toLowerCase(),"nth"===t[1].slice(0,3)?(t[3]||ot.error(t[0]),t[4]=+(t[4]?t[5]+(t[6]||1):2*("even"===t[3]||"odd"===t[3])),t[5]=+(t[7]+t[8]||"odd"===t[3])):t[3]&&ot.error(t[0]),t},PSEUDO:function(t){var e,n=!t[6]&&t[2];return X.CHILD.test(t[0])?null:(t[3]?t[2]=t[4]||t[5]||"":n&&z.test(n)&&(e=a(n,!0))&&(e=n.indexOf(")",n.length-e)-n.length)&&(t[0]=t[0].slice(0,e),t[2]=n.slice(0,e)),t.slice(0,3))}},filter:{TAG:function(t){var e=t.replace(Z,tt).toLowerCase();return"*"===t?function(){return!0}:function(t){return t.nodeName&&t.nodeName.toLowerCase()===e}},CLASS:function(t){var e=T[t+" "];return e||(e=new RegExp("(^|"+R+")"+t+"("+R+"|$)"))&&T(t,function(t){return e.test("string"==typeof t.className&&t.className||void 0!==t.getAttribute&&t.getAttribute("class")||"")})},ATTR:function(t,e,n){return function(r){var i=ot.attr(r,t);return null==i?"!="===e:!e||(i+="","="===e?i===n:"!="===e?i!==n:"^="===e?n&&0===i.indexOf(n):"*="===e?n&&i.indexOf(n)>-1:"$="===e?n&&i.slice(-n.length)===n:"~="===e?(" "+i.replace(q," ")+" ").indexOf(n)>-1:"|="===e&&(i===n||i.slice(0,n.length+1)===n+"-"))}},CHILD:function(t,e,n,r,i){var o="nth"!==t.slice(0,3),a="last"!==t.slice(-4),s="of-type"===e;return 1===r&&0===i?function(t){return!!t.parentNode}:function(e,n,u){var c,l,f,p,d,h,v=o!==a?"nextSibling":"previousSibling",m=e.parentNode,g=s&&e.nodeName.toLowerCase(),y=!u&&!s,b=!1;if(m){if(o){for(;v;){for(p=e;p=p[v];)if(s?p.nodeName.toLowerCase()===g:1===p.nodeType)return!1;h=v="only"===t&&!h&&"nextSibling"}return!0}if(h=[a?m.firstChild:m.lastChild],a&&y){for(b=(d=(c=(l=(f=(p=m)[_]||(p[_]={}))[p.uniqueID]||(f[p.uniqueID]={}))[t]||[])[0]===x&&c[1])&&c[2],p=d&&m.childNodes[d];p=++d&&p&&p[v]||(b=d=0)||h.pop();)if(1===p.nodeType&&++b&&p===e){l[t]=[x,d,b];break}}else if(y&&(b=d=(c=(l=(f=(p=e)[_]||(p[_]={}))[p.uniqueID]||(f[p.uniqueID]={}))[t]||[])[0]===x&&c[1]),!1===b)for(;(p=++d&&p&&p[v]||(b=d=0)||h.pop())&&((s?p.nodeName.toLowerCase()!==g:1!==p.nodeType)||!++b||(y&&((l=(f=p[_]||(p[_]={}))[p.uniqueID]||(f[p.uniqueID]={}))[t]=[x,b]),p!==e)););return(b-=i)===r||b%r==0&&b/r>=0}}},PSEUDO:function(t,e){var n,i=r.pseudos[t]||r.setFilters[t.toLowerCase()]||ot.error("unsupported pseudo: "+t);return i[_]?i(e):i.length>1?(n=[t,t,"",e],r.setFilters.hasOwnProperty(t.toLowerCase())?st(function(t,n){for(var r,o=i(t,e),a=o.length;a--;)t[r=I(t,o[a])]=!(n[r]=o[a])}):function(t){return i(t,0,n)}):i}},pseudos:{not:st(function(t){var e=[],n=[],r=s(t.replace(B,"$1"));return r[_]?st(function(t,e,n,i){for(var o,a=r(t,null,i,[]),s=t.length;s--;)(o=a[s])&&(t[s]=!(e[s]=o))}):function(t,i,o){return e[0]=t,r(e,null,o,n),e[0]=null,!n.pop()}}),has:st(function(t){return function(e){return ot(t,e).length>0}}),contains:st(function(t){return t=t.replace(Z,tt),function(e){return(e.textContent||e.innerText||i(e)).indexOf(t)>-1}}),lang:st(function(t){return V.test(t||"")||ot.error("unsupported lang: "+t),t=t.replace(Z,tt).toLowerCase(),function(e){var n;do{if(n=v?e.lang:e.getAttribute("xml:lang")||e.getAttribute("lang"))return(n=n.toLowerCase())===t||0===n.indexOf(t+"-")}while((e=e.parentNode)&&1===e.nodeType);return!1}}),target:function(e){var n=t.location&&t.location.hash;return n&&n.slice(1)===e.id},root:function(t){return t===h},focus:function(t){return t===d.activeElement&&(!d.hasFocus||d.hasFocus())&&!!(t.type||t.href||~t.tabIndex)},enabled:dt(!1),disabled:dt(!0),checked:function(t){var e=t.nodeName.toLowerCase();return"input"===e&&!!t.checked||"option"===e&&!!t.selected},selected:function(t){return t.parentNode&&t.parentNode.selectedIndex,!0===t.selected},empty:function(t){for(t=t.firstChild;t;t=t.nextSibling)if(t.nodeType<6)return!1;return!0},parent:function(t){return!r.pseudos.empty(t)},header:function(t){return J.test(t.nodeName)},input:function(t){return K.test(t.nodeName)},button:function(t){var e=t.nodeName.toLowerCase();return"input"===e&&"button"===t.type||"button"===e},text:function(t){var e;return"input"===t.nodeName.toLowerCase()&&"text"===t.type&&(null==(e=t.getAttribute("type"))||"text"===e.toLowerCase())},first:ht(function(){return[0]}),last:ht(function(t,e){return[e-1]}),eq:ht(function(t,e,n){return[n<0?n+e:n]}),even:ht(function(t,e){for(var n=0;n=0;)t.push(r);return t}),gt:ht(function(t,e,n){for(var r=n<0?n+e:n;++r1?function(e,n,r){for(var i=t.length;i--;)if(!t[i](e,n,r))return!1;return!0}:t[0]}function _t(t,e,n,r,i){for(var o,a=[],s=0,u=t.length,c=null!=e;s-1&&(o[c]=!(a[c]=f))}}else g=_t(g===a?g.splice(h,g.length):g),i?i(null,a,g,u):N.apply(a,g)})}function xt(t){for(var e,n,i,o=t.length,a=r.relative[t[0].type],s=a||r.relative[" "],u=a?1:0,l=yt(function(t){return t===e},s,!0),f=yt(function(t){return I(e,t)>-1},s,!0),p=[function(t,n,r){var i=!a&&(r||n!==c)||((e=n).nodeType?l(t,n,r):f(t,n,r));return e=null,i}];u1&&bt(p),u>1&>(t.slice(0,u-1).concat({value:" "===t[u-2].type?"*":""})).replace(B,"$1"),n,u0,i=t.length>0,o=function(o,a,s,u,l){var f,h,m,g=0,y="0",b=o&&[],_=[],w=c,C=o||i&&r.find.TAG("*",l),T=x+=null==w?1:Math.random()||.1,k=C.length;for(l&&(c=a===d||a||l);y!==k&&null!=(f=C[y]);y++){if(i&&f){for(h=0,a||f.ownerDocument===d||(p(f),s=!v);m=t[h++];)if(m(f,a||d,s)){u.push(f);break}l&&(x=T)}n&&((f=!m&&f)&&g--,o&&b.push(f))}if(g+=y,n&&y!==g){for(h=0;m=e[h++];)m(b,_,a,s);if(o){if(g>0)for(;y--;)b[y]||_[y]||(_[y]=O.call(u));_=_t(_)}N.apply(u,_),l&&!o&&_.length>0&&g+e.length>1&&ot.uniqueSort(u)}return l&&(x=T,c=w),b};return n?st(o):o}(o,i))).selector=t}return s},u=ot.select=function(t,e,n,i){var o,u,c,l,f,p="function"==typeof t&&t,d=!i&&a(t=p.selector||t);if(n=n||[],1===d.length){if((u=d[0]=d[0].slice(0)).length>2&&"ID"===(c=u[0]).type&&9===e.nodeType&&v&&r.relative[u[1].type]){if(!(e=(r.find.ID(c.matches[0].replace(Z,tt),e)||[])[0]))return n;p&&(e=e.parentNode),t=t.slice(u.shift().value.length)}for(o=X.needsContext.test(t)?0:u.length;o--&&(c=u[o],!r.relative[l=c.type]);)if((f=r.find[l])&&(i=f(c.matches[0].replace(Z,tt),Y.test(u[0].type)&&vt(e.parentNode)||e))){if(u.splice(o,1),!(t=i.length&>(u)))return N.apply(n,i),n;break}}return(p||s(t,d))(i,e,!v,n,!e||Y.test(t)&&vt(e.parentNode)||e),n},n.sortStable=_.split("").sort(A).join("")===_,n.detectDuplicates=!!f,p(),n.sortDetached=ut(function(t){return 1&t.compareDocumentPosition(d.createElement("fieldset"))}),ut(function(t){return t.innerHTML="","#"===t.firstChild.getAttribute("href")})||ct("type|href|height|width",function(t,e,n){if(!n)return t.getAttribute(e,"type"===e.toLowerCase()?1:2)}),n.attributes&&ut(function(t){return t.innerHTML="",t.firstChild.setAttribute("value",""),""===t.firstChild.getAttribute("value")})||ct("value",function(t,e,n){if(!n&&"input"===t.nodeName.toLowerCase())return t.defaultValue}),ut(function(t){return null==t.getAttribute("disabled")})||ct(L,function(t,e,n){var r;if(!n)return!0===t[e]?e.toLowerCase():(r=t.getAttributeNode(e))&&r.specified?r.value:null}),ot}(n);C.find=$,C.expr=$.selectors,C.expr[":"]=C.expr.pseudos,C.uniqueSort=C.unique=$.uniqueSort,C.text=$.getText,C.isXMLDoc=$.isXML,C.contains=$.contains,C.escapeSelector=$.escape;var A=function(t,e,n){for(var r=[],i=void 0!==n;(t=t[e])&&9!==t.nodeType;)if(1===t.nodeType){if(i&&C(t).is(n))break;r.push(t)}return r},S=function(t,e){for(var n=[];t;t=t.nextSibling)1===t.nodeType&&t!==e&&n.push(t);return n},E=C.expr.match.needsContext;function O(t,e){return t.nodeName&&t.nodeName.toLowerCase()===e.toLowerCase()}var j=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function N(t,e,n){return y(e)?C.grep(t,function(t,r){return!!e.call(t,r,t)!==n}):e.nodeType?C.grep(t,function(t){return t===e!==n}):"string"!=typeof e?C.grep(t,function(t){return f.call(e,t)>-1!==n}):C.filter(e,t,n)}C.filter=function(t,e,n){var r=e[0];return n&&(t=":not("+t+")"),1===e.length&&1===r.nodeType?C.find.matchesSelector(r,t)?[r]:[]:C.find.matches(t,C.grep(e,function(t){return 1===t.nodeType}))},C.fn.extend({find:function(t){var e,n,r=this.length,i=this;if("string"!=typeof t)return this.pushStack(C(t).filter(function(){for(e=0;e1?C.uniqueSort(n):n},filter:function(t){return this.pushStack(N(this,t||[],!1))},not:function(t){return this.pushStack(N(this,t||[],!0))},is:function(t){return!!N(this,"string"==typeof t&&E.test(t)?C(t):t||[],!1).length}});var D,I=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/;(C.fn.init=function(t,e,n){var r,i;if(!t)return this;if(n=n||D,"string"==typeof t){if(!(r="<"===t[0]&&">"===t[t.length-1]&&t.length>=3?[null,t,null]:I.exec(t))||!r[1]&&e)return!e||e.jquery?(e||n).find(t):this.constructor(e).find(t);if(r[1]){if(e=e instanceof C?e[0]:e,C.merge(this,C.parseHTML(r[1],e&&e.nodeType?e.ownerDocument||e:a,!0)),j.test(r[1])&&C.isPlainObject(e))for(r in e)y(this[r])?this[r](e[r]):this.attr(r,e[r]);return this}return(i=a.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return t.nodeType?(this[0]=t,this.length=1,this):y(t)?void 0!==n.ready?n.ready(t):t(C):C.makeArray(t,this)}).prototype=C.fn,D=C(a);var L=/^(?:parents|prev(?:Until|All))/,R={children:!0,contents:!0,next:!0,prev:!0};function P(t,e){for(;(t=t[e])&&1!==t.nodeType;);return t}C.fn.extend({has:function(t){var e=C(t,this),n=e.length;return this.filter(function(){for(var t=0;t-1:1===n.nodeType&&C.find.matchesSelector(n,t))){o.push(n);break}return this.pushStack(o.length>1?C.uniqueSort(o):o)},index:function(t){return t?"string"==typeof t?f.call(C(t),this[0]):f.call(this,t.jquery?t[0]:t):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(t,e){return this.pushStack(C.uniqueSort(C.merge(this.get(),C(t,e))))},addBack:function(t){return this.add(null==t?this.prevObject:this.prevObject.filter(t))}}),C.each({parent:function(t){var e=t.parentNode;return e&&11!==e.nodeType?e:null},parents:function(t){return A(t,"parentNode")},parentsUntil:function(t,e,n){return A(t,"parentNode",n)},next:function(t){return P(t,"nextSibling")},prev:function(t){return P(t,"previousSibling")},nextAll:function(t){return A(t,"nextSibling")},prevAll:function(t){return A(t,"previousSibling")},nextUntil:function(t,e,n){return A(t,"nextSibling",n)},prevUntil:function(t,e,n){return A(t,"previousSibling",n)},siblings:function(t){return S((t.parentNode||{}).firstChild,t)},children:function(t){return S(t.firstChild)},contents:function(t){return O(t,"iframe")?t.contentDocument:(O(t,"template")&&(t=t.content||t),C.merge([],t.childNodes))}},function(t,e){C.fn[t]=function(n,r){var i=C.map(this,e,n);return"Until"!==t.slice(-5)&&(r=n),r&&"string"==typeof r&&(i=C.filter(r,i)),this.length>1&&(R[t]||C.uniqueSort(i),L.test(t)&&i.reverse()),this.pushStack(i)}});var F=/[^\x20\t\r\n\f]+/g;function M(t){return t}function q(t){throw t}function B(t,e,n,r){var i;try{t&&y(i=t.promise)?i.call(t).done(e).fail(n):t&&y(i=t.then)?i.call(t,e,n):e.apply(void 0,[t].slice(r))}catch(t){n.apply(void 0,[t])}}C.Callbacks=function(t){t="string"==typeof t?function(t){var e={};return C.each(t.match(F)||[],function(t,n){e[n]=!0}),e}(t):C.extend({},t);var e,n,r,i,o=[],a=[],s=-1,u=function(){for(i=i||t.once,r=e=!0;a.length;s=-1)for(n=a.shift();++s-1;)o.splice(n,1),n<=s&&s--}),this},has:function(t){return t?C.inArray(t,o)>-1:o.length>0},empty:function(){return o&&(o=[]),this},disable:function(){return i=a=[],o=n="",this},disabled:function(){return!o},lock:function(){return i=a=[],n||e||(o=n=""),this},locked:function(){return!!i},fireWith:function(t,n){return i||(n=[t,(n=n||[]).slice?n.slice():n],a.push(n),e||u()),this},fire:function(){return c.fireWith(this,arguments),this},fired:function(){return!!r}};return c},C.extend({Deferred:function(t){var e=[["notify","progress",C.Callbacks("memory"),C.Callbacks("memory"),2],["resolve","done",C.Callbacks("once memory"),C.Callbacks("once memory"),0,"resolved"],["reject","fail",C.Callbacks("once memory"),C.Callbacks("once memory"),1,"rejected"]],r="pending",i={state:function(){return r},always:function(){return o.done(arguments).fail(arguments),this},catch:function(t){return i.then(null,t)},pipe:function(){var t=arguments;return C.Deferred(function(n){C.each(e,function(e,r){var i=y(t[r[4]])&&t[r[4]];o[r[1]](function(){var t=i&&i.apply(this,arguments);t&&y(t.promise)?t.promise().progress(n.notify).done(n.resolve).fail(n.reject):n[r[0]+"With"](this,i?[t]:arguments)})}),t=null}).promise()},then:function(t,r,i){var o=0;function a(t,e,r,i){return function(){var s=this,u=arguments,c=function(){var n,c;if(!(t=o&&(r!==q&&(s=void 0,u=[n]),e.rejectWith(s,u))}};t?l():(C.Deferred.getStackHook&&(l.stackTrace=C.Deferred.getStackHook()),n.setTimeout(l))}}return C.Deferred(function(n){e[0][3].add(a(0,n,y(i)?i:M,n.notifyWith)),e[1][3].add(a(0,n,y(t)?t:M)),e[2][3].add(a(0,n,y(r)?r:q))}).promise()},promise:function(t){return null!=t?C.extend(t,i):i}},o={};return C.each(e,function(t,n){var a=n[2],s=n[5];i[n[1]]=a.add,s&&a.add(function(){r=s},e[3-t][2].disable,e[3-t][3].disable,e[0][2].lock,e[0][3].lock),a.add(n[3].fire),o[n[0]]=function(){return o[n[0]+"With"](this===o?void 0:this,arguments),this},o[n[0]+"With"]=a.fireWith}),i.promise(o),t&&t.call(o,o),o},when:function(t){var e=arguments.length,n=e,r=Array(n),i=u.call(arguments),o=C.Deferred(),a=function(t){return function(n){r[t]=this,i[t]=arguments.length>1?u.call(arguments):n,--e||o.resolveWith(r,i)}};if(e<=1&&(B(t,o.done(a(n)).resolve,o.reject,!e),"pending"===o.state()||y(i[n]&&i[n].then)))return o.then();for(;n--;)B(i[n],a(n),o.reject);return o.promise()}});var H=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;C.Deferred.exceptionHook=function(t,e){n.console&&n.console.warn&&t&&H.test(t.name)&&n.console.warn("jQuery.Deferred exception: "+t.message,t.stack,e)},C.readyException=function(t){n.setTimeout(function(){throw t})};var U=C.Deferred();function W(){a.removeEventListener("DOMContentLoaded",W),n.removeEventListener("load",W),C.ready()}C.fn.ready=function(t){return U.then(t).catch(function(t){C.readyException(t)}),this},C.extend({isReady:!1,readyWait:1,ready:function(t){(!0===t?--C.readyWait:C.isReady)||(C.isReady=!0,!0!==t&&--C.readyWait>0||U.resolveWith(a,[C]))}}),C.ready.then=U.then,"complete"===a.readyState||"loading"!==a.readyState&&!a.documentElement.doScroll?n.setTimeout(C.ready):(a.addEventListener("DOMContentLoaded",W),n.addEventListener("load",W));var z=function(t,e,n,r,i,o,a){var s=0,u=t.length,c=null==n;if("object"===x(n))for(s in i=!0,n)z(t,e,s,n[s],!0,o,a);else if(void 0!==r&&(i=!0,y(r)||(a=!0),c&&(a?(e.call(t,r),e=null):(c=e,e=function(t,e,n){return c.call(C(t),n)})),e))for(;s1,null,!0)},removeData:function(t){return this.each(function(){Z.remove(this,t)})}}),C.extend({queue:function(t,e,n){var r;if(t)return e=(e||"fx")+"queue",r=Y.get(t,e),n&&(!r||Array.isArray(n)?r=Y.access(t,e,C.makeArray(n)):r.push(n)),r||[]},dequeue:function(t,e){e=e||"fx";var n=C.queue(t,e),r=n.length,i=n.shift(),o=C._queueHooks(t,e);"inprogress"===i&&(i=n.shift(),r--),i&&("fx"===e&&n.unshift("inprogress"),delete o.stop,i.call(t,function(){C.dequeue(t,e)},o)),!r&&o&&o.empty.fire()},_queueHooks:function(t,e){var n=e+"queueHooks";return Y.get(t,n)||Y.access(t,n,{empty:C.Callbacks("once memory").add(function(){Y.remove(t,[e+"queue",n])})})}}),C.fn.extend({queue:function(t,e){var n=2;return"string"!=typeof t&&(e=t,t="fx",n--),arguments.length\x20\t\r\n\f]+)/i,ht=/^$|^module$|\/(?:java|ecma)script/i,vt={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function mt(t,e){var n;return n=void 0!==t.getElementsByTagName?t.getElementsByTagName(e||"*"):void 0!==t.querySelectorAll?t.querySelectorAll(e||"*"):[],void 0===e||e&&O(t,e)?C.merge([t],n):n}function gt(t,e){for(var n=0,r=t.length;n-1)i&&i.push(o);else if(c=C.contains(o.ownerDocument,o),a=mt(f.appendChild(o),"script"),c&>(a),n)for(l=0;o=a[l++];)ht.test(o.type||"")&&n.push(o);return f}yt=a.createDocumentFragment().appendChild(a.createElement("div")),(bt=a.createElement("input")).setAttribute("type","radio"),bt.setAttribute("checked","checked"),bt.setAttribute("name","t"),yt.appendChild(bt),g.checkClone=yt.cloneNode(!0).cloneNode(!0).lastChild.checked,yt.innerHTML="",g.noCloneChecked=!!yt.cloneNode(!0).lastChild.defaultValue;var xt=a.documentElement,Ct=/^key/,Tt=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,kt=/^([^.]*)(?:\.(.+)|)/;function $t(){return!0}function At(){return!1}function St(){try{return a.activeElement}catch(t){}}function Et(t,e,n,r,i,o){var a,s;if("object"==typeof e){for(s in"string"!=typeof n&&(r=r||n,n=void 0),e)Et(t,s,n,r,e[s],o);return t}if(null==r&&null==i?(i=n,r=n=void 0):null==i&&("string"==typeof n?(i=r,r=void 0):(i=r,r=n,n=void 0)),!1===i)i=At;else if(!i)return t;return 1===o&&(a=i,(i=function(t){return C().off(t),a.apply(this,arguments)}).guid=a.guid||(a.guid=C.guid++)),t.each(function(){C.event.add(this,e,i,r,n)})}C.event={global:{},add:function(t,e,n,r,i){var o,a,s,u,c,l,f,p,d,h,v,m=Y.get(t);if(m)for(n.handler&&(n=(o=n).handler,i=o.selector),i&&C.find.matchesSelector(xt,i),n.guid||(n.guid=C.guid++),(u=m.events)||(u=m.events={}),(a=m.handle)||(a=m.handle=function(e){return void 0!==C&&C.event.triggered!==e.type?C.event.dispatch.apply(t,arguments):void 0}),c=(e=(e||"").match(F)||[""]).length;c--;)d=v=(s=kt.exec(e[c])||[])[1],h=(s[2]||"").split(".").sort(),d&&(f=C.event.special[d]||{},d=(i?f.delegateType:f.bindType)||d,f=C.event.special[d]||{},l=C.extend({type:d,origType:v,data:r,handler:n,guid:n.guid,selector:i,needsContext:i&&C.expr.match.needsContext.test(i),namespace:h.join(".")},o),(p=u[d])||((p=u[d]=[]).delegateCount=0,f.setup&&!1!==f.setup.call(t,r,h,a)||t.addEventListener&&t.addEventListener(d,a)),f.add&&(f.add.call(t,l),l.handler.guid||(l.handler.guid=n.guid)),i?p.splice(p.delegateCount++,0,l):p.push(l),C.event.global[d]=!0)},remove:function(t,e,n,r,i){var o,a,s,u,c,l,f,p,d,h,v,m=Y.hasData(t)&&Y.get(t);if(m&&(u=m.events)){for(c=(e=(e||"").match(F)||[""]).length;c--;)if(d=v=(s=kt.exec(e[c])||[])[1],h=(s[2]||"").split(".").sort(),d){for(f=C.event.special[d]||{},p=u[d=(r?f.delegateType:f.bindType)||d]||[],s=s[2]&&new RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),a=o=p.length;o--;)l=p[o],!i&&v!==l.origType||n&&n.guid!==l.guid||s&&!s.test(l.namespace)||r&&r!==l.selector&&("**"!==r||!l.selector)||(p.splice(o,1),l.selector&&p.delegateCount--,f.remove&&f.remove.call(t,l));a&&!p.length&&(f.teardown&&!1!==f.teardown.call(t,h,m.handle)||C.removeEvent(t,d,m.handle),delete u[d])}else for(d in u)C.event.remove(t,d+e[c],n,r,!0);C.isEmptyObject(u)&&Y.remove(t,"handle events")}},dispatch:function(t){var e,n,r,i,o,a,s=C.event.fix(t),u=new Array(arguments.length),c=(Y.get(this,"events")||{})[s.type]||[],l=C.event.special[s.type]||{};for(u[0]=s,e=1;e=1))for(;c!==this;c=c.parentNode||this)if(1===c.nodeType&&("click"!==t.type||!0!==c.disabled)){for(o=[],a={},n=0;n-1:C.find(i,this,null,[c]).length),a[i]&&o.push(r);o.length&&s.push({elem:c,handlers:o})}return c=this,u\x20\t\r\n\f]*)[^>]*)\/>/gi,jt=/\s*$/g;function It(t,e){return O(t,"table")&&O(11!==e.nodeType?e:e.firstChild,"tr")&&C(t).children("tbody")[0]||t}function Lt(t){return t.type=(null!==t.getAttribute("type"))+"/"+t.type,t}function Rt(t){return"true/"===(t.type||"").slice(0,5)?t.type=t.type.slice(5):t.removeAttribute("type"),t}function Pt(t,e){var n,r,i,o,a,s,u,c;if(1===e.nodeType){if(Y.hasData(t)&&(o=Y.access(t),a=Y.set(e,o),c=o.events))for(i in delete a.handle,a.events={},c)for(n=0,r=c[i].length;n1&&"string"==typeof h&&!g.checkClone&&Nt.test(h))return t.each(function(i){var o=t.eq(i);v&&(e[0]=h.call(this,i,o.html())),Ft(o,e,n,r)});if(p&&(o=(i=wt(e,t[0].ownerDocument,!1,t,r)).firstChild,1===i.childNodes.length&&(i=o),o||r)){for(s=(a=C.map(mt(i,"script"),Lt)).length;f")},clone:function(t,e,n){var r,i,o,a,s,u,c,l=t.cloneNode(!0),f=C.contains(t.ownerDocument,t);if(!(g.noCloneChecked||1!==t.nodeType&&11!==t.nodeType||C.isXMLDoc(t)))for(a=mt(l),r=0,i=(o=mt(t)).length;r0&>(a,!f&&mt(t,"script")),l},cleanData:function(t){for(var e,n,r,i=C.event.special,o=0;void 0!==(n=t[o]);o++)if(G(n)){if(e=n[Y.expando]){if(e.events)for(r in e.events)i[r]?C.event.remove(n,r):C.removeEvent(n,r,e.handle);n[Y.expando]=void 0}n[Z.expando]&&(n[Z.expando]=void 0)}}}),C.fn.extend({detach:function(t){return Mt(this,t,!0)},remove:function(t){return Mt(this,t)},text:function(t){return z(this,function(t){return void 0===t?C.text(this):this.empty().each(function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=t)})},null,t,arguments.length)},append:function(){return Ft(this,arguments,function(t){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||It(this,t).appendChild(t)})},prepend:function(){return Ft(this,arguments,function(t){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var e=It(this,t);e.insertBefore(t,e.firstChild)}})},before:function(){return Ft(this,arguments,function(t){this.parentNode&&this.parentNode.insertBefore(t,this)})},after:function(){return Ft(this,arguments,function(t){this.parentNode&&this.parentNode.insertBefore(t,this.nextSibling)})},empty:function(){for(var t,e=0;null!=(t=this[e]);e++)1===t.nodeType&&(C.cleanData(mt(t,!1)),t.textContent="");return this},clone:function(t,e){return t=null!=t&&t,e=null==e?t:e,this.map(function(){return C.clone(this,t,e)})},html:function(t){return z(this,function(t){var e=this[0]||{},n=0,r=this.length;if(void 0===t&&1===e.nodeType)return e.innerHTML;if("string"==typeof t&&!jt.test(t)&&!vt[(dt.exec(t)||["",""])[1].toLowerCase()]){t=C.htmlPrefilter(t);try{for(;n=0&&(u+=Math.max(0,Math.ceil(t["offset"+e[0].toUpperCase()+e.slice(1)]-o-u-s-.5))),u}function te(t,e,n){var r=Bt(t),i=Ut(t,e,r),o="border-box"===C.css(t,"boxSizing",!1,r),a=o;if(qt.test(i)){if(!n)return i;i="auto"}return a=a&&(g.boxSizingReliable()||i===t.style[e]),("auto"===i||!parseFloat(i)&&"inline"===C.css(t,"display",!1,r))&&(i=t["offset"+e[0].toUpperCase()+e.slice(1)],a=!0),(i=parseFloat(i)||0)+Zt(t,e,n||(o?"border":"content"),a,r,i)+"px"}function ee(t,e,n,r,i){return new ee.prototype.init(t,e,n,r,i)}C.extend({cssHooks:{opacity:{get:function(t,e){if(e){var n=Ut(t,"opacity");return""===n?"1":n}}}},cssNumber:{animationIterationCount:!0,columnCount:!0,fillOpacity:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{},style:function(t,e,n,r){if(t&&3!==t.nodeType&&8!==t.nodeType&&t.style){var i,o,a,s=J(e),u=Vt.test(e),c=t.style;if(u||(e=Qt(s)),a=C.cssHooks[e]||C.cssHooks[s],void 0===n)return a&&"get"in a&&void 0!==(i=a.get(t,!1,r))?i:c[e];"string"===(o=typeof n)&&(i=it.exec(n))&&i[1]&&(n=ut(t,e,i),o="number"),null!=n&&n==n&&("number"===o&&(n+=i&&i[3]||(C.cssNumber[s]?"":"px")),g.clearCloneStyle||""!==n||0!==e.indexOf("background")||(c[e]="inherit"),a&&"set"in a&&void 0===(n=a.set(t,n,r))||(u?c.setProperty(e,n):c[e]=n))}},css:function(t,e,n,r){var i,o,a,s=J(e);return Vt.test(e)||(e=Qt(s)),(a=C.cssHooks[e]||C.cssHooks[s])&&"get"in a&&(i=a.get(t,!0,n)),void 0===i&&(i=Ut(t,e,r)),"normal"===i&&e in Kt&&(i=Kt[e]),""===n||n?(o=parseFloat(i),!0===n||isFinite(o)?o||0:i):i}}),C.each(["height","width"],function(t,e){C.cssHooks[e]={get:function(t,n,r){if(n)return!zt.test(C.css(t,"display"))||t.getClientRects().length&&t.getBoundingClientRect().width?te(t,e,r):st(t,Xt,function(){return te(t,e,r)})},set:function(t,n,r){var i,o=Bt(t),a="border-box"===C.css(t,"boxSizing",!1,o),s=r&&Zt(t,e,r,a,o);return a&&g.scrollboxSize()===o.position&&(s-=Math.ceil(t["offset"+e[0].toUpperCase()+e.slice(1)]-parseFloat(o[e])-Zt(t,e,"border",!1,o)-.5)),s&&(i=it.exec(n))&&"px"!==(i[3]||"px")&&(t.style[e]=n,n=C.css(t,e)),Yt(0,n,s)}}}),C.cssHooks.marginLeft=Wt(g.reliableMarginLeft,function(t,e){if(e)return(parseFloat(Ut(t,"marginLeft"))||t.getBoundingClientRect().left-st(t,{marginLeft:0},function(){return t.getBoundingClientRect().left}))+"px"}),C.each({margin:"",padding:"",border:"Width"},function(t,e){C.cssHooks[t+e]={expand:function(n){for(var r=0,i={},o="string"==typeof n?n.split(" "):[n];r<4;r++)i[t+ot[r]+e]=o[r]||o[r-2]||o[0];return i}},"margin"!==t&&(C.cssHooks[t+e].set=Yt)}),C.fn.extend({css:function(t,e){return z(this,function(t,e,n){var r,i,o={},a=0;if(Array.isArray(e)){for(r=Bt(t),i=e.length;a1)}}),C.Tween=ee,ee.prototype={constructor:ee,init:function(t,e,n,r,i,o){this.elem=t,this.prop=n,this.easing=i||C.easing._default,this.options=e,this.start=this.now=this.cur(),this.end=r,this.unit=o||(C.cssNumber[n]?"":"px")},cur:function(){var t=ee.propHooks[this.prop];return t&&t.get?t.get(this):ee.propHooks._default.get(this)},run:function(t){var e,n=ee.propHooks[this.prop];return this.options.duration?this.pos=e=C.easing[this.easing](t,this.options.duration*t,0,1,this.options.duration):this.pos=e=t,this.now=(this.end-this.start)*e+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),n&&n.set?n.set(this):ee.propHooks._default.set(this),this}},ee.prototype.init.prototype=ee.prototype,ee.propHooks={_default:{get:function(t){var e;return 1!==t.elem.nodeType||null!=t.elem[t.prop]&&null==t.elem.style[t.prop]?t.elem[t.prop]:(e=C.css(t.elem,t.prop,""))&&"auto"!==e?e:0},set:function(t){C.fx.step[t.prop]?C.fx.step[t.prop](t):1!==t.elem.nodeType||null==t.elem.style[C.cssProps[t.prop]]&&!C.cssHooks[t.prop]?t.elem[t.prop]=t.now:C.style(t.elem,t.prop,t.now+t.unit)}}},ee.propHooks.scrollTop=ee.propHooks.scrollLeft={set:function(t){t.elem.nodeType&&t.elem.parentNode&&(t.elem[t.prop]=t.now)}},C.easing={linear:function(t){return t},swing:function(t){return.5-Math.cos(t*Math.PI)/2},_default:"swing"},C.fx=ee.prototype.init,C.fx.step={};var ne,re,ie=/^(?:toggle|show|hide)$/,oe=/queueHooks$/;function ae(){re&&(!1===a.hidden&&n.requestAnimationFrame?n.requestAnimationFrame(ae):n.setTimeout(ae,C.fx.interval),C.fx.tick())}function se(){return n.setTimeout(function(){ne=void 0}),ne=Date.now()}function ue(t,e){var n,r=0,i={height:t};for(e=e?1:0;r<4;r+=2-e)i["margin"+(n=ot[r])]=i["padding"+n]=t;return e&&(i.opacity=i.width=t),i}function ce(t,e,n){for(var r,i=(le.tweeners[e]||[]).concat(le.tweeners["*"]),o=0,a=i.length;o1)},removeAttr:function(t){return this.each(function(){C.removeAttr(this,t)})}}),C.extend({attr:function(t,e,n){var r,i,o=t.nodeType;if(3!==o&&8!==o&&2!==o)return void 0===t.getAttribute?C.prop(t,e,n):(1===o&&C.isXMLDoc(t)||(i=C.attrHooks[e.toLowerCase()]||(C.expr.match.bool.test(e)?fe:void 0)),void 0!==n?null===n?void C.removeAttr(t,e):i&&"set"in i&&void 0!==(r=i.set(t,n,e))?r:(t.setAttribute(e,n+""),n):i&&"get"in i&&null!==(r=i.get(t,e))?r:null==(r=C.find.attr(t,e))?void 0:r)},attrHooks:{type:{set:function(t,e){if(!g.radioValue&&"radio"===e&&O(t,"input")){var n=t.value;return t.setAttribute("type",e),n&&(t.value=n),e}}}},removeAttr:function(t,e){var n,r=0,i=e&&e.match(F);if(i&&1===t.nodeType)for(;n=i[r++];)t.removeAttribute(n)}}),fe={set:function(t,e,n){return!1===e?C.removeAttr(t,n):t.setAttribute(n,n),n}},C.each(C.expr.match.bool.source.match(/\w+/g),function(t,e){var n=pe[e]||C.find.attr;pe[e]=function(t,e,r){var i,o,a=e.toLowerCase();return r||(o=pe[a],pe[a]=i,i=null!=n(t,e,r)?a:null,pe[a]=o),i}});var de=/^(?:input|select|textarea|button)$/i,he=/^(?:a|area)$/i;function ve(t){return(t.match(F)||[]).join(" ")}function me(t){return t.getAttribute&&t.getAttribute("class")||""}function ge(t){return Array.isArray(t)?t:"string"==typeof t&&t.match(F)||[]}C.fn.extend({prop:function(t,e){return z(this,C.prop,t,e,arguments.length>1)},removeProp:function(t){return this.each(function(){delete this[C.propFix[t]||t]})}}),C.extend({prop:function(t,e,n){var r,i,o=t.nodeType;if(3!==o&&8!==o&&2!==o)return 1===o&&C.isXMLDoc(t)||(e=C.propFix[e]||e,i=C.propHooks[e]),void 0!==n?i&&"set"in i&&void 0!==(r=i.set(t,n,e))?r:t[e]=n:i&&"get"in i&&null!==(r=i.get(t,e))?r:t[e]},propHooks:{tabIndex:{get:function(t){var e=C.find.attr(t,"tabindex");return e?parseInt(e,10):de.test(t.nodeName)||he.test(t.nodeName)&&t.href?0:-1}}},propFix:{for:"htmlFor",class:"className"}}),g.optSelected||(C.propHooks.selected={get:function(t){var e=t.parentNode;return e&&e.parentNode&&e.parentNode.selectedIndex,null},set:function(t){var e=t.parentNode;e&&(e.selectedIndex,e.parentNode&&e.parentNode.selectedIndex)}}),C.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){C.propFix[this.toLowerCase()]=this}),C.fn.extend({addClass:function(t){var e,n,r,i,o,a,s,u=0;if(y(t))return this.each(function(e){C(this).addClass(t.call(this,e,me(this)))});if((e=ge(t)).length)for(;n=this[u++];)if(i=me(n),r=1===n.nodeType&&" "+ve(i)+" "){for(a=0;o=e[a++];)r.indexOf(" "+o+" ")<0&&(r+=o+" ");i!==(s=ve(r))&&n.setAttribute("class",s)}return this},removeClass:function(t){var e,n,r,i,o,a,s,u=0;if(y(t))return this.each(function(e){C(this).removeClass(t.call(this,e,me(this)))});if(!arguments.length)return this.attr("class","");if((e=ge(t)).length)for(;n=this[u++];)if(i=me(n),r=1===n.nodeType&&" "+ve(i)+" "){for(a=0;o=e[a++];)for(;r.indexOf(" "+o+" ")>-1;)r=r.replace(" "+o+" "," ");i!==(s=ve(r))&&n.setAttribute("class",s)}return this},toggleClass:function(t,e){var n=typeof t,r="string"===n||Array.isArray(t);return"boolean"==typeof e&&r?e?this.addClass(t):this.removeClass(t):y(t)?this.each(function(n){C(this).toggleClass(t.call(this,n,me(this),e),e)}):this.each(function(){var e,i,o,a;if(r)for(i=0,o=C(this),a=ge(t);e=a[i++];)o.hasClass(e)?o.removeClass(e):o.addClass(e);else void 0!==t&&"boolean"!==n||((e=me(this))&&Y.set(this,"__className__",e),this.setAttribute&&this.setAttribute("class",e||!1===t?"":Y.get(this,"__className__")||""))})},hasClass:function(t){var e,n,r=0;for(e=" "+t+" ";n=this[r++];)if(1===n.nodeType&&(" "+ve(me(n))+" ").indexOf(e)>-1)return!0;return!1}});var ye=/\r/g;C.fn.extend({val:function(t){var e,n,r,i=this[0];return arguments.length?(r=y(t),this.each(function(n){var i;1===this.nodeType&&(null==(i=r?t.call(this,n,C(this).val()):t)?i="":"number"==typeof i?i+="":Array.isArray(i)&&(i=C.map(i,function(t){return null==t?"":t+""})),(e=C.valHooks[this.type]||C.valHooks[this.nodeName.toLowerCase()])&&"set"in e&&void 0!==e.set(this,i,"value")||(this.value=i))})):i?(e=C.valHooks[i.type]||C.valHooks[i.nodeName.toLowerCase()])&&"get"in e&&void 0!==(n=e.get(i,"value"))?n:"string"==typeof(n=i.value)?n.replace(ye,""):null==n?"":n:void 0}}),C.extend({valHooks:{option:{get:function(t){var e=C.find.attr(t,"value");return null!=e?e:ve(C.text(t))}},select:{get:function(t){var e,n,r,i=t.options,o=t.selectedIndex,a="select-one"===t.type,s=a?null:[],u=a?o+1:i.length;for(r=o<0?u:a?o:0;r-1)&&(n=!0);return n||(t.selectedIndex=-1),o}}}}),C.each(["radio","checkbox"],function(){C.valHooks[this]={set:function(t,e){if(Array.isArray(e))return t.checked=C.inArray(C(t).val(),e)>-1}},g.checkOn||(C.valHooks[this].get=function(t){return null===t.getAttribute("value")?"on":t.value})}),g.focusin="onfocusin"in n;var be=/^(?:focusinfocus|focusoutblur)$/,_e=function(t){t.stopPropagation()};C.extend(C.event,{trigger:function(t,e,r,i){var o,s,u,c,l,f,p,d,v=[r||a],m=h.call(t,"type")?t.type:t,g=h.call(t,"namespace")?t.namespace.split("."):[];if(s=d=u=r=r||a,3!==r.nodeType&&8!==r.nodeType&&!be.test(m+C.event.triggered)&&(m.indexOf(".")>-1&&(m=(g=m.split(".")).shift(),g.sort()),l=m.indexOf(":")<0&&"on"+m,(t=t[C.expando]?t:new C.Event(m,"object"==typeof t&&t)).isTrigger=i?2:3,t.namespace=g.join("."),t.rnamespace=t.namespace?new RegExp("(^|\\.)"+g.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,t.result=void 0,t.target||(t.target=r),e=null==e?[t]:C.makeArray(e,[t]),p=C.event.special[m]||{},i||!p.trigger||!1!==p.trigger.apply(r,e))){if(!i&&!p.noBubble&&!b(r)){for(c=p.delegateType||m,be.test(c+m)||(s=s.parentNode);s;s=s.parentNode)v.push(s),u=s;u===(r.ownerDocument||a)&&v.push(u.defaultView||u.parentWindow||n)}for(o=0;(s=v[o++])&&!t.isPropagationStopped();)d=s,t.type=o>1?c:p.bindType||m,(f=(Y.get(s,"events")||{})[t.type]&&Y.get(s,"handle"))&&f.apply(s,e),(f=l&&s[l])&&f.apply&&G(s)&&(t.result=f.apply(s,e),!1===t.result&&t.preventDefault());return t.type=m,i||t.isDefaultPrevented()||p._default&&!1!==p._default.apply(v.pop(),e)||!G(r)||l&&y(r[m])&&!b(r)&&((u=r[l])&&(r[l]=null),C.event.triggered=m,t.isPropagationStopped()&&d.addEventListener(m,_e),r[m](),t.isPropagationStopped()&&d.removeEventListener(m,_e),C.event.triggered=void 0,u&&(r[l]=u)),t.result}},simulate:function(t,e,n){var r=C.extend(new C.Event,n,{type:t,isSimulated:!0});C.event.trigger(r,null,e)}}),C.fn.extend({trigger:function(t,e){return this.each(function(){C.event.trigger(t,e,this)})},triggerHandler:function(t,e){var n=this[0];if(n)return C.event.trigger(t,e,n,!0)}}),g.focusin||C.each({focus:"focusin",blur:"focusout"},function(t,e){var n=function(t){C.event.simulate(e,t.target,C.event.fix(t))};C.event.special[e]={setup:function(){var r=this.ownerDocument||this,i=Y.access(r,e);i||r.addEventListener(t,n,!0),Y.access(r,e,(i||0)+1)},teardown:function(){var r=this.ownerDocument||this,i=Y.access(r,e)-1;i?Y.access(r,e,i):(r.removeEventListener(t,n,!0),Y.remove(r,e))}}});var we=n.location,xe=Date.now(),Ce=/\?/;C.parseXML=function(t){var e;if(!t||"string"!=typeof t)return null;try{e=(new n.DOMParser).parseFromString(t,"text/xml")}catch(t){e=void 0}return e&&!e.getElementsByTagName("parsererror").length||C.error("Invalid XML: "+t),e};var Te=/\[\]$/,ke=/\r?\n/g,$e=/^(?:submit|button|image|reset|file)$/i,Ae=/^(?:input|select|textarea|keygen)/i;function Se(t,e,n,r){var i;if(Array.isArray(e))C.each(e,function(e,i){n||Te.test(t)?r(t,i):Se(t+"["+("object"==typeof i&&null!=i?e:"")+"]",i,n,r)});else if(n||"object"!==x(e))r(t,e);else for(i in e)Se(t+"["+i+"]",e[i],n,r)}C.param=function(t,e){var n,r=[],i=function(t,e){var n=y(e)?e():e;r[r.length]=encodeURIComponent(t)+"="+encodeURIComponent(null==n?"":n)};if(Array.isArray(t)||t.jquery&&!C.isPlainObject(t))C.each(t,function(){i(this.name,this.value)});else for(n in t)Se(n,t[n],e,i);return r.join("&")},C.fn.extend({serialize:function(){return C.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var t=C.prop(this,"elements");return t?C.makeArray(t):this}).filter(function(){var t=this.type;return this.name&&!C(this).is(":disabled")&&Ae.test(this.nodeName)&&!$e.test(t)&&(this.checked||!pt.test(t))}).map(function(t,e){var n=C(this).val();return null==n?null:Array.isArray(n)?C.map(n,function(t){return{name:e.name,value:t.replace(ke,"\r\n")}}):{name:e.name,value:n.replace(ke,"\r\n")}}).get()}});var Ee=/%20/g,Oe=/#.*$/,je=/([?&])_=[^&]*/,Ne=/^(.*?):[ \t]*([^\r\n]*)$/gm,De=/^(?:GET|HEAD)$/,Ie=/^\/\//,Le={},Re={},Pe="*/".concat("*"),Fe=a.createElement("a");function Me(t){return function(e,n){"string"!=typeof e&&(n=e,e="*");var r,i=0,o=e.toLowerCase().match(F)||[];if(y(n))for(;r=o[i++];)"+"===r[0]?(r=r.slice(1)||"*",(t[r]=t[r]||[]).unshift(n)):(t[r]=t[r]||[]).push(n)}}function qe(t,e,n,r){var i={},o=t===Re;function a(s){var u;return i[s]=!0,C.each(t[s]||[],function(t,s){var c=s(e,n,r);return"string"!=typeof c||o||i[c]?o?!(u=c):void 0:(e.dataTypes.unshift(c),a(c),!1)}),u}return a(e.dataTypes[0])||!i["*"]&&a("*")}function Be(t,e){var n,r,i=C.ajaxSettings.flatOptions||{};for(n in e)void 0!==e[n]&&((i[n]?t:r||(r={}))[n]=e[n]);return r&&C.extend(!0,t,r),t}Fe.href=we.href,C.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:we.href,type:"GET",isLocal:/^(?:about|app|app-storage|.+-extension|file|res|widget):$/.test(we.protocol),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":Pe,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":JSON.parse,"text xml":C.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(t,e){return e?Be(Be(t,C.ajaxSettings),e):Be(C.ajaxSettings,t)},ajaxPrefilter:Me(Le),ajaxTransport:Me(Re),ajax:function(t,e){"object"==typeof t&&(e=t,t=void 0),e=e||{};var r,i,o,s,u,c,l,f,p,d,h=C.ajaxSetup({},e),v=h.context||h,m=h.context&&(v.nodeType||v.jquery)?C(v):C.event,g=C.Deferred(),y=C.Callbacks("once memory"),b=h.statusCode||{},_={},w={},x="canceled",T={readyState:0,getResponseHeader:function(t){var e;if(l){if(!s)for(s={};e=Ne.exec(o);)s[e[1].toLowerCase()]=e[2];e=s[t.toLowerCase()]}return null==e?null:e},getAllResponseHeaders:function(){return l?o:null},setRequestHeader:function(t,e){return null==l&&(t=w[t.toLowerCase()]=w[t.toLowerCase()]||t,_[t]=e),this},overrideMimeType:function(t){return null==l&&(h.mimeType=t),this},statusCode:function(t){var e;if(t)if(l)T.always(t[T.status]);else for(e in t)b[e]=[b[e],t[e]];return this},abort:function(t){var e=t||x;return r&&r.abort(e),k(0,e),this}};if(g.promise(T),h.url=((t||h.url||we.href)+"").replace(Ie,we.protocol+"//"),h.type=e.method||e.type||h.method||h.type,h.dataTypes=(h.dataType||"*").toLowerCase().match(F)||[""],null==h.crossDomain){c=a.createElement("a");try{c.href=h.url,c.href=c.href,h.crossDomain=Fe.protocol+"//"+Fe.host!=c.protocol+"//"+c.host}catch(t){h.crossDomain=!0}}if(h.data&&h.processData&&"string"!=typeof h.data&&(h.data=C.param(h.data,h.traditional)),qe(Le,h,e,T),l)return T;for(p in(f=C.event&&h.global)&&0==C.active++&&C.event.trigger("ajaxStart"),h.type=h.type.toUpperCase(),h.hasContent=!De.test(h.type),i=h.url.replace(Oe,""),h.hasContent?h.data&&h.processData&&0===(h.contentType||"").indexOf("application/x-www-form-urlencoded")&&(h.data=h.data.replace(Ee,"+")):(d=h.url.slice(i.length),h.data&&(h.processData||"string"==typeof h.data)&&(i+=(Ce.test(i)?"&":"?")+h.data,delete h.data),!1===h.cache&&(i=i.replace(je,"$1"),d=(Ce.test(i)?"&":"?")+"_="+xe+++d),h.url=i+d),h.ifModified&&(C.lastModified[i]&&T.setRequestHeader("If-Modified-Since",C.lastModified[i]),C.etag[i]&&T.setRequestHeader("If-None-Match",C.etag[i])),(h.data&&h.hasContent&&!1!==h.contentType||e.contentType)&&T.setRequestHeader("Content-Type",h.contentType),T.setRequestHeader("Accept",h.dataTypes[0]&&h.accepts[h.dataTypes[0]]?h.accepts[h.dataTypes[0]]+("*"!==h.dataTypes[0]?", "+Pe+"; q=0.01":""):h.accepts["*"]),h.headers)T.setRequestHeader(p,h.headers[p]);if(h.beforeSend&&(!1===h.beforeSend.call(v,T,h)||l))return T.abort();if(x="abort",y.add(h.complete),T.done(h.success),T.fail(h.error),r=qe(Re,h,e,T)){if(T.readyState=1,f&&m.trigger("ajaxSend",[T,h]),l)return T;h.async&&h.timeout>0&&(u=n.setTimeout(function(){T.abort("timeout")},h.timeout));try{l=!1,r.send(_,k)}catch(t){if(l)throw t;k(-1,t)}}else k(-1,"No Transport");function k(t,e,a,s){var c,p,d,_,w,x=e;l||(l=!0,u&&n.clearTimeout(u),r=void 0,o=s||"",T.readyState=t>0?4:0,c=t>=200&&t<300||304===t,a&&(_=function(t,e,n){for(var r,i,o,a,s=t.contents,u=t.dataTypes;"*"===u[0];)u.shift(),void 0===r&&(r=t.mimeType||e.getResponseHeader("Content-Type"));if(r)for(i in s)if(s[i]&&s[i].test(r)){u.unshift(i);break}if(u[0]in n)o=u[0];else{for(i in n){if(!u[0]||t.converters[i+" "+u[0]]){o=i;break}a||(a=i)}o=o||a}if(o)return o!==u[0]&&u.unshift(o),n[o]}(h,T,a)),_=function(t,e,n,r){var i,o,a,s,u,c={},l=t.dataTypes.slice();if(l[1])for(a in t.converters)c[a.toLowerCase()]=t.converters[a];for(o=l.shift();o;)if(t.responseFields[o]&&(n[t.responseFields[o]]=e),!u&&r&&t.dataFilter&&(e=t.dataFilter(e,t.dataType)),u=o,o=l.shift())if("*"===o)o=u;else if("*"!==u&&u!==o){if(!(a=c[u+" "+o]||c["* "+o]))for(i in c)if((s=i.split(" "))[1]===o&&(a=c[u+" "+s[0]]||c["* "+s[0]])){!0===a?a=c[i]:!0!==c[i]&&(o=s[0],l.unshift(s[1]));break}if(!0!==a)if(a&&t.throws)e=a(e);else try{e=a(e)}catch(t){return{state:"parsererror",error:a?t:"No conversion from "+u+" to "+o}}}return{state:"success",data:e}}(h,_,T,c),c?(h.ifModified&&((w=T.getResponseHeader("Last-Modified"))&&(C.lastModified[i]=w),(w=T.getResponseHeader("etag"))&&(C.etag[i]=w)),204===t||"HEAD"===h.type?x="nocontent":304===t?x="notmodified":(x=_.state,p=_.data,c=!(d=_.error))):(d=x,!t&&x||(x="error",t<0&&(t=0))),T.status=t,T.statusText=(e||x)+"",c?g.resolveWith(v,[p,x,T]):g.rejectWith(v,[T,x,d]),T.statusCode(b),b=void 0,f&&m.trigger(c?"ajaxSuccess":"ajaxError",[T,h,c?p:d]),y.fireWith(v,[T,x]),f&&(m.trigger("ajaxComplete",[T,h]),--C.active||C.event.trigger("ajaxStop")))}return T},getJSON:function(t,e,n){return C.get(t,e,n,"json")},getScript:function(t,e){return C.get(t,void 0,e,"script")}}),C.each(["get","post"],function(t,e){C[e]=function(t,n,r,i){return y(n)&&(i=i||r,r=n,n=void 0),C.ajax(C.extend({url:t,type:e,dataType:i,data:n,success:r},C.isPlainObject(t)&&t))}}),C._evalUrl=function(t){return C.ajax({url:t,type:"GET",dataType:"script",cache:!0,async:!1,global:!1,throws:!0})},C.fn.extend({wrapAll:function(t){var e;return this[0]&&(y(t)&&(t=t.call(this[0])),e=C(t,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&e.insertBefore(this[0]),e.map(function(){for(var t=this;t.firstElementChild;)t=t.firstElementChild;return t}).append(this)),this},wrapInner:function(t){return y(t)?this.each(function(e){C(this).wrapInner(t.call(this,e))}):this.each(function(){var e=C(this),n=e.contents();n.length?n.wrapAll(t):e.append(t)})},wrap:function(t){var e=y(t);return this.each(function(n){C(this).wrapAll(e?t.call(this,n):t)})},unwrap:function(t){return this.parent(t).not("body").each(function(){C(this).replaceWith(this.childNodes)}),this}}),C.expr.pseudos.hidden=function(t){return!C.expr.pseudos.visible(t)},C.expr.pseudos.visible=function(t){return!!(t.offsetWidth||t.offsetHeight||t.getClientRects().length)},C.ajaxSettings.xhr=function(){try{return new n.XMLHttpRequest}catch(t){}};var He={0:200,1223:204},Ue=C.ajaxSettings.xhr();g.cors=!!Ue&&"withCredentials"in Ue,g.ajax=Ue=!!Ue,C.ajaxTransport(function(t){var e,r;if(g.cors||Ue&&!t.crossDomain)return{send:function(i,o){var a,s=t.xhr();if(s.open(t.type,t.url,t.async,t.username,t.password),t.xhrFields)for(a in t.xhrFields)s[a]=t.xhrFields[a];for(a in t.mimeType&&s.overrideMimeType&&s.overrideMimeType(t.mimeType),t.crossDomain||i["X-Requested-With"]||(i["X-Requested-With"]="XMLHttpRequest"),i)s.setRequestHeader(a,i[a]);e=function(t){return function(){e&&(e=r=s.onload=s.onerror=s.onabort=s.ontimeout=s.onreadystatechange=null,"abort"===t?s.abort():"error"===t?"number"!=typeof s.status?o(0,"error"):o(s.status,s.statusText):o(He[s.status]||s.status,s.statusText,"text"!==(s.responseType||"text")||"string"!=typeof s.responseText?{binary:s.response}:{text:s.responseText},s.getAllResponseHeaders()))}},s.onload=e(),r=s.onerror=s.ontimeout=e("error"),void 0!==s.onabort?s.onabort=r:s.onreadystatechange=function(){4===s.readyState&&n.setTimeout(function(){e&&r()})},e=e("abort");try{s.send(t.hasContent&&t.data||null)}catch(t){if(e)throw t}},abort:function(){e&&e()}}}),C.ajaxPrefilter(function(t){t.crossDomain&&(t.contents.script=!1)}),C.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/\b(?:java|ecma)script\b/},converters:{"text script":function(t){return C.globalEval(t),t}}}),C.ajaxPrefilter("script",function(t){void 0===t.cache&&(t.cache=!1),t.crossDomain&&(t.type="GET")}),C.ajaxTransport("script",function(t){var e,n;if(t.crossDomain)return{send:function(r,i){e=C(" diff --git a/resources/views/budgets/index.twig b/resources/views/budgets/index.twig index 97cba2e2e3..73e175b45a 100644 --- a/resources/views/budgets/index.twig +++ b/resources/views/budgets/index.twig @@ -9,7 +9,7 @@
-

{{ periodStart }} — {{ periodEnd }}

+

{{ start.formatLocalized(monthAndDayFormat) }} — {{ end.formatLocalized(monthAndDayFormat) }}

@@ -17,7 +17,7 @@ {{ 'budgeted'|_ }}: {{ budgeted|formatAmountPlain }}
- {{ trans('firefly.available_between',{start : periodStart, end: periodEnd }) }}: + {{ trans('firefly.available_between',{start: start.formatLocalized(monthAndDayFormat), end: end.formatLocalized(monthAndDayFormat) }) }}: {{ available|formatAmountPlain }} @@ -39,7 +39,7 @@
- {{ trans('firefly.spent_between', {start: periodStart, end: periodEnd}) }}: {{ spent|formatAmount }} + {{ trans('firefly.spent_between', {start: start.formatLocalized(monthAndDayFormat), end: end.formatLocalized(monthAndDayFormat)}) }}: {{ spent|formatAmount }}
- {% if budgets.count > 0 and inactive.count > 0 %} + {% if paginator.count > 0 and inactive.count > 0 %}

{{ 'createBudget'|_ }}

@@ -83,7 +83,7 @@ {% endif %}
- {% if budgets.count == 0 and inactive.count == 0 and page == 1 %} + {% if paginator.count == 0 and inactive.count == 0 and page == 1 %} {% include 'partials.empty' with {what: 'default', type: 'budgets',route: route('budgets.create')} %} {# make FF ignore demo for now. #} {% set shownDemo = true %} @@ -138,7 +138,7 @@ {{ 'createBudget'|_ }}
- {{ budgets.render|raw }} + {{ paginator.render|raw }}
@@ -152,7 +152,7 @@ - {% for budget in budgets %} + {% for budget in paginator %} @@ -202,7 +202,7 @@
{{ (repAmount + budgetInformation[budget.id]['spent'])|formatAmount }} {% if repAmount + budgetInformation[budget.id]['spent'] > 0 %} - ({{ ((repAmount + budgetInformation[budget.id]['spent']) / daysPassed)|formatAmount }}) + ({{ ((repAmount + budgetInformation[budget.id]['spent']) / activeDaysLeft)|formatAmount }}) {% endif %}
- {{ budgets.render|raw }} + {{ paginator.render|raw }}
- {% include 'list.journals' with {'journals': journals,'hideBudgets': true} %} + {% if periods.count > 0 %} + {% include 'list.transactions' %}

{{ 'show_all_no_filter'|_ }}

{% else %} + {% include 'list.transactions' with {showCategories:true, showBill:true} %}

{{ 'show_the_current_period_and_overview'|_ }} diff --git a/resources/views/budgets/show.twig b/resources/views/budgets/show.twig index 8fdc7a414e..e40e0ed0cc 100644 --- a/resources/views/budgets/show.twig +++ b/resources/views/budgets/show.twig @@ -89,7 +89,11 @@

{{ 'transactions'|_ }}

- {% include 'list.journals' with {hideBudgets:true, hideBills:true} %} + {% if budgetLimit %} + {% include 'list.transactions' %} + {% else %} + {% include 'list.transactions' with {showCategories: true} %} + {% endif %} {% if budgetLimit %}

diff --git a/resources/views/categories/no-category.twig b/resources/views/categories/no-category.twig index da8077c8b7..ca2dc081a8 100644 --- a/resources/views/categories/no-category.twig +++ b/resources/views/categories/no-category.twig @@ -1,7 +1,7 @@ {% extends "./layout/default" %} {% block breadcrumbs %} - {{ Breadcrumbs.render(Route.getCurrentRoute.getName, moment, start, end) }} + {{ Breadcrumbs.render(Route.getCurrentRoute.getName, start, end) }} {% endblock %} {% block content %} @@ -10,7 +10,7 @@ {% if periods.count > 0 %}

{% endif %} @@ -22,13 +22,15 @@

{{ subTitle }}

- {% include 'list.journals' with {'journals': journals, 'hideCategories':true} %} + {% if periods.count > 0 %} + {% include 'list.transactions' %}

- {{ 'show_all_no_filter'|_ }} + {{ 'show_all_no_filter'|_ }}

{% else %} + {% include 'list.transactions' with {showBudgets:true, showBill:true} %}

{{ 'show_the_current_period_and_overview'|_ }} diff --git a/resources/views/categories/show.twig b/resources/views/categories/show.twig index d3ccd0f5e6..09d6992601 100644 --- a/resources/views/categories/show.twig +++ b/resources/views/categories/show.twig @@ -1,12 +1,12 @@ {% extends "./layout/default" %} {% block breadcrumbs %} - {{ Breadcrumbs.render(Route.getCurrentRoute.getName, category, '', start, end) }} + {{ Breadcrumbs.render(Route.getCurrentRoute.getName, category, start, end) }} {% endblock %} {% block content %}

- {% if moment != 'all' %} + {% if Route.getCurrentRoute.getName == 'categories.show' %} {# both charts #}
@@ -33,7 +33,7 @@
{% endif %} - {% if moment == 'all' %} + {% if Route.getCurrentRoute.getName == 'categories.show.all' %} {# all chart #}
@@ -52,7 +52,7 @@ {% if periods.count > 0 %} {% endif %} @@ -65,15 +65,16 @@

{{ 'transactions'|_ }}

- {% include 'list.journals' with {hideCategories: true, hideBills: true} %} {% if periods.count > 0 %} + {% include 'list.transactions' %}

- + {{ 'show_all_no_filter'|_ }}

{% else %} + {% include 'list.transactions' with {showBudgets:true, showBill:true} %}

diff --git a/resources/views/debug.twig b/resources/views/debug.twig index 7d57662635..57c1408034 100644 --- a/resources/views/debug.twig +++ b/resources/views/debug.twig @@ -19,7 +19,7 @@ Debug information generated at {{ now }} for Firefly III version **{{ FF_VERSION | App environment | {{ appEnv }} | | App debug mode | {{ appDebug }} | | App cache driver | {{ cacheDriver }} | -| App logging | {{ appLogLevel }}, {{ appLog }} | +| App logging | {{ appLogLevel }}, {{ logChannel }} | | PHP version | {{ phpVersion }} | | Display errors | {{ displayErrors }} | | Session start | {{ session('start') }} | diff --git a/resources/views/errors/FireflyException.twig b/resources/views/errors/FireflyException.twig index 19bfb45cc9..ee0b221ce0 100644 --- a/resources/views/errors/FireflyException.twig +++ b/resources/views/errors/FireflyException.twig @@ -7,8 +7,19 @@ - + + {# CSS things #} + + {# libraries #} + + + + + {# the theme #} + + + {# Firefly III customisations #} {# favicons #} diff --git a/resources/views/form/amount-no-currency.twig b/resources/views/form/amount-no-currency.twig index 005be0f54a..a5b2d39ba9 100644 --- a/resources/views/form/amount-no-currency.twig +++ b/resources/views/form/amount-no-currency.twig @@ -2,6 +2,7 @@

diff --git a/resources/views/form/number.twig b/resources/views/form/number.twig index 6951b19328..54be537886 100644 --- a/resources/views/form/number.twig +++ b/resources/views/form/number.twig @@ -3,6 +3,7 @@
{{ Form.input('number', name, value, options) }} + {% include 'form/help' %} {% include 'form/feedback' %}
diff --git a/resources/views/form/percentage.twig b/resources/views/form/percentage.twig new file mode 100644 index 0000000000..b5e73b7b91 --- /dev/null +++ b/resources/views/form/percentage.twig @@ -0,0 +1,12 @@ +
+ + +
+
+ {{ Form.input('number', name, value, options) }} +
%
+
+ {% include 'form/help' %} + {% include 'form/feedback' %} +
+
diff --git a/resources/views/import/index.twig b/resources/views/import/index.twig index e1d542d513..f729647d09 100644 --- a/resources/views/import/index.twig +++ b/resources/views/import/index.twig @@ -18,11 +18,18 @@ {% for name, provider in providers %} {# button for each import thing: #}
+ {% endfor %}
diff --git a/resources/views/import/spectre/redirect.twig b/resources/views/import/spectre/redirect.twig index 72f9ca282b..84d4547cad 100644 --- a/resources/views/import/spectre/redirect.twig +++ b/resources/views/import/spectre/redirect.twig @@ -11,4 +11,4 @@ If you are not redirected automatically, follow this link to Spectre.. -#} + diff --git a/resources/views/import/ynab/accounts.twig b/resources/views/import/ynab/accounts.twig new file mode 100644 index 0000000000..d4e3b8a718 --- /dev/null +++ b/resources/views/import/ynab/accounts.twig @@ -0,0 +1,100 @@ +{% extends "./layout/default" %} + +{% block breadcrumbs %} + {{ Breadcrumbs.render }} +{% endblock %} +{% block content %} +
+
+ + +
+
+
+

{{ trans('import.job_config_ynab_apply_rules') }}

+
+
+
+
+

+ {{ trans('import.job_config_ynab_apply_rules_text') }} +

+ {{ ExpandedForm.checkbox('apply_rules', 1, true) }} +
+
+ +
+
+
+ +
+
+
+

{{ trans('import.job_config_ynab_accounts_title') }}

+
+
+
+
+

+ {{ trans('import.job_config_ynab_accounts_text', {count: data.accounts|length}) }} +

+
+
+
+
+ + + + + + + + + {% for account in data.ynab_accounts %} + + + + + + {% endfor %} + +
{{ trans('list.account_on_ynab') }}{{ trans('list.account') }}
+ {{ account.name }} ({{ trans('import.ynab_account_type_'~account.type) }}) + {% if account.closed %} +
{{ trans('import.ynab_account_closed') }} + {% endif %} + {% if account.deleted %} +
{{ trans('import.ynab_account_deleted') }} + {% endif %} +
+ +
+ + +
+
+ +
+
+ +
+{% endblock %} +{% block scripts %} +{% endblock %} +{% block styles %} +{% endblock %} diff --git a/resources/views/import/ynab/prerequisites.twig b/resources/views/import/ynab/prerequisites.twig new file mode 100644 index 0000000000..c7faab048a --- /dev/null +++ b/resources/views/import/ynab/prerequisites.twig @@ -0,0 +1,69 @@ +{% extends "./layout/default" %} + +{% block breadcrumbs %} + {{ Breadcrumbs.render }} +{% endblock %} +{% block content %} +
+
+ +
+
+
+

{{ trans('import.prereq_ynab_title') }}

+
+
+
+
+

+ {{ trans('import.prereq_ynab_text')|raw }} +

+ {% if not is_https %} +

+ {{ trans('import.callback_not_tls') }} +

+ {{ callback_uri }} +

+ {% endif %} + {% if is_https %} +

+ {{ trans('import.prereq_ynab_redirect')|raw }} +

+ {{ callback_uri }} +

+ {% endif %} +
+
+ +
+
+ {{ ExpandedForm.text('client_id', client_id) }} +
+
+
+
+ {{ ExpandedForm.text('client_secret', client_secret) }} +
+
+
+ +
+
+
+
+{% endblock %} +{% block scripts %} +{% endblock %} +{% block styles %} +{% endblock %} diff --git a/resources/views/import/ynab/redirect.twig b/resources/views/import/ynab/redirect.twig new file mode 100644 index 0000000000..56b0a159d4 --- /dev/null +++ b/resources/views/import/ynab/redirect.twig @@ -0,0 +1,14 @@ + + + + + + + Page Redirection + + +If you are not redirected automatically, follow this link to YNAB.. + + \ No newline at end of file diff --git a/resources/views/import/ynab/select-budgets.twig b/resources/views/import/ynab/select-budgets.twig new file mode 100644 index 0000000000..1c6c37b8fd --- /dev/null +++ b/resources/views/import/ynab/select-budgets.twig @@ -0,0 +1,58 @@ +{% extends "./layout/default" %} + +{% block breadcrumbs %} + {{ Breadcrumbs.render }} +{% endblock %} +{% block content %} +
+
+ + +
+
+
+

{{ trans('import.job_config_ynab_select_budgets') }}

+
+
+
+
+

+ {{ trans('import.job_config_ynab_select_budgets_text', {count: data.total}) }} +

+ {% if data.available|length == 0 %} +

+ {{ trans('import.job_config_ynab_no_budgets') }} +

+ {% else %} + {{ ExpandedForm.select('budget_id', data.available) }} + {% endif %} + + {% if data.not_available|length > 0 %} +

+ {{ trans('import.job_config_ynab_bad_currency') }} +

+
    + {% for budget in data.not_available %} +
  • {{ budget }}
  • + {% endfor %} +
+ {% endif %} +
+
+ +
+
+
+
+
+{% endblock %} +{% block scripts %} +{% endblock %} +{% block styles %} +{% endblock %} diff --git a/resources/views/javascript/variables.twig b/resources/views/javascript/variables.twig index 847068f7e4..4fec8ca474 100644 --- a/resources/views/javascript/variables.twig +++ b/resources/views/javascript/variables.twig @@ -44,3 +44,14 @@ var todayText = ' {{ trans('firefly.today')|escape('js') }}'; // some formatting stuff: var month_and_day_js = "{{ trans('config.month_and_day_js') }}"; var acc_config_new = {format: accountingConfig}; + +// strings and translations used often: +var helpPageTitle = "{{ trans('firefly.help_for_this_page')|escape('js') }}"; +var noHelpForPage = "{{ trans('firefly.no_help_could_be_found')|escape('js') }}"; +var noHelpForPageTitle = "{{ trans('firefly.no_help_title')|escape('js') }}"; + +// for demo: +var nextLabel = "{{ trans('firefly.intro_next_label')|escape('js') }}"; +var prevLabel = "{{ trans('firefly.intro_prev_label')|escape('js') }}"; +var skipLabel = "{{ trans('firefly.intro_skip_label')|escape('js') }}"; +var doneLabel = "{{ trans('firefly.intro_done_label')|escape('js') }}"; \ No newline at end of file diff --git a/resources/views/layout/default.twig b/resources/views/layout/default.twig index 2fc15a9541..70bf4aa925 100644 --- a/resources/views/layout/default.twig +++ b/resources/views/layout/default.twig @@ -18,23 +18,34 @@ - + {# CSS things #} + {# libraries #} + + + + + {# the theme #} - + + {# Firefly III customisations #} + + {# Extra CSS for the demo: #} {% if not shownDemo %} {% endif %} + {# Any local custom CSS. #} {% block styles %}{% endblock %} - + + {# this entry is in the header so it's loaded early #} + {# SHA256: C45493A8175B10AC47EEDFC7C20AC31FAE5C804FB6C4F75468DB0F95112664BF #} + {# favicons #} {% include('partials.favicons') %} @@ -54,7 +65,7 @@
- + +{# Java libraries and stuff: #} + +{# Moment JS #} + +{# All kinds of variables. #} + +{# big fat JS thing courtesy of Vue#} +{# date range picker, current template, etc.#} - + +{# Firefly III code#} - {% if not shownDemo %}