diff --git a/.dockerignore b/.dockerignore index cc02d7439..4c1f0b007 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,6 +12,7 @@ postgresql elasticsearch redis + # Ignore public assets public/uploads public/assets diff --git a/.rubocop.yml b/.rubocop.yml index 9d0978cf1..364b0382f 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,9 +1,9 @@ Metrics/LineLength: Max: 140 Metrics/MethodLength: - Max: 30 + Max: 35 Metrics/CyclomaticComplexity: - Max: 9 + Max: 13 Metrics/PerceivedComplexity: Max: 9 Metrics/AbcSize: @@ -16,6 +16,8 @@ Metrics/BlockLength: - 'lib/tasks/**/*.rake' - 'config/routes.rb' - 'app/pdfs/pdf/*.rb' +Metrics/ParameterLists: + CountKeywordArgs: false Style/BracesAroundHashParameters: EnforcedStyle: context_dependent Style/RegexpLiteral: diff --git a/CHANGELOG.md b/CHANGELOG.md index 50b1613c6..f53655604 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,18 @@ # Changelog Fab Manager +- Ability to configure and export the accounting data to the ACD accounting software +- Compute the VAT per item in each invoices, instead of globally +- Use Alpine Linux to build the Docker image (#147) +- Fix a bug: invoices with total = 0, are marked as paid on site even if paid by card +- Fix a bug: after disabling a group, its associated plans are hidden from the interface +- Fix a bug: in case of unexpected server error during stripe payment process, the confirm button is not unlocked +- [TODO DEPLOY] `rake db:migrate` + ## v4.1.1 2019 september 20 -- fix a bug: api/reservations#index was using user_id instead of statistic_profile_id -- fix a bug: event_service#date_range method, test on all_day was never truthy -- fix a bug: sidekiq 5 does not have delay_for method anymore, uses perform_in instead +- Fix a bug: api/reservations#index was using user_id instead of statistic_profile_id +- Fix a bug: event_service#date_range method, test on all_day was never truthy +- Fix a bug: sidekiq 5 does not have delay_for method anymore, uses perform_in instead ## v4.1.0 2019 September 12 diff --git a/Procfile b/Procfile index f8a9f7b3c..ffe497c23 100644 --- a/Procfile +++ b/Procfile @@ -1,3 +1,3 @@ web: bundle exec rails server puma -p $PORT -b0.0.0.0 worker: bundle exec sidekiq -C ./config/sidekiq.yml -mail: bundle exec mailcatcher --foreground +mail: bundle exec mailcatcher --foreground --http-ip=0.0.0.0 diff --git a/app/assets/javascripts/controllers/admin/invoices.js.erb b/app/assets/javascripts/controllers/admin/invoices.js.erb index 3115f50f2..8f62f7081 100644 --- a/app/assets/javascripts/controllers/admin/invoices.js.erb +++ b/app/assets/javascripts/controllers/admin/invoices.js.erb @@ -76,6 +76,94 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I } }; + // Accounting codes + $scope.settings = { + journalCode: { + name: 'accounting_journal_code', + value: settings['accounting_journal_code'] + }, + cardClientCode: { + name: 'accounting_card_client_code', + value: settings['accounting_card_client_code'] + }, + cardClientLabel: { + name: 'accounting_card_client_label', + value: settings['accounting_card_client_label'] + }, + walletClientCode: { + name: 'accounting_wallet_client_code', + value: settings['accounting_wallet_client_code'] + }, + walletClientLabel: { + name: 'accounting_wallet_client_label', + value: settings['accounting_wallet_client_label'] + }, + otherClientCode: { + name: 'accounting_other_client_code', + value: settings['accounting_other_client_code'] + }, + otherClientLabel: { + name: 'accounting_other_client_label', + value: settings['accounting_other_client_label'] + }, + walletCode: { + name: 'accounting_wallet_code', + value: settings['accounting_wallet_code'] + }, + walletLabel: { + name: 'accounting_wallet_label', + value: settings['accounting_wallet_label'] + }, + vatCode: { + name: 'accounting_VAT_code', + value: settings['accounting_VAT_code'] + }, + vatLabel: { + name: 'accounting_VAT_label', + value: settings['accounting_VAT_label'] + }, + subscriptionCode: { + name: 'accounting_subscription_code', + value: settings['accounting_subscription_code'] + }, + subscriptionLabel: { + name: 'accounting_subscription_label', + value: settings['accounting_subscription_label'] + }, + machineCode: { + name: 'accounting_Machine_code', + value: settings['accounting_Machine_code'] + }, + machineLabel: { + name: 'accounting_Machine_label', + value: settings['accounting_Machine_label'] + }, + trainingCode: { + name: 'accounting_Training_code', + value: settings['accounting_Training_code'] + }, + trainingLabel: { + name: 'accounting_Training_label', + value: settings['accounting_Training_label'] + }, + eventCode: { + name: 'accounting_Event_code', + value: settings['accounting_Event_code'] + }, + eventLabel: { + name: 'accounting_Event_label', + value: settings['accounting_Event_label'] + }, + spaceCode: { + name: 'accounting_Space_code', + value: settings['accounting_Space_code'] + }, + spaceLabel: { + name: 'accounting_Space_label', + value: settings['accounting_Space_label'] + } + }; + // Placeholding date for the invoice creation $scope.today = moment(); @@ -325,9 +413,7 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I $scope.history.push({ date: rate.created_at, rate: rate.value, user: rate.user }) }); activeHistory.setting.history.forEach(function (v) { - if (v.value === 'false') { - $scope.history.push({ date: v.created_at, rate: 0, user: v.user }) - } + $scope.history.push({ date: v.created_at, enabled: v.value === 'true', user: v.user }) }); } @@ -432,6 +518,14 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I }); } + $scope.toggleExportModal = function() { + $uibModal.open({ + templateUrl: '<%= asset_path "admin/invoices/accountingExportModal.html" %>', + controller: 'AccountingExportModalController', + size: 'xl' + }); + } + /** * Test if the given date is within a closed accounting period * @param date {Date} date to test @@ -446,6 +540,20 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I return false; } + /** + * Callback to bulk save all settings in the page to the database with their values + */ + $scope.save = function() { + Setting.bulkUpdate( + { settings: Object.values($scope.settings) }, + function () { growl.success(_t('invoices.codes_customization_success')); }, + function (error) { + growl.error('unexpected_error_occurred'); + console.error(error); + } + ); + } + /* PRIVATE SCOPE */ /** @@ -791,7 +899,7 @@ Application.Controllers.controller('ClosePeriodModalController', ['$scope', '$ui }; /** - * Cancel the refund, dismiss the modal window + * Just dismiss the modal window */ $scope.cancel = function () { $uibModalInstance.dismiss('cancel'); }; @@ -803,3 +911,136 @@ Application.Controllers.controller('ClosePeriodModalController', ['$scope', '$ui } } ]); + +Application.Controllers.controller('AccountingExportModalController', ['$scope', '$uibModalInstance', 'Invoice', 'Export', 'CSRF', 'growl', '_t', + function ($scope, $uibModalInstance, Invoice, Export, CSRF, growl, _t) { + // Retrieve Anti-CSRF tokens from cookies + CSRF.setMetaTags(); + + const SETTINGS = { + acd: { + format: 'csv', + encoding: 'ISO-8859-1', + separator: ';', + dateFormat: '%d/%m/%Y', + labelMaxLength: 50, + decimalSeparator: ',', + exportInvoicesAtZero: false, + columns: ['journal_code', 'date', 'account_code', 'account_label', 'piece', 'line_label', 'debit_origin', 'credit_origin', 'debit_euro', 'credit_euro', 'lettering'] + } + }; + + /* PUBLIC SCOPE */ + + // API URL where the form will be posted + $scope.actionUrl = '/api/accounting/export'; + + // Form action on the above URL + $scope.method = 'post'; + + // Anti-CSRF token to inject into the download form + $scope.csrfToken = angular.element('meta[name="csrf-token"]')[0].content; + + // API request body to generate the export + $scope.query = null; + + // binding to radio button "export to" + $scope.exportTarget = { + software: null, + startDate: null, + endDate: null, + settings: null + }; + + // AngularUI-Bootstrap datepicker parameters to define export dates range + $scope.datePicker = { + format: Fablab.uibDateFormat, + opened: { // default: datePickers are not shown + start: false, + end: false + }, + options: { + startingDay: Fablab.weekStartingDay + } + }; + + // Date of the first invoice + $scope.firstInvoice = null; + + /** + * Validate the export + */ + $scope.ok = function () { + const statusQry = mkQuery(); + $scope.query = statusQry; + + Export.status(statusQry).then(function (res) { + if (!res.data.exists) { + growl.success(_t('invoices.export_is_running')); + } + $uibModalInstance.close(res); + }); + }; + + /** + * Callback to open/close one of the datepickers + * @param event {Object} see https://docs.angularjs.org/guide/expression#-event- + * @param picker {string} start | end + */ + $scope.toggleDatePicker = function(event, picker) { + event.preventDefault(); + $scope.datePicker.opened[picker] = !$scope.datePicker.opened[picker]; + }; + + /** + * Will fill the export settings, according to the selected software + */ + $scope.fillSettings = function() { + $scope.exportTarget.settings = SETTINGS[$scope.exportTarget.software]; + }; + + /** + * Just dismiss the modal window + */ + $scope.cancel = function () { $uibModalInstance.dismiss('cancel'); }; + + /* PRIVATE SCOPE */ + + /** + * Kind of constructor: these actions will be realized first when the controller is loaded + */ + const initialize = function () { + // if the invoice was payed with stripe, allow to refund through stripe + Invoice.first(function (data) { + $scope.firstInvoice = data.date; + $scope.exportTarget.startDate = data.date; + $scope.exportTarget.endDate = moment().toISOString(); + }); + }; + + /** + * Prepare the query for the export API + * @returns {{extension: *, query: *, category: string, type: *, key: *}} + */ + const mkQuery = function() { + return { + category: 'accounting', + type: $scope.exportTarget.software, + extension: $scope.exportTarget.settings.format, + key: $scope.exportTarget.settings.separator, + query: JSON.stringify({ + columns: $scope.exportTarget.settings.columns, + encoding: $scope.exportTarget.settings.encoding, + date_format: $scope.exportTarget.settings.dateFormat, + start_date: $scope.exportTarget.startDate, + end_date: $scope.exportTarget.endDate, + label_max_length: $scope.exportTarget.settings.labelMaxLength, + decimal_separator: $scope.exportTarget.settings.decimalSeparator, + export_invoices_at_zero: $scope.exportTarget.settings.exportInvoicesAtZero + }) + }; + } + + // !!! MUST BE CALLED AT THE END of the controller + return initialize(); +}]); diff --git a/app/assets/javascripts/directives/stripe-form.js.erb b/app/assets/javascripts/directives/stripe-form.js.erb index e61416f2b..15835c91c 100644 --- a/app/assets/javascripts/directives/stripe-form.js.erb +++ b/app/assets/javascripts/directives/stripe-form.js.erb @@ -63,7 +63,7 @@ Application.Directives.directive('stripeForm', ['Payment', 'growl', '_t', Payment.confirm({ payment_method_id: paymentMethod.id, cart_items: $scope.cartItems }, function (response) { // Handle server response (see Step 3) handleServerResponse(response, button); - }, function(error) { handleServerResponse({ error }) }); + }, function(error) { handleServerResponse({ error }, button) }); } }); }); @@ -89,7 +89,7 @@ Application.Directives.directive('stripeForm', ['Payment', 'growl', '_t', // The PaymentIntent can be confirmed again on the server Payment.confirm({ payment_intent_id: result.paymentIntent.id, cart_items: $scope.cartItems }, function(confirmResult) { handleServerResponse(confirmResult, confirmButton); - }, function(error) { handleServerResponse({ error }) }); + }, function(error) { handleServerResponse({ error }, confirmButton) }); } }); } else { diff --git a/app/assets/javascripts/router.js.erb b/app/assets/javascripts/router.js.erb index 3616fac07..0c40c4d7f 100644 --- a/app/assets/javascripts/router.js.erb +++ b/app/assets/javascripts/router.js.erb @@ -899,15 +899,13 @@ angular.module('application.router', ['ui.router']) resolve: { settings: ['Setting', function (Setting) { return Setting.query({ - names: `['invoice_legals', \ - 'invoice_text', \ - 'invoice_VAT-rate', \ - 'invoice_VAT-active', \ - 'invoice_order-nb', \ - 'invoice_code-value', \ - 'invoice_code-active', \ - 'invoice_reference', \ - 'invoice_logo']` }).$promise; + names: `['invoice_legals', 'invoice_text', 'invoice_VAT-rate', 'invoice_VAT-active', 'invoice_order-nb', 'invoice_code-value', \ + 'invoice_code-active', 'invoice_reference', 'invoice_logo', 'accounting_journal_code', 'accounting_card_client_code', \ + 'accounting_card_client_label', 'accounting_wallet_client_code', 'accounting_wallet_client_label', \ + 'accounting_other_client_code', 'accounting_other_client_label', 'accounting_wallet_code', 'accounting_wallet_label', \ + 'accounting_VAT_code', 'accounting_VAT_label', 'accounting_subscription_code', 'accounting_subscription_label', \ + 'accounting_Machine_code', 'accounting_Machine_label', 'accounting_Training_code', 'accounting_Training_label', \ + 'accounting_Event_code', 'accounting_Event_label', 'accounting_Space_code', 'accounting_Space_label']` }).$promise; }], invoices: [ 'Invoice', function (Invoice) { return Invoice.list({ diff --git a/app/assets/javascripts/services/invoice.js b/app/assets/javascripts/services/invoice.js index d2cc57795..ab38a0e6a 100644 --- a/app/assets/javascripts/services/invoice.js +++ b/app/assets/javascripts/services/invoice.js @@ -10,6 +10,10 @@ Application.Services.factory('Invoice', ['$resource', function ($resource) { url: '/api/invoices/list', method: 'POST', isArray: true + }, + first: { + url: '/api/invoices/first', + method: 'GET' } } ); diff --git a/app/assets/javascripts/services/setting.js b/app/assets/javascripts/services/setting.js index 9306435d3..c0b43b645 100644 --- a/app/assets/javascripts/services/setting.js +++ b/app/assets/javascripts/services/setting.js @@ -9,6 +9,10 @@ Application.Services.factory('Setting', ['$resource', function ($resource) { return angular.toJson({ setting: data }); } }, + bulkUpdate: { + url: '/api/settings/bulk_update', + method: 'PATCH' + }, query: { isArray: false } diff --git a/app/assets/stylesheets/app.components.scss b/app/assets/stylesheets/app.components.scss index d58a57afb..f477fd3dc 100644 --- a/app/assets/stylesheets/app.components.scss +++ b/app/assets/stylesheets/app.components.scss @@ -65,6 +65,10 @@ height: 100%; } +.modal-xl { + width: 900px; +} + // component card .card { position: relative; diff --git a/app/assets/stylesheets/modules/invoice.scss b/app/assets/stylesheets/modules/invoice.scss index d4904f49c..fb524d20e 100644 --- a/app/assets/stylesheets/modules/invoice.scss +++ b/app/assets/stylesheets/modules/invoice.scss @@ -276,3 +276,31 @@ table.scrollable-3-cols { input.form-control.as-writable { background-color: white; } + +.accounting-codes .row { + margin-top: 2rem; + + button { + margin-top: 1em; + } +} + +table.export-table-template { + margin-top: 10px; + + thead td { + width: 20px; + background-color: #227447; + color: white; + border-bottom: 2px solid black; + font-size: 13px; + font-weight: bold; + padding: 10px 5px; + line-height: 12px; + } + + tbody td { + border-right: 1px solid #d4d4d4; + height: 30px; + } +} diff --git a/app/assets/templates/admin/invoices/accountingExportModal.html b/app/assets/templates/admin/invoices/accountingExportModal.html new file mode 100644 index 000000000..6d6b580fe --- /dev/null +++ b/app/assets/templates/admin/invoices/accountingExportModal.html @@ -0,0 +1,97 @@ +