Refactored monthly invoice generation job

This commit is contained in:
Sergei Tsõganov 2022-10-12 09:59:21 +03:00
parent cd8e14a177
commit 5e6dbac462
20 changed files with 367 additions and 486 deletions

View file

@ -1,15 +1,8 @@
class DirectoInvoiceForwardJob < ApplicationJob
def perform(monthly: false, dry: false)
data = nil
def perform(dry: false)
data = collect_receipts_data
if monthly
@month = Time.zone.now - 1.month
data = collect_monthly_data
else
data = collect_receipts_data
end
EisBilling::SendDataToDirecto.send_request(object_data: data, monthly: monthly, dry: dry)
EisBilling::SendDataToDirecto.send_request(object_data: data, monthly: false, dry: dry)
end
def collect_receipts_data
@ -38,47 +31,4 @@ class DirectoInvoiceForwardJob < ApplicationJob
true
end
def collect_monthly_data
registrars_data = []
Registrar.where.not(test_registrar: true).find_each do |registrar|
registrars_data << {
registrar: registrar,
registrar_summery: registrar.monthly_summary(month: @month),
}
end
registrars_data
end
def mark_invoice_as_sent(invoice: nil, res:, req:)
directo_record = Directo.new(response: res.as_json.to_h,
request: req, invoice_number: res.attributes['docid'].value.to_i)
if invoice
directo_record.item = invoice
invoice.update(in_directo: true)
else
update_directo_number(num: directo_record.invoice_number)
end
directo_record.save!
end
def update_directo_number(num:)
return unless num.to_i > Setting.directo_monthly_number_last.to_i
Setting.directo_monthly_number_last = num.to_i
end
def directo_counter_exceedable?(invoice_count)
min_directo = Setting.directo_monthly_number_min.presence.try(:to_i)
max_directo = Setting.directo_monthly_number_max.presence.try(:to_i)
last_directo = [Setting.directo_monthly_number_last.presence.try(:to_i),
min_directo].compact.max || 0
return true if max_directo && max_directo < (last_directo + invoice_count)
false
end
end

View file

@ -3,11 +3,10 @@ class SendEInvoiceJob < ApplicationJob
def perform(invoice_id, payable: true)
logger.info "Started to process e-invoice for invoice_id #{invoice_id}"
invoice = Invoice.find_by(id: invoice_id)
invoice = Invoice.find(invoice_id)
return unless need_to_process_invoice?(invoice: invoice, payable: payable)
send_invoice_to_eis_billing(invoice: invoice, payable: payable)
invoice.update(e_invoice_sent_at: Time.zone.now)
rescue StandardError => e
log_error(invoice: invoice, error: e)
raise e

View file

@ -1,147 +1,71 @@
class SendMonthlyInvoicesJob < ApplicationJob # rubocop:disable Metrics/ClassLength
queue_as :default
discard_on StandardError
def perform(dry: false)
def perform(dry: false, months_ago: 1)
@dry = dry
@month = Time.zone.now - 1.month
@directo_client = new_directo_client
@min_directo_num = Setting.directo_monthly_number_min.presence.try(:to_i)
@max_directo_num = Setting.directo_monthly_number_max.presence.try(:to_i)
@month = Time.zone.now - months_ago.month
@directo_data = []
send_monthly_invoices
end
def new_directo_client
DirectoApi::Client.new(ENV['directo_invoice_url'], Setting.directo_sales_agent,
Setting.directo_receipt_payment_term)
end
# rubocop:disable Metrics/MethodLength
def send_monthly_invoices
Registrar.with_cash_accounts.find_each do |registrar|
summary = registrar.monthly_summary(month: @month)
next if summary.nil?
invoices = find_or_init_monthly_invoices
assign_invoice_numbers(invoices)
return if invoices.empty? || @dry
invoice = registrar.monthly_invoice(month: @month) || create_invoice(summary, registrar)
next if invoice.nil? || @dry
send_email_to_registrar(invoice: invoice, registrar: registrar)
send_e_invoice(invoice.id)
next if invoice.in_directo
Rails.logger.info("[DIRECTO] Trying to send monthly invoice #{invoice.number}")
@directo_client = new_directo_client
directo_invoices = @directo_client.invoices.add_with_schema(invoice: summary,
schema: 'summary')
next unless directo_invoices.size.positive?
directo_invoices.last.number = invoice.number
sync_with_directo
invoices.each do |inv|
inv.send_to_registrar! unless inv.sent?
send_e_invoice(inv.id)
@directo_data << inv.as_monthly_directo_json unless inv.in_directo
end
return if @directo_data.empty?
EisBilling::SendDataToDirecto.send_request(object_data: @directo_data,
monthly: true)
end
def assign_invoice_numbers(invoices)
invoice_without_numbers = invoices.select { |i| i.number.nil? }
return if invoice_without_numbers.empty?
result = EisBilling::GetMonthlyInvoiceNumbers.send_request(invoice_without_numbers.size)
response = JSON.parse(result.body)
billing_restrictions_issue if response['code'] == '403'
billing_out_of_range_issue if response['error'] == 'out of range'
numbers = response['invoice_numbers']
invoice_without_numbers.each_with_index do |inv, index|
inv.number = numbers[index]
next if inv.save
Rails.logger.info 'There was an error creating monthly ' \
"invoice #{inv.number}: #{inv.errors.full_messages.first}"
end
end
# rubocop:enable Metrics/MethodLength
def send_email_to_registrar(invoice:, registrar:)
InvoiceMailer.invoice_email(invoice: invoice,
recipient: registrar.billing_email)
.deliver_now
def find_or_init_monthly_invoices(invoices: [])
Registrar.with_cash_accounts.find_each do |registrar|
invoice = registrar.find_or_init_monthly_invoice(month: @month)
invoices << invoice unless invoice.nil?
end
invoices
end
def send_e_invoice(invoice_id)
SendEInvoiceLegacyJob.set(wait: 1.minute).perform_later(invoice_id, payable: false)
end
def create_invoice(summary, registrar)
invoice = registrar.init_monthly_invoice(normalize(summary))
invoice.number = assign_monthly_number
return unless invoice.save!
update_monthly_invoice_number(num: invoice.number)
invoice
end
def sync_with_directo
invoices_xml = @directo_client.invoices.as_xml
Rails.logger.info("[Directo] - attempting to send following XML:\n #{invoices_xml}")
res = @directo_client.invoices.deliver(ssl_verify: false)
process_directo_response(res.body, invoices_xml)
rescue SocketError, Errno::ECONNREFUSED, Timeout::Error, Errno::EINVAL, Errno::ECONNRESET,
EOFError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError
Rails.logger.info('[Directo] Failed to communicate via API')
end
def assign_monthly_number
last_directo_num = [Setting.directo_monthly_number_last.presence.try(:to_i),
@min_directo_num].compact.max || 0
raise 'Directo Counter is out of period!' if directo_counter_exceedable?(1, last_directo_num)
last_directo_num + 1
end
def directo_counter_exceedable?(invoices_count, last_directo_num)
return true if @max_directo_num && @max_directo_num < (last_directo_num + invoices_count)
false
end
def process_directo_response(body, req)
Rails.logger.info "[Directo] - Responded with body: #{body}"
Nokogiri::XML(body).css('Result').each do |res|
inv = Invoice.find_by(number: res.attributes['docid'].value.to_i)
mark_invoice_as_sent_to_directo(res: res, req: req, invoice: inv)
end
end
def mark_invoice_as_sent_to_directo(res:, req:, invoice: nil)
directo_record = Directo.new(response: res.as_json.to_h,
request: req, invoice_number: res.attributes['docid'].value.to_i)
directo_record.item = invoice
invoice.update(in_directo: true)
directo_record.save!
end
def update_monthly_invoice_number(num:)
return unless num.to_i > Setting.directo_monthly_number_last.to_i
Setting.directo_monthly_number_last = num.to_i
SendEInvoiceJob.set(wait: 30.seconds).perform_later(invoice_id, payable: false)
end
private
# rubocop:disable Metrics/MethodLength
def normalize(summary, lines: [])
sum = summary.dup
line_map = Hash.new 0
sum['invoice_lines'].each { |l| line_map[l] += 1 }
line_map.each_key do |count|
count['quantity'] = line_map[count] unless count['unit'].nil?
regex = /Domeenide ettemaks|Domains prepayment/
count['quantity'] = -1 if count['description'].match?(regex)
lines << count
end
sum['invoice_lines'] = summarize_lines(lines)
sum
def billing_out_of_range_issue
raise 'INVOICE NUMBER LIMIT REACHED, COULD NOT GENERATE INVOICE'
end
# rubocop:enable Metrics/MethodLength
def summarize_lines(invoice_lines, lines: [])
line_map = Hash.new 0
invoice_lines.each do |l|
hash = l.with_indifferent_access.except(:start_date, :end_date)
line_map[hash] += 1
end
line_map.each_key do |count|
count['price'] = (line_map[count] * count['price'].to_f).round(3) unless count['price'].nil?
lines << count
end
lines
def billing_restrictions_issue
raise 'PROBLEM WITH TOKEN'
end
end

View file

@ -4,6 +4,7 @@ module Invoice::BookKeeping
def as_directo_json
invoice = ActiveSupport::JSON.decode(ActiveSupport::JSON.encode(self))
invoice['customer'] = compose_directo_customer
invoice['date'] = issue_date.strftime('%Y-%m-%d')
invoice['issue_date'] = issue_date.strftime('%Y-%m-%d')
invoice['transaction_date'] = account_activity
.bank_transaction&.paid_at&.strftime('%Y-%m-%d')
@ -13,6 +14,47 @@ module Invoice::BookKeeping
invoice
end
def as_monthly_directo_json
invoice = as_json(only: %i[issue_date due_date created_at
vat_rate description number currency])
invoice['customer'] = compose_directo_customer
invoice['date'] = issue_date.strftime('%Y-%m-%d')
invoice['language'] = buyer.language == 'en' ? 'ENG' : ''
invoice['invoice_lines'] = compose_monthly_directo_lines
invoice
end
private
def compose_monthly_directo_lines(lines: [])
metadata['items'].each do |item|
quantity = item['quantity']
duration = item['duration_in_years']
lines << item and next if !quantity || quantity&.negative?
divide_by_quantity_and_years(quantity, duration, item, lines)
end
lines.as_json
end
# rubocop:disable Metrics/MethodLength
def divide_by_quantity_and_years(quantity, duration, item, lines)
quantity.times do
single_item = item.except('duration_in_years').merge('quantity' => 1)
lines << single_item and next if duration < 1
duration.times do |dur|
single_item_dup = single_item.dup
single_item_dup['start_date'] = (issue_date + dur.year).end_of_month.strftime('%Y-%m-%d')
single_item_dup['end_date'] = (issue_date + (dur + 1).year).end_of_month.strftime('%Y-%m-%d')
single_item_dup['price'] = (item['price'].to_f / duration).round(2)
lines << single_item_dup
end
end
end
# rubocop:enable Metrics/MethodLength
def compose_directo_product
[{ 'product_id': Setting.directo_receipt_product_name, 'description': order,
'quantity': 1, 'price': ActionController::Base.helpers.number_with_precision(

View file

@ -13,83 +13,64 @@ module Registrar::BookKeeping # rubocop:disable Metrics/ModuleLength
end
def monthly_summary(month:)
activities = monthly_activites(month)
return unless activities.any?
invoice_lines = prepare_invoice_lines(month: month)
return unless invoice_lines
invoice = {
'number': 1, 'customer': compose_directo_customer,
'language': language == 'en' ? 'ENG' : '', 'currency': activities.first.currency,
'date': month.end_of_month.strftime('%Y-%m-%d')
'date': month.end_of_month.strftime('%Y-%m-%d'),
'description': title_for_summary(month),
}.as_json
invoice['invoice_lines'] = prepare_invoice_lines(month: month, activities: activities)
invoice['invoice_lines'] = invoice_lines
invoice
end
def prepare_invoice_lines(month:, activities:)
lines = []
def prepare_invoice_lines(month:, lines: [])
activities = monthly_activities(month)
return if activities.empty?
lines << { 'description': title_for_summary(month) }
activities.each do |activity|
fetch_invoice_lines(activity, lines)
lines << new_monthly_invoice_line(activity)
end
lines.sort_by! { |k, _v| k['product_id'] }
lines.sort_by! { |k, _v| k['duration_in_years'] }
lines.unshift({ 'description': title_for_summary(month) })
lines << prepayment_for_all(lines)
lines.as_json
end
def find_or_init_monthly_invoice(month:)
invoice = invoices.find_by(monthly_invoice: true, issue_date: month.end_of_month.to_date)
return invoice if invoice
summary = monthly_summary(month: month)
return unless summary
init_monthly_invoice(summary)
end
def title_for_summary(date)
I18n.with_locale(language == 'en' ? 'en' : 'et') do
I18n.t('registrar.monthly_summary_title', date: I18n.l(date, format: '%B %Y'))
end
end
def fetch_invoice_lines(activity, lines)
price = load_price(activity)
if price.duration.in_years.to_i >= 1
price.duration.in_years.to_i.times do |duration|
lines << new_monthly_invoice_line(activity: activity, duration: duration + 1).as_json
end
else
lines << new_monthly_invoice_line(activity: activity).as_json
end
end
def monthly_activites(month)
def monthly_activities(month)
AccountActivity.where(account_id: account_ids)
.where(created_at: month.beginning_of_month..month.end_of_month)
.where(activity_type: [AccountActivity::CREATE, AccountActivity::RENEW])
end
def monthly_invoice(month:)
invoices.where(monthly_invoice: true, issue_date: month.end_of_month.to_date,
cancelled_at: nil).first
end
def new_monthly_invoice_line(activity:, duration: nil)
def new_monthly_invoice_line(activity)
price = load_price(activity)
line = {
duration = price.duration.in_years.to_i
{
'product_id': DOMAIN_TO_PRODUCT[price.zone_name.to_sym],
'quantity': 1,
'unit': language == 'en' ? 'pc' : 'tk',
'price': price.price.amount.to_f,
'duration_in_years': duration,
'description': description_in_language(price: price, yearly: duration >= 1),
}.with_indifferent_access
finalize_invoice_line(line, price: price, duration: duration, activity: activity)
end
def finalize_invoice_line(line, price:, activity:, duration:)
yearly = price.duration.in_years.to_i >= 1
line['price'] = yearly ? (price.price.amount / price.duration.in_years.to_i).to_f : price.price.amount.to_f
line['description'] = description_in_language(price: price, yearly: yearly)
add_product_timeframe(line: line, activity: activity, duration: duration) if duration.present? && (duration > 1)
line
end
def add_product_timeframe(line:, activity:, duration:)
create_time = activity.created_at
line['start_date'] = (create_time + (duration - 1).year).end_of_month.strftime('%Y-%m-%d')
line['end_date'] = (create_time + duration.year).end_of_month.strftime('%Y-%m-%d')
end
def description_in_language(price:, yearly:)
@ -115,14 +96,6 @@ module Registrar::BookKeeping # rubocop:disable Metrics/ModuleLength
}
end
def compose_directo_customer
{
'code': accounting_customer_code,
'destination': address_country_code,
'vat_reg_no': vat_no,
}.as_json
end
def load_price(account_activity)
@pricelists ||= {}
return @pricelists[account_activity.price_id] if @pricelists.key? account_activity.price_id

View file

@ -33,13 +33,13 @@ class Invoice < ApplicationRecord
# rubocop:enable Style/MultilineBlockLayout
validates :due_date, :currency, :seller_name,
:seller_iban, :buyer_name, presence: true
validates :items, presence: true, unless: -> { monthly_invoice }
validates :items, presence: true, unless: [:monthly_invoice]
before_create :set_invoice_number
before_create :set_invoice_number, unless: [:monthly_invoice]
before_create :calculate_total, unless: :total?
before_create :apply_default_buyer_vat_no, unless: :buyer_vat_no?
skip_callback :create, :before, :set_invoice_number, if: -> { monthly_invoice }
skip_callback :create, :before, :calculate_total, if: -> { monthly_invoice }
skip_callback :create, :before, :calculate_total, if: [:monthly_invoice]
attribute :vat_rate, ::Type::VatRate.new
@ -69,6 +69,17 @@ class Invoice < ApplicationRecord
self.number = JSON.parse(result.body)['invoice_number'].to_i
end
def send_to_registrar!
InvoiceMailer.invoice_email(invoice: self,
recipient: buyer_email)
.deliver_now
update(sent_at: Time.zone.now)
end
def sent?
sent_at.present?
end
def to_s
I18n.t('invoice_no', no: number)
end

View file

@ -62,7 +62,7 @@ class Registrar < ApplicationRecord # rubocop:disable Metrics/ClassLength
issue_date: summary['date'].to_date,
due_date: summary['date'].to_date,
currency: 'EUR',
description: I18n.t('invoice.monthly_invoice_description'),
description: summary['description'],
seller_name: Setting.registry_juridical_name,
seller_reg_no: Setting.registry_reg_no,
seller_iban: Setting.registry_iban,
@ -88,11 +88,11 @@ class Registrar < ApplicationRecord # rubocop:disable Metrics/ClassLength
buyer_zip: address_zip,
buyer_phone: phone,
buyer_url: website,
buyer_email: email,
buyer_email: billing_email,
reference_no: reference_no,
vat_rate: calculate_vat_rate,
monthly_invoice: true,
metadata: { items: summary['invoice_lines'] },
metadata: { items: remove_line_duplicates(summary['invoice_lines']) },
total: 0
)
end
@ -128,7 +128,7 @@ class Registrar < ApplicationRecord # rubocop:disable Metrics/ClassLength
buyer_zip: address_zip,
buyer_phone: phone,
buyer_url: website,
buyer_email: email,
buyer_email: billing_email,
reference_no: reference_no,
vat_rate: calculate_vat_rate,
items_attributes: [
@ -312,4 +312,16 @@ class Registrar < ApplicationRecord # rubocop:disable Metrics/ClassLength
def calculate_vat_rate
::Invoice::VatRateCalculator.new(registrar: self).calculate
end
def remove_line_duplicates(invoice_lines, lines: [])
line_map = Hash.new 0
invoice_lines.each { |l| line_map[l] += 1 }
line_map.each_key do |line|
line['quantity'] = line_map[line] unless line['unit'].nil? || line['quantity']&.negative?
lines << line
end
lines
end
end

View file

@ -0,0 +1,15 @@
module EisBilling
class GetMonthlyInvoiceNumbers < EisBilling::Base
def self.send_request(count)
prepared_data = {
count: count,
}
http = EisBilling::Base.base_request(url: invoice_numbers_generator_url)
http.post(invoice_numbers_generator_url, prepared_data.to_json, EisBilling::Base.headers)
end
def self.invoice_numbers_generator_url
"#{BASE_URL}/api/v1/invoice_generator/monthly_invoice_numbers_generator"
end
end
end

View file

@ -1,6 +1,6 @@
module EisBilling
class SendDataToDirecto < EisBilling::Base
def self.send_request(object_data:, monthly:, dry:)
def self.send_request(object_data:, monthly: false, dry: false)
send_info(object_data: object_data, monthly: monthly, dry: dry)
end

View file

@ -5,35 +5,34 @@ module EisBilling
end
def self.send_info(invoice:, payable:)
items = []
prepared_data = prepare_data(invoice: invoice, payable: payable)
invoice.items.each do |invoice_item|
items << prepare_item(invoice_item)
end
prepared_data[:items] = items
http = EisBilling::Base.base_request(url: e_invoice_url)
http.post(e_invoice_url, prepared_data.to_json, EisBilling::Base.headers)
end
def self.prepare_item(invoice_item)
{
description: invoice_item.description,
price: invoice_item.price,
quantity: invoice_item.quantity,
unit: invoice_item.unit,
subtotal: invoice_item.subtotal,
vat_rate: invoice_item.vat_rate,
vat_amount: invoice_item.vat_amount,
total: invoice_item.total,
}
def self.prepare_items(invoice)
if invoice.monthly_invoice
invoice.metadata['items']
else
invoice.items.map do |invoice_item|
{
description: invoice_item.description,
price: invoice_item.price,
quantity: invoice_item.quantity,
unit: invoice_item.unit,
subtotal: invoice_item.subtotal,
vat_rate: invoice_item.vat_rate,
vat_amount: invoice_item.vat_amount,
total: invoice_item.total,
}
end
end
end
def self.prepare_data(invoice:, payable:)
{
invoice: invoice,
invoice: invoice.as_json,
vat_amount: invoice.vat_amount,
invoice_subtotal: invoice.subtotal,
buyer_billing_email: invoice.buyer.billing_email,
@ -42,6 +41,7 @@ module EisBilling
buyer_country_code: invoice.buyer_country_code,
payable: payable,
initiator: EisBilling::Base::INITIATOR,
items: prepare_items(invoice),
}
end

View file

@ -208,19 +208,17 @@
%table.table.table-hover.table-condensed
%thead
%tr
%th{class: 'col-xs-1'}= t(:code)
%th{class: 'col-xs-1'}= InvoiceItem.human_attribute_name :quantity
%th{class: 'col-xs-1'}= t(:unit)
%th{class: 'col-xs-5'}= t(:description)
%th{class: 'col-xs-1'}= t(:unit)
%th{class: 'col-xs-1'}= InvoiceItem.human_attribute_name :quantity
%th{class: 'col-xs-2'}= t(:price)
%th{class: 'col-xs-2'}= t(:total)
%tbody
- @invoice.each do |invoice_item|
%tr
%td= invoice_item.product_id
%td= invoice_item.quantity
%td= invoice_item.unit
%td= invoice_item.description
%td= invoice_item.unit
%td= invoice_item.quantity
- if invoice_item.price && invoice_item.quantity
%td= currency(invoice_item.price)
%td= "#{currency((invoice_item.price * invoice_item.quantity).round(3))} #{@invoice.currency}"
@ -229,15 +227,15 @@
%td= ''
%tfoot
%tr
%th{colspan: 4}
%th{colspan: 3}
%th= Invoice.human_attribute_name :subtotal
%td= number_to_currency(0)
%tr
%th.no-border{colspan: 4}
%th.no-border{colspan: 3}
%th= "VAT #{number_to_percentage(@invoice.vat_rate, precision: 1)}"
%td= number_to_currency(0)
%tr
%th.no-border{colspan: 4}
%th.no-border{colspan: 3}
%th= t(:total)
%td= number_to_currency(0)

View file

@ -24,8 +24,9 @@
- else
%dd{class: 'text-danger'}= t(:unpaid)
%dt= t(:payment_term)
%dd Prepayment
- unless @invoice.monthly_invoice
%dt= t(:payment_term)
%dd Prepayment
%dt= t(:invoice_number)
%dd= @invoice.number

View file

@ -4,19 +4,17 @@
%table.table.table-hover.table-condensed
%thead
%tr
%th{class: 'col-xs-1'}= t(:code)
%th{class: 'col-xs-1'}= InvoiceItem.human_attribute_name :quantity
%th{class: 'col-xs-1'}= t(:unit)
%th{class: 'col-xs-5'}= t(:description)
%th{class: 'col-xs-1'}= t(:unit)
%th{class: 'col-xs-1'}= InvoiceItem.human_attribute_name :quantity
%th{class: 'col-xs-2'}= t(:price)
%th{class: 'col-xs-2'}= t(:total)
%tbody
- @invoice.each do |invoice_item|
%tr
%td= invoice_item.product_id
%td= invoice_item.quantity
%td= invoice_item.unit
%td= invoice_item.description
%td= invoice_item.unit
%td= invoice_item.quantity
- if invoice_item.price && invoice_item.quantity
%td= currency(invoice_item.price)
%td= "#{currency((invoice_item.price * invoice_item.quantity).round(3))} #{@invoice.currency}"
@ -25,14 +23,14 @@
%td= ''
%tfoot
%tr
%th{colspan: 4}
%th{colspan: 3}
%th= Invoice.human_attribute_name :subtotal
%td= number_to_currency(0)
%tr
%th.no-border{colspan: 4}
%th.no-border{colspan: 3}
%th= "VAT #{number_to_percentage(@invoice.vat_rate, precision: 1)}"
%td= number_to_currency(0)
%tr
%th.no-border{colspan: 4}
%th.no-border{colspan: 3}
%th= t(:total)
%td= number_to_currency(0)