From af3def0e2e22a60a37c4deecda73543d6054c5a8 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Fri, 18 Nov 2022 16:42:11 +0100 Subject: [PATCH] (feat) save the accounting data in DB Previously, the accounting data were built on demand, extracting the data from the invoices on-the-fly. This is intended to be used only once in a while, so there was no performance issue with that. Now, we want those data to be accessed from the OpenAPI, so building them on-the-fly would be very much intensive and resouces heavy. So we build them each nights using a scheduled worker and save them in the database --- .../api/profile_custom_fields_controller.rb | 3 +- .../javascript/api/profile-custom-field.ts | 7 +- .../components/user/user-profile-form.tsx | 7 +- .../javascript/models/profile-custom-field.ts | 6 + app/models/accounting_line.rb | 8 + app/models/invoice.rb | 2 + app/models/invoicing_profile.rb | 11 +- app/models/profile_custom_field.rb | 5 +- app/models/user_profile_custom_field.rb | 3 + .../accounting/accounting_code_service.rb | 4 +- .../accounting/accounting_export_service.rb | 135 +---- app/services/accounting/accounting_service.rb | 140 +++++ app/workers/accounting_export_worker.rb | 2 +- app/workers/accounting_worker.rb | 11 + config/locales/en.yml | 15 +- config/schedule.yml | 18 +- .../20221118092948_create_accounting_lines.rb | 24 + db/schema.rb | 39 +- test/fixtures/accounting_lines.yml | 544 ++++++++++++++++++ .../exports/accounting_export_test.rb | 16 +- 20 files changed, 842 insertions(+), 158 deletions(-) create mode 100644 app/models/accounting_line.rb create mode 100644 app/services/accounting/accounting_service.rb create mode 100644 app/workers/accounting_worker.rb create mode 100644 db/migrate/20221118092948_create_accounting_lines.rb create mode 100644 test/fixtures/accounting_lines.yml diff --git a/app/controllers/api/profile_custom_fields_controller.rb b/app/controllers/api/profile_custom_fields_controller.rb index 1d45e5985..7179b47a2 100644 --- a/app/controllers/api/profile_custom_fields_controller.rb +++ b/app/controllers/api/profile_custom_fields_controller.rb @@ -1,13 +1,14 @@ # frozen_string_literal: true # API Controller for resources of type ProfileCustomField -# ProfileCustomFields are used to provide admin config user profile custom fields +# ProfileCustomFields are fields configured by an admin, added to the user's profile class API::ProfileCustomFieldsController < API::ApiController before_action :authenticate_user!, except: :index before_action :set_profile_custom_field, only: %i[show update destroy] def index @profile_custom_fields = ProfileCustomField.all.order('id ASC') + @profile_custom_fields = @profile_custom_fields.where(actived: params[:actived]) if params[:actived].present? end def show; end diff --git a/app/frontend/src/javascript/api/profile-custom-field.ts b/app/frontend/src/javascript/api/profile-custom-field.ts index 0267dadd4..fc6a45a91 100644 --- a/app/frontend/src/javascript/api/profile-custom-field.ts +++ b/app/frontend/src/javascript/api/profile-custom-field.ts @@ -1,10 +1,11 @@ import apiClient from './clients/api-client'; import { AxiosResponse } from 'axios'; -import { ProfileCustomField } from '../models/profile-custom-field'; +import { ProfileCustomField, ProfileCustomFieldIndexFilters } from '../models/profile-custom-field'; +import ApiLib from '../lib/api'; export default class ProfileCustomFieldAPI { - static async index (): Promise> { - const res: AxiosResponse> = await apiClient.get('/api/profile_custom_fields'); + static async index (filters?: ProfileCustomFieldIndexFilters): Promise> { + const res: AxiosResponse> = await apiClient.get(`/api/profile_custom_fields${ApiLib.filtersToQuery(filters)}`); return res?.data; } diff --git a/app/frontend/src/javascript/components/user/user-profile-form.tsx b/app/frontend/src/javascript/components/user/user-profile-form.tsx index 71d66d9c8..842e3fd44 100644 --- a/app/frontend/src/javascript/components/user/user-profile-form.tsx +++ b/app/frontend/src/javascript/components/user/user-profile-form.tsx @@ -82,10 +82,9 @@ export const UserProfileForm: React.FC = ({ action, size, if (cgu?.custom_asset_file_attributes) setTermsAndConditions(cgu); }).catch(error => onError(error)); } - ProfileCustomFieldAPI.index().then(data => { - const fData = data.filter(f => f.actived); - setProfileCustomFields(fData); - const userProfileCustomFields = fData.map(f => { + ProfileCustomFieldAPI.index({ actived: true }).then(data => { + setProfileCustomFields(data); + const userProfileCustomFields = data.map(f => { const upcf = user?.invoicing_profile_attributes?.user_profile_custom_fields_attributes?.find(uf => uf.profile_custom_field_id === f.id); return upcf || { value: '', diff --git a/app/frontend/src/javascript/models/profile-custom-field.ts b/app/frontend/src/javascript/models/profile-custom-field.ts index c1e8ca2fa..0389074b4 100644 --- a/app/frontend/src/javascript/models/profile-custom-field.ts +++ b/app/frontend/src/javascript/models/profile-custom-field.ts @@ -1,6 +1,12 @@ +import { ApiFilter } from './api'; + export interface ProfileCustomField { id: number, label: string, required: boolean, actived: boolean } + +export interface ProfileCustomFieldIndexFilters extends ApiFilter { + actived?: boolean +} diff --git a/app/models/accounting_line.rb b/app/models/accounting_line.rb new file mode 100644 index 000000000..2f94fa990 --- /dev/null +++ b/app/models/accounting_line.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: false + +# Stores an accounting datum related to an invoice, matching the French accounting system (PCG). +# Accounting data are configured by settings starting with accounting_* and by AdvancedAccounting +class AccountingLine < ApplicationRecord + belongs_to :invoice + belongs_to :invoicing_profile +end diff --git a/app/models/invoice.rb b/app/models/invoice.rb index 888a5ec26..a59e2c2a6 100644 --- a/app/models/invoice.rb +++ b/app/models/invoice.rb @@ -18,6 +18,8 @@ class Invoice < PaymentDocument has_one :payment_gateway_object, as: :item, dependent: :destroy belongs_to :operator_profile, class_name: 'InvoicingProfile' + has_many :accounting_lines, dependent: :destroy + delegate :user, to: :invoicing_profile before_create :add_environment diff --git a/app/models/invoicing_profile.rb b/app/models/invoicing_profile.rb index 4db85f2de..3edcee79e 100644 --- a/app/models/invoicing_profile.rb +++ b/app/models/invoicing_profile.rb @@ -17,17 +17,20 @@ class InvoicingProfile < ApplicationRecord has_many :history_values, dependent: :nullify - has_many :operated_invoices, foreign_key: :operator_profile_id, class_name: 'Invoice', dependent: :nullify - has_many :operated_payment_schedules, foreign_key: :operator_profile_id, class_name: 'PaymentSchedule', dependent: :nullify + has_many :operated_invoices, foreign_key: :operator_profile_id, class_name: 'Invoice', dependent: :nullify, inverse_of: :operator_profile + has_many :operated_payment_schedules, foreign_key: :operator_profile_id, class_name: 'PaymentSchedule', + dependent: :nullify, inverse_of: :operator_profile - has_many :user_profile_custom_fields + has_many :user_profile_custom_fields, dependent: :destroy has_many :profile_custom_fields, through: :user_profile_custom_fields accepts_nested_attributes_for :user_profile_custom_fields, allow_destroy: true + has_many :accounting_lines, dependent: :destroy + validates :address, presence: true, if: -> { Setting.get('address_required') } def full_name # if first_name or last_name is nil, the empty string will be used as a temporary replacement - (first_name || '').humanize.titleize + ' ' + (last_name || '').humanize.titleize + "#{(first_name || '').humanize.titleize} #{(last_name || '').humanize.titleize}" end end diff --git a/app/models/profile_custom_field.rb b/app/models/profile_custom_field.rb index fef31c580..4620fe9b0 100644 --- a/app/models/profile_custom_field.rb +++ b/app/models/profile_custom_field.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + +# ProfileCustomFields are customer fields, configured by an admin, added to the user's profile class ProfileCustomField < ApplicationRecord - has_many :user_profile_custom_fields + has_many :user_profile_custom_fields, dependent: :destroy has_many :invoicing_profiles, through: :user_profile_custom_fields end diff --git a/app/models/user_profile_custom_field.rb b/app/models/user_profile_custom_field.rb index b98c65c82..68a202e6a 100644 --- a/app/models/user_profile_custom_field.rb +++ b/app/models/user_profile_custom_field.rb @@ -1,3 +1,6 @@ +# frozen_string_literal: true + +# UserProfileCustomField store values for custom fields per user's profile class UserProfileCustomField < ApplicationRecord belongs_to :invoicing_profile belongs_to :profile_custom_field diff --git a/app/services/accounting/accounting_code_service.rb b/app/services/accounting/accounting_code_service.rb index 1a8fe838c..060b3a5cc 100644 --- a/app/services/accounting/accounting_code_service.rb +++ b/app/services/accounting/accounting_code_service.rb @@ -57,7 +57,7 @@ class Accounting::AccountingCodeService raise ArgumentError('invalid section') unless %i[code analytical_section].include?(section) if type == :code - item_code = Setting.get('advanced_accounting') ? invoice_item.object.plan.advanced_accounting.send(section) : nil + item_code = Setting.get('advanced_accounting') ? invoice_item.object.plan.advanced_accounting&.send(section) : nil return Setting.get('accounting_subscription_code') if item_code.nil? && section == :code item_code @@ -71,7 +71,7 @@ class Accounting::AccountingCodeService raise ArgumentError('invalid section') unless %i[code analytical_section].include?(section) if type == :code - item_code = Setting.get('advanced_accounting') ? invoice_item.object.orderable.advanced_accounting.send(section) : nil + item_code = Setting.get('advanced_accounting') ? invoice_item.object.orderable.advanced_accounting&.send(section) : nil return Setting.get('accounting_Product_code') if item_code.nil? && section == :code item_code diff --git a/app/services/accounting/accounting_export_service.rb b/app/services/accounting/accounting_export_service.rb index 61d54fcf0..df35f7d65 100644 --- a/app/services/accounting/accounting_export_service.rb +++ b/app/services/accounting/accounting_export_service.rb @@ -7,8 +7,7 @@ module Accounting; end class Accounting::AccountingExportService include ActionView::Helpers::NumberHelper - attr_reader :encoding, :format, :separator, :journal_code, :date_format, :columns, :decimal_separator, :label_max_length, - :export_zeros + attr_reader :encoding, :format, :separator, :date_format, :columns, :decimal_separator, :label_max_length, :export_zeros def initialize(columns, encoding: 'UTF-8', format: 'CSV', separator: ';') @encoding = encoding @@ -18,7 +17,6 @@ class Accounting::AccountingExportService @date_format = '%d/%m/%Y' @label_max_length = 50 @export_zeros = false - @journal_code = Setting.get('accounting_journal_code') || '' @columns = columns end @@ -32,11 +30,12 @@ class Accounting::AccountingExportService def export(start_date, end_date, file) # build CSV content content = header_row - invoices = Invoice.where('created_at >= ? AND created_at <= ?', start_date, end_date).order('created_at ASC') - invoices = invoices.where('total > 0') unless export_zeros - invoices.each do |i| - Rails.logger.debug { "processing invoice #{i.id}..." } unless Rails.env.test? - content << generate_rows(i) + lines = AccountingLine.where('date >= ? AND date <= ?', start_date, end_date) + .order('date ASC') + lines = lines.joins(:invoice).where('invoices.total > 0') unless export_zeros + lines.each do |l| + Rails.logger.debug { "processing invoice #{l.invoice_id}..." } unless Rails.env.test? + content << "#{row(l)}\n" end # write content to file @@ -53,138 +52,46 @@ class Accounting::AccountingExportService "#{row}\n" end - def generate_rows(invoice) - rows = client_rows(invoice) + items_rows(invoice) - - vat = vat_row(invoice) - rows += "#{vat}\n" unless vat.nil? - - rows - end - - # Generate the "subscription" and "reservation" rows associated with the provided invoice - def items_rows(invoice) - rows = '' - %w[Subscription Reservation WalletTransaction StatisticProfilePrepaidPack OrderItem Error].each do |object_type| - items = invoice.invoice_items.filter { |ii| ii.object_type == object_type } - items.each do |item| - rows << "#{row( - invoice, - Accounting::AccountingCodeService.sales_account(item), - Accounting::AccountingCodeService.sales_account(item, type: :label), - item.net_amount / 100.00, - line_label: label(invoice) - )}\n" - end - end - rows - end - - # Generate the "client" rows, which contains the debit to the client account, all taxes included - def client_rows(invoice) - rows = '' - invoice.payment_means.each do |details| - rows << row( - invoice, - Accounting::AccountingCodeService.client_account(details[:means]), - Accounting::AccountingCodeService.client_account(details[:means], type: :label), - details[:amount] / 100.00, - line_label: label(invoice), - debit_method: :debit_client, - credit_method: :credit_client - ) - rows << "\n" - end - rows - end - - # Generate the "VAT" row, which contains the credit to the VAT account, with VAT amount only - def vat_row(invoice) - total = invoice.invoice_items.map(&:net_amount).sum - # we do not render the VAT row if it was disabled for this invoice - return nil if total == invoice.total - - row( - invoice, - Accounting::AccountingCodeService.vat_account, - Accounting::AccountingCodeService.vat_account(type: :label), - invoice.invoice_items.map(&:vat).map(&:to_i).reduce(:+) / 100.00, - line_label: label(invoice) - ) - end - # Generate a row of the export, filling the configured columns with the provided values - def row(invoice, account_code, account_label, amount, line_label: '', debit_method: :debit, credit_method: :credit) + def row(line) row = '' columns.each do |column| case column when 'journal_code' - row << journal_code.to_s + row << line.journal_code.to_s when 'date' - row << invoice.created_at&.strftime(date_format) + row << line.date&.strftime(date_format) when 'account_code' - row << account_code + row << line.account_code when 'account_label' - row << account_label + row << line.account_label when 'piece' - row << invoice.reference + row << line.invoice.reference when 'line_label' - row << line_label + row << label(line) when 'debit_origin', 'debit_euro' - row << method(debit_method).call(invoice, amount) + row << format_number(line.debit / 100.00) when 'credit_origin', 'credit_euro' - row << method(credit_method).call(invoice, amount) + row << format_number(line.credit / 100.00) when 'lettering' row << '' else - Rails.logger.debug { "Unsupported column: #{column}" } + Rails.logger.warn { "Unsupported column: #{column}" } end row << separator end row end - # Fill the value of the "debit" column: if the invoice is a refund, returns the given amount, returns 0 otherwise - def debit(invoice, amount) - avoir = invoice.is_a? Avoir - avoir ? format_number(amount) : '0' - end - - # Fill the value of the "credit" column: if the invoice is a refund, returns 0, otherwise, returns the given amount - def credit(invoice, amount) - avoir = invoice.is_a? Avoir - avoir ? '0' : format_number(amount) - end - - # Fill the value of the "debit" column for the client row: if the invoice is a refund, returns 0, otherwise, returns the given amount - def debit_client(invoice, amount) - credit(invoice, amount) - end - - # Fill the value of the "credit" column, for the client row: if the invoice is a refund, returns the given amount, returns 0 otherwise - def credit_client(invoice, amount) - debit(invoice, amount) - end - # Format the given number as a string, using the configured separator def format_number(num) number_to_currency(num, unit: '', separator: decimal_separator, delimiter: '', precision: 2) end # Create a text from the given invoice, matching the accounting software rules for the labels - def label(invoice) - name = "#{invoice.invoicing_profile.last_name} #{invoice.invoicing_profile.first_name}".tr separator, '' - reference = invoice.reference - - items = invoice.subscription_invoice? ? [I18n.t('accounting_export.subscription')] : [] - if invoice.main_item.object_type == 'Reservation' - items.push I18n.t("accounting_export.#{invoice.main_item.object.reservable_type}_reservation") - end - items.push I18n.t('accounting_export.wallet') if invoice.main_item.object_type == 'WalletTransaction' - items.push I18n.t('accounting_export.shop_order') if invoice.main_item.object_type == 'OrderItem' - - summary = items.join(' + ') - res = "#{reference}, #{summary}" - "#{name.truncate(label_max_length - res.length)}, #{res}" + def label(line) + name = "#{line.invoicing_profile.last_name} #{line.invoicing_profile.first_name}".tr separator, '' + summary = line.summary + "#{name.truncate(label_max_length - summary.length)}, #{summary}" end end diff --git a/app/services/accounting/accounting_service.rb b/app/services/accounting/accounting_service.rb new file mode 100644 index 000000000..ffd45cfbb --- /dev/null +++ b/app/services/accounting/accounting_service.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: false + +# module definition +module Accounting; end + +# Provides the routine to build the accounting data and save them in DB +class Accounting::AccountingService + attr_reader :currency, :journal_code + + def initialize + @currency = ENV.fetch('INTL_CURRENCY') { '' } + @journal_code = Setting.get('accounting_journal_code') || '' + end + + def build(start_date, end_date) + # build accounting lines + lines = [] + invoices = Invoice.where('created_at >= ? AND created_at <= ?', start_date, end_date).order('created_at ASC') + invoices.each do |i| + Rails.logger.debug { "processing invoice #{i.id}..." } unless Rails.env.test? + lines << generate_lines(i) + end + AccountingLine.create!(lines) + end + + private + + def generate_lines(invoice) + lines = client_lines(invoice) + items_lines(invoice) + + vat = vat_line(invoice) + lines << vat unless vat.nil? + + lines + end + + # Generate the lines associated with the provided invoice, for the sales accounts + def items_lines(invoice) + lines = [] + %w[Subscription Reservation WalletTransaction StatisticProfilePrepaidPack OrderItem Error].each do |object_type| + items = invoice.invoice_items.filter { |ii| ii.object_type == object_type } + items.each do |item| + lines << line( + invoice, + 'item', + Accounting::AccountingCodeService.sales_account(item), + Accounting::AccountingCodeService.sales_account(item, type: :label), + item.net_amount, + analytical_code: Accounting::AccountingCodeService.sales_account(item, section: :analytical_section) + ) + end + end + lines + end + + # Generate the "client" lines, which contains the debit to the client account, all taxes included + def client_lines(invoice) + lines = [] + invoice.payment_means.each do |details| + lines << line( + invoice, + 'client', + Accounting::AccountingCodeService.client_account(details[:means]), + Accounting::AccountingCodeService.client_account(details[:means], type: :label), + details[:amount], + debit_method: :debit_client, + credit_method: :credit_client + ) + end + lines + end + + # Generate the "VAT" line, which contains the credit to the VAT account, with total VAT amount only + def vat_line(invoice) + vat_rate_groups = VatHistoryService.new.invoice_vat(invoice) + total_vat = vat_rate_groups.values.pluck(:total_vat).sum + # we do not render the VAT row if it was disabled for this invoice + return nil if total_vat.zero? + + line( + invoice, + 'vat', + Accounting::AccountingCodeService.vat_account, + Accounting::AccountingCodeService.vat_account(type: :label), + total_vat + ) + end + + # Generate a row of the export, filling the configured columns with the provided values + def line(invoice, line_type, account_code, account_label, amount, analytical_code: '', debit_method: :debit, credit_method: :credit) + { + line_type: line_type, + journal_code: journal_code, + date: invoice.created_at, + account_code: account_code, + account_label: account_label, + analytical_code: analytical_code, + invoice_id: invoice.id, + invoicing_profile_id: invoice.invoicing_profile_id, + debit: method(debit_method).call(invoice, amount), + credit: method(credit_method).call(invoice, amount), + currency: currency, + summary: summary(invoice) + } + end + + # Fill the value of the "debit" column: if the invoice is a refund, returns the given amount, returns 0 otherwise + def debit(invoice, amount) + invoice.is_a?(Avoir) ? amount : 0 + end + + # Fill the value of the "credit" column: if the invoice is a refund, returns 0, otherwise, returns the given amount + def credit(invoice, amount) + invoice.is_a?(Avoir) ? 0 : amount + end + + # Fill the value of the "debit" column for the client row: if the invoice is a refund, returns 0, otherwise, returns the given amount + def debit_client(invoice, amount) + credit(invoice, amount) + end + + # Fill the value of the "credit" column, for the client row: if the invoice is a refund, returns the given amount, returns 0 otherwise + def credit_client(invoice, amount) + debit(invoice, amount) + end + + # Create a text from the given invoice, matching the accounting software rules for the labels + def summary(invoice) + reference = invoice.reference + + items = invoice.subscription_invoice? ? [I18n.t('accounting_summary.subscription_abbreviation')] : [] + if invoice.main_item.object_type == 'Reservation' + items.push I18n.t("accounting_summary.#{invoice.main_item.object.reservable_type}_reservation_abbreviation") + end + items.push I18n.t('accounting_summary.wallet_abbreviation') if invoice.main_item.object_type == 'WalletTransaction' + items.push I18n.t('accounting_summary.shop_order_abbreviation') if invoice.main_item.object_type == 'OrderItem' + + "#{reference}, #{items.join(' + ')}" + end +end diff --git a/app/workers/accounting_export_worker.rb b/app/workers/accounting_export_worker.rb index a9e433b9c..7286708d0 100644 --- a/app/workers/accounting_export_worker.rb +++ b/app/workers/accounting_export_worker.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Asynchronously export the accounting data (Invoices & Avoirs) to an external accounting software +# Asynchronously export the accounting data (AccountingLines) to an external accounting software class AccountingExportWorker include Sidekiq::Worker diff --git a/app/workers/accounting_worker.rb b/app/workers/accounting_worker.rb new file mode 100644 index 000000000..1a5f9578c --- /dev/null +++ b/app/workers/accounting_worker.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# Periodically build the accounting data (AccountingLine) from the Invoices & Avoirs +class AccountingWorker + include Sidekiq::Worker + + def perform + service = Accounting::AccountingService.new + service.build(DateTime.current.beginning_of_day, DateTime.current.end_of_day) + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 5b976887b..14058295f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -158,13 +158,14 @@ en: credit_euro: "Euro credit" lettering: "Lettering" VAT: 'VAT' - subscription: "subscr." - Machine_reservation: "machine reserv." - Training_reservation: "training reserv." - Event_reservation: "event reserv." - Space_reservation: "space reserv." - wallet: "wallet" - shop_order: "shop order" + accounting_summary: + subscription_abbreviation: "subscr." + Machine_reservation_abbreviation: "machine reserv." + Training_reservation_abbreviation: "training reserv." + Event_reservation_abbreviation: "event reserv." + Space_reservation_abbreviation: "space reserv." + wallet_abbreviation: "wallet" + shop_order_abbreviation: "shop order" vat_export: start_date: "Start date" end_date: "End date" diff --git a/config/schedule.yml b/config/schedule.yml index b83e7d469..745c078eb 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -1,27 +1,27 @@ subscription_expire_in_7_days: - cron: "0 0 * * *" + cron: "0 0 * * *" # every day, at midnight class: SubscriptionExpireWorker queue: default args: [7] subscription_is_expired: - cron: "0 23 * * *" + cron: "0 23 * * *" # every day, at 11pm class: SubscriptionExpireWorker queue: default args: [0] generate_statistic: - cron: "0 1 * * *" + cron: "0 1 * * *" # every day, at 1am class: StatisticWorker queue: default i_calendar_import: - cron: "0 * * * *" + cron: "0 * * * *" # every day, every hour class: ICalendarImportWorker queue: default reservation_reminder: - cron: "1 * * * *" + cron: "1 * * * *" # every day, every hour + 1 minute class: ReservationReminderWorker queue: default @@ -35,11 +35,10 @@ free_disk_space: class: FreeDiskSpaceWorker queue: system -# schedule a version check, every week at the current day+time # this will prevent that all the instances query the hub simultaneously <% h = DateTime.current - 1.minute %> version_check: - cron: <%="#{h.strftime('%M %H')} * * #{h.cwday}" %> + cron: <%="#{h.strftime('%M %H')} * * #{h.cwday}" %> # every week, at current day+time class: VersionCheckWorker queue: system @@ -48,4 +47,9 @@ payment_schedule_item: class: PaymentScheduleItemWorker queue: default +accounting_data: + cron: "0 0 * * *" # every day, at midnight + class: AccountingWorker + queue: default + <%= PluginRegistry.insert_code('yml.schedule') %> diff --git a/db/migrate/20221118092948_create_accounting_lines.rb b/db/migrate/20221118092948_create_accounting_lines.rb new file mode 100644 index 000000000..f936d8979 --- /dev/null +++ b/db/migrate/20221118092948_create_accounting_lines.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# From this migration we save the accounting lines in database rather than building them on-the-fly. +# This will improve performance for API based requests +class CreateAccountingLines < ActiveRecord::Migration[5.2] + def change + create_table :accounting_lines do |t| + t.string :line_type + t.string :journal_code + t.datetime :date + t.string :account_code + t.string :account_label + t.string :analytical_code + t.references :invoice, foreign_key: true, index: true + t.references :invoicing_profile, foreign_key: true, index: true + t.integer :debit + t.integer :credit + t.string :currency + t.string :summary + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 261db0394..e1ecb1d70 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -19,8 +19,8 @@ ActiveRecord::Schema.define(version: 2022_11_22_123605) do enable_extension "unaccent" create_table "abuses", id: :serial, force: :cascade do |t| - t.integer "signaled_id" t.string "signaled_type" + t.integer "signaled_id" t.string "first_name" t.string "last_name" t.string "email" @@ -30,6 +30,25 @@ ActiveRecord::Schema.define(version: 2022_11_22_123605) do t.index ["signaled_type", "signaled_id"], name: "index_abuses_on_signaled_type_and_signaled_id" end + create_table "accounting_lines", force: :cascade do |t| + t.string "line_type" + t.string "journal_code" + t.datetime "date" + t.string "account_code" + t.string "account_label" + t.string "analytical_code" + t.bigint "invoice_id" + t.bigint "invoicing_profile_id" + t.integer "debit" + t.integer "credit" + t.string "currency" + t.string "summary" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["invoice_id"], name: "index_accounting_lines_on_invoice_id" + t.index ["invoicing_profile_id"], name: "index_accounting_lines_on_invoicing_profile_id" + end + create_table "accounting_periods", id: :serial, force: :cascade do |t| t.date "start_at" t.date "end_at" @@ -49,8 +68,8 @@ ActiveRecord::Schema.define(version: 2022_11_22_123605) do t.string "locality" t.string "country" t.string "postal_code" - t.integer "placeable_id" t.string "placeable_type" + t.integer "placeable_id" t.datetime "created_at" t.datetime "updated_at" end @@ -74,8 +93,8 @@ ActiveRecord::Schema.define(version: 2022_11_22_123605) do end create_table "assets", id: :serial, force: :cascade do |t| - t.integer "viewable_id" t.string "viewable_type" + t.integer "viewable_id" t.string "attachment" t.string "type" t.datetime "created_at" @@ -157,8 +176,8 @@ ActiveRecord::Schema.define(version: 2022_11_22_123605) do end create_table "credits", id: :serial, force: :cascade do |t| - t.integer "creditable_id" t.string "creditable_type" + t.integer "creditable_id" t.integer "plan_id" t.integer "hours" t.datetime "created_at" @@ -387,15 +406,15 @@ ActiveRecord::Schema.define(version: 2022_11_22_123605) do create_table "notifications", id: :serial, force: :cascade do |t| t.integer "receiver_id" - t.integer "attached_object_id" t.string "attached_object_type" + t.integer "attached_object_id" t.integer "notification_type_id" t.boolean "is_read", default: false t.datetime "created_at" t.datetime "updated_at" t.string "receiver_type" t.boolean "is_send", default: false - t.jsonb "meta_data", default: {} + t.jsonb "meta_data", default: "{}" t.index ["notification_type_id"], name: "index_notifications_on_notification_type_id" t.index ["receiver_id"], name: "index_notifications_on_receiver_id" end @@ -635,8 +654,8 @@ ActiveRecord::Schema.define(version: 2022_11_22_123605) do create_table "prices", id: :serial, force: :cascade do |t| t.integer "group_id" t.integer "plan_id" - t.integer "priceable_id" t.string "priceable_type" + t.integer "priceable_id" t.integer "amount" t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -836,8 +855,8 @@ ActiveRecord::Schema.define(version: 2022_11_22_123605) do t.text "message" t.datetime "created_at" t.datetime "updated_at" - t.integer "reservable_id" t.string "reservable_type" + t.integer "reservable_id" t.integer "nb_reserve_places" t.integer "statistic_profile_id" t.index ["reservable_type", "reservable_id"], name: "index_reservations_on_reservable_type_and_reservable_id" @@ -846,8 +865,8 @@ ActiveRecord::Schema.define(version: 2022_11_22_123605) do create_table "roles", id: :serial, force: :cascade do |t| t.string "name" - t.integer "resource_id" t.string "resource_type" + t.integer "resource_id" t.datetime "created_at" t.datetime "updated_at" t.index ["name", "resource_type", "resource_id"], name: "index_roles_on_name_and_resource_type_and_resource_id" @@ -1178,6 +1197,8 @@ ActiveRecord::Schema.define(version: 2022_11_22_123605) do t.index ["invoicing_profile_id"], name: "index_wallets_on_invoicing_profile_id" end + add_foreign_key "accounting_lines", "invoices" + add_foreign_key "accounting_lines", "invoicing_profiles" add_foreign_key "accounting_periods", "users", column: "closed_by" add_foreign_key "auth_provider_mappings", "auth_providers" add_foreign_key "availability_tags", "availabilities" diff --git a/test/fixtures/accounting_lines.yml b/test/fixtures/accounting_lines.yml new file mode 100644 index 000000000..823e2d00d --- /dev/null +++ b/test/fixtures/accounting_lines.yml @@ -0,0 +1,544 @@ +accounting_line_1: + id: 1 + line_type: client + journal_code: '530' + date: '2012-03-12 11:03:31.651441' + account_code: '5801' + account_label: Client card + analytical_code: '' + invoice_id: 1 + invoicing_profile_id: 3 + debit: 10000 + credit: 0 + currency: EUR + summary: 1604001/VL, subscr. + created_at: '2022-11-18 15:04:08.437029' + updated_at: '2022-11-18 15:04:08.437029' +accounting_line_2: + id: 2 + line_type: item + journal_code: '530' + date: '2012-03-12 11:03:31.651441' + account_code: '7061' + account_label: Subscription + analytical_code: + invoice_id: 1 + invoicing_profile_id: 3 + debit: 0 + credit: 10000 + currency: EUR + summary: 1604001/VL, subscr. + created_at: '2022-11-18 15:04:08.455753' + updated_at: '2022-11-18 15:04:08.455753' +accounting_line_3: + id: 3 + line_type: client + journal_code: '530' + date: '2012-03-12 13:40:22.342717' + account_code: '5803' + account_label: Client other + analytical_code: '' + invoice_id: 2 + invoicing_profile_id: 4 + debit: 2000 + credit: 0 + currency: EUR + summary: 1604002, subscr. + created_at: '2022-11-18 15:04:08.463802' + updated_at: '2022-11-18 15:04:08.463802' +accounting_line_4: + id: 4 + line_type: item + journal_code: '530' + date: '2012-03-12 13:40:22.342717' + account_code: '7061' + account_label: Subscription + analytical_code: + invoice_id: 2 + invoicing_profile_id: 4 + debit: 0 + credit: 2000 + currency: EUR + summary: 1604002, subscr. + created_at: '2022-11-18 15:04:08.471904' + updated_at: '2022-11-18 15:04:08.471904' +accounting_line_5: + id: 5 + line_type: client + journal_code: '530' + date: '2015-06-10 11:20:01.341130' + account_code: '5803' + account_label: Client other + analytical_code: '' + invoice_id: 3 + invoicing_profile_id: 7 + debit: 3000 + credit: 0 + currency: EUR + summary: 1203001, subscr. + created_at: '2022-11-18 15:04:08.480362' + updated_at: '2022-11-18 15:04:08.480362' +accounting_line_6: + id: 6 + line_type: item + journal_code: '530' + date: '2015-06-10 11:20:01.341130' + account_code: '7061' + account_label: Subscription + analytical_code: + invoice_id: 3 + invoicing_profile_id: 7 + debit: 0 + credit: 3000 + currency: EUR + summary: 1203001, subscr. + created_at: '2022-11-18 15:04:08.488755' + updated_at: '2022-11-18 15:04:08.488755' +accounting_line_7: + id: 7 + line_type: client + journal_code: '530' + date: '2016-04-05 08:35:52.931187' + account_code: '5803' + account_label: Client other + analytical_code: '' + invoice_id: 4 + invoicing_profile_id: 7 + debit: 0 + credit: 0 + currency: EUR + summary: 1203002, training reserv. + created_at: '2022-11-18 15:04:08.497148' + updated_at: '2022-11-18 15:04:08.497148' +accounting_line_8: + id: 8 + line_type: item + journal_code: '530' + date: '2016-04-05 08:35:52.931187' + account_code: '7062' + account_label: Training reservation + analytical_code: + invoice_id: 4 + invoicing_profile_id: 7 + debit: 0 + credit: 0 + currency: EUR + summary: 1203002, training reserv. + created_at: '2022-11-18 15:04:08.505540' + updated_at: '2022-11-18 15:04:08.505540' +accounting_line_9: + id: 9 + line_type: client + journal_code: '530' + date: '2016-04-05 08:36:46.853368' + account_code: '5803' + account_label: Client other + analytical_code: '' + invoice_id: 5 + invoicing_profile_id: 3 + debit: 1500 + credit: 0 + currency: EUR + summary: 1506031, machine reserv. + created_at: '2022-11-18 15:04:08.513708' + updated_at: '2022-11-18 15:04:08.513708' +accounting_line_10: + id: 10 + line_type: item + journal_code: '530' + date: '2016-04-05 08:36:46.853368' + account_code: '7065' + account_label: Machine reservation + analytical_code: + invoice_id: 5 + invoicing_profile_id: 3 + debit: 0 + credit: 1500 + currency: EUR + summary: 1506031, machine reserv. + created_at: '2022-11-18 15:04:08.522222' + updated_at: '2022-11-18 15:04:08.522222' +accounting_line_11: + id: 11 + line_type: client + journal_code: '530' + date: '2021-01-04 14:51:21.616153' + account_code: '5803' + account_label: Client other + analytical_code: '' + invoice_id: 6 + invoicing_profile_id: 8 + debit: 3000 + credit: 0 + currency: EUR + summary: 2101041, subscr. + created_at: '2022-11-18 15:04:08.530494' + updated_at: '2022-11-18 15:04:08.530494' +accounting_line_12: + id: 12 + line_type: item + journal_code: '530' + date: '2021-01-04 14:51:21.616153' + account_code: '7061' + account_label: Subscription + analytical_code: + invoice_id: 6 + invoicing_profile_id: 8 + debit: 0 + credit: 3000 + currency: EUR + summary: 2101041, subscr. + created_at: '2022-11-18 15:04:08.538721' + updated_at: '2022-11-18 15:04:08.538721' +accounting_line_13: + id: 13 + line_type: client + journal_code: '530' + date: '2022-09-20 15:14:22.873707' + account_code: '5803' + account_label: Client other + analytical_code: '' + invoice_id: 5811 + invoicing_profile_id: 3 + debit: 4500 + credit: 0 + currency: EUR + summary: 2209002, shop order + created_at: '2022-11-18 15:04:08.547966' + updated_at: '2022-11-18 15:04:08.547966' +accounting_line_14: + id: 14 + line_type: item + journal_code: '530' + date: '2022-09-20 15:14:22.873707' + account_code: '7067' + account_label: Shop order + analytical_code: + invoice_id: 5811 + invoicing_profile_id: 3 + debit: 0 + credit: 4000 + currency: EUR + summary: 2209002, shop order + created_at: '2022-11-18 15:04:08.556504' + updated_at: '2022-11-18 15:04:08.556504' +accounting_line_15: + id: 15 + line_type: item + journal_code: '530' + date: '2022-09-20 15:14:22.873707' + account_code: '7067' + account_label: Shop order + analytical_code: + invoice_id: 5811 + invoicing_profile_id: 3 + debit: 0 + credit: 500 + currency: EUR + summary: 2209002, shop order + created_at: '2022-11-18 15:04:08.563733' + updated_at: '2022-11-18 15:04:08.563733' +accounting_line_16: + id: 16 + line_type: client + journal_code: '530' + date: '2022-09-20 15:14:48.345927' + account_code: '5803' + account_label: Client other + analytical_code: '' + invoice_id: 5812 + invoicing_profile_id: 7 + debit: 6000 + credit: 0 + currency: EUR + summary: 2209004, shop order + created_at: '2022-11-18 15:04:08.571992' + updated_at: '2022-11-18 15:04:08.571992' +accounting_line_17: + id: 17 + line_type: item + journal_code: '530' + date: '2022-09-20 15:14:48.345927' + account_code: '7067' + account_label: Shop order + analytical_code: + invoice_id: 5812 + invoicing_profile_id: 7 + debit: 0 + credit: 6000 + currency: EUR + summary: 2209004, shop order + created_at: '2022-11-18 15:04:08.580452' + updated_at: '2022-11-18 15:04:08.580452' +accounting_line_18: + id: 18 + line_type: client + journal_code: '530' + date: '2022-10-04 12:36:03.060832' + account_code: '5801' + account_label: Client card + analytical_code: '' + invoice_id: 5816 + invoicing_profile_id: 4 + debit: 319 + credit: 0 + currency: EUR + summary: 2210002/VL, shop order + created_at: '2022-11-18 15:04:08.589664' + updated_at: '2022-11-18 15:04:08.589664' +accounting_line_19: + id: 19 + line_type: item + journal_code: '530' + date: '2022-10-04 12:36:03.060832' + account_code: '7067' + account_label: Shop order + analytical_code: + invoice_id: 5816 + invoicing_profile_id: 4 + debit: 0 + credit: 119 + currency: EUR + summary: 2210002/VL, shop order + created_at: '2022-11-18 15:04:08.598371' + updated_at: '2022-11-18 15:04:08.598371' +accounting_line_20: + id: 20 + line_type: item + journal_code: '530' + date: '2022-10-04 12:36:03.060832' + account_code: '7067' + account_label: Shop order + analytical_code: + invoice_id: 5816 + invoicing_profile_id: 4 + debit: 0 + credit: 200 + currency: EUR + summary: 2210002/VL, shop order + created_at: '2022-11-18 15:04:08.613961' + updated_at: '2022-11-18 15:04:08.613961' +accounting_line_21: + id: 21 + line_type: client + journal_code: '530' + date: '2022-10-04 13:54:42.975196' + account_code: '5801' + account_label: Client card + analytical_code: '' + invoice_id: 5817 + invoicing_profile_id: 4 + debit: 1295 + credit: 0 + currency: EUR + summary: 2210004/VL, shop order + created_at: '2022-11-18 15:04:08.622056' + updated_at: '2022-11-18 15:04:08.622056' +accounting_line_22: + id: 22 + line_type: item + journal_code: '530' + date: '2022-10-04 13:54:42.975196' + account_code: '7067' + account_label: Shop order + analytical_code: + invoice_id: 5817 + invoicing_profile_id: 4 + debit: 0 + credit: 95 + currency: EUR + summary: 2210004/VL, shop order + created_at: '2022-11-18 15:04:08.630519' + updated_at: '2022-11-18 15:04:08.630519' +accounting_line_23: + id: 23 + line_type: item + journal_code: '530' + date: '2022-10-04 13:54:42.975196' + account_code: '7067' + account_label: Shop order + analytical_code: + invoice_id: 5817 + invoicing_profile_id: 4 + debit: 0 + credit: 1200 + currency: EUR + summary: 2210004/VL, shop order + created_at: '2022-11-18 15:04:08.640333' + updated_at: '2022-11-18 15:04:08.640333' +accounting_line_24: + id: 24 + line_type: client + journal_code: '530' + date: '2022-10-04 14:04:12.742685' + account_code: '5801' + account_label: Client card + analytical_code: '' + invoice_id: 5818 + invoicing_profile_id: 4 + debit: 1000 + credit: 0 + currency: EUR + summary: 2210006/VL, shop order + created_at: '2022-11-18 15:04:08.656104' + updated_at: '2022-11-18 15:04:08.656104' +accounting_line_25: + id: 25 + line_type: item + journal_code: '530' + date: '2022-10-04 14:04:12.742685' + account_code: '7067' + account_label: Shop order + analytical_code: + invoice_id: 5818 + invoicing_profile_id: 4 + debit: 0 + credit: 1000 + currency: EUR + summary: 2210006/VL, shop order + created_at: '2022-11-18 15:04:08.663862' + updated_at: '2022-11-18 15:04:08.663862' +accounting_line_26: + id: 26 + line_type: client + journal_code: '530' + date: '2022-10-04 14:17:52.854636' + account_code: '5801' + account_label: Client card + analytical_code: '' + invoice_id: 5819 + invoicing_profile_id: 4 + debit: 4002 + credit: 0 + currency: EUR + summary: 2210008/VL, shop order + created_at: '2022-11-18 15:04:08.672150' + updated_at: '2022-11-18 15:04:08.672150' +accounting_line_27: + id: 27 + line_type: item + journal_code: '530' + date: '2022-10-04 14:17:52.854636' + account_code: '7067' + account_label: Shop order + analytical_code: + invoice_id: 5819 + invoicing_profile_id: 4 + debit: 0 + credit: 2 + currency: EUR + summary: 2210008/VL, shop order + created_at: '2022-11-18 15:04:08.680577' + updated_at: '2022-11-18 15:04:08.680577' +accounting_line_28: + id: 28 + line_type: item + journal_code: '530' + date: '2022-10-04 14:17:52.854636' + account_code: '7067' + account_label: Shop order + analytical_code: + invoice_id: 5819 + invoicing_profile_id: 4 + debit: 0 + credit: 4000 + currency: EUR + summary: 2210008/VL, shop order + created_at: '2022-11-18 15:04:08.688864' + updated_at: '2022-11-18 15:04:08.688864' +accounting_line_29: + id: 29 + line_type: client + journal_code: '530' + date: '2022-10-04 14:25:37.291945' + account_code: '5803' + account_label: Client other + analytical_code: '' + invoice_id: 5820 + invoicing_profile_id: 3 + debit: 12000 + credit: 0 + currency: EUR + summary: 2210010, shop order + created_at: '2022-11-18 15:04:08.697635' + updated_at: '2022-11-18 15:04:08.697635' +accounting_line_30: + id: 30 + line_type: item + journal_code: '530' + date: '2022-10-04 14:25:37.291945' + account_code: '7067' + account_label: Shop order + analytical_code: + invoice_id: 5820 + invoicing_profile_id: 3 + debit: 0 + credit: 12000 + currency: EUR + summary: 2210010, shop order + created_at: '2022-11-18 15:04:08.705822' + updated_at: '2022-11-18 15:04:08.705822' +accounting_line_31: + id: 31 + line_type: client + journal_code: '530' + date: '2022-10-04 14:32:28.204985' + account_code: '5803' + account_label: Client other + analytical_code: '' + invoice_id: 5821 + invoicing_profile_id: 2 + debit: 12000 + credit: 0 + currency: EUR + summary: 2210012, shop order + created_at: '2022-11-18 15:04:08.713849' + updated_at: '2022-11-18 15:04:08.713849' +accounting_line_32: + id: 32 + line_type: item + journal_code: '530' + date: '2022-10-04 14:32:28.204985' + account_code: '7067' + account_label: Shop order + analytical_code: + invoice_id: 5821 + invoicing_profile_id: 2 + debit: 0 + credit: 12000 + currency: EUR + summary: 2210012, shop order + created_at: '2022-11-18 15:04:08.722579' + updated_at: '2022-11-18 15:04:08.722579' +accounting_line_33: + id: 33 + line_type: client + journal_code: '530' + date: '2022-10-04 14:35:40.584472' + account_code: '5803' + account_label: Client other + analytical_code: '' + invoice_id: 5822 + invoicing_profile_id: 2 + debit: 3000 + credit: 0 + currency: EUR + summary: 2210014, shop order + created_at: '2022-11-18 15:04:08.731248' + updated_at: '2022-11-18 15:04:08.731248' +accounting_line_34: + id: 34 + line_type: item + journal_code: '530' + date: '2022-10-04 14:35:40.584472' + account_code: '7067' + account_label: Shop order + analytical_code: + invoice_id: 5822 + invoicing_profile_id: 2 + debit: 0 + credit: 3000 + currency: EUR + summary: 2210014, shop order + created_at: '2022-11-18 15:04:08.739474' + updated_at: '2022-11-18 15:04:08.739474' diff --git a/test/integration/exports/accounting_export_test.rb b/test/integration/exports/accounting_export_test.rb index edb9d06e1..076a199bc 100644 --- a/test/integration/exports/accounting_export_test.rb +++ b/test/integration/exports/accounting_export_test.rb @@ -168,19 +168,25 @@ class Exports::AccountingExportTest < ActionDispatch::IntegrationTest def check_entry_label(invoice, line) if invoice.subscription_invoice? - assert_match I18n.t('accounting_export.subscription'), + assert_match I18n.t('accounting_summary.subscription_abbreviation'), line[I18n.t('accounting_export.line_label')], 'Entry label does not contains the reference to the subscription' end if invoice.main_item.object_type == 'Reservation' - assert_match I18n.t("accounting_export.#{invoice.main_item.object.reservable_type}_reservation"), + assert_match I18n.t("accounting_summary.#{invoice.main_item.object.reservable_type}_reservation_abbreviation"), line[I18n.t('accounting_export.line_label')], 'Entry label does not contains the reference to the reservation' end - return unless invoice.main_item.object_type == 'WalletTransaction' + if invoice.main_item.object_type == 'WalletTransaction' + assert_match I18n.t('accounting_summary.wallet_abbreviation'), + line[I18n.t('accounting_export.line_label')], + 'Entry label does not contains the reference to the wallet' + end - assert_match I18n.t('accounting_export.wallet'), + return unless invoice.main_item.object_type == 'OrderItem' + + assert_match I18n.t('accounting_summary.shop_order_abbreviation'), line[I18n.t('accounting_export.line_label')], - 'Entry label does not contains the reference to the wallet' + 'Entry label does not contains the reference to the order' end end