diff --git a/app/jobs/delete_monthly_invoices_job.rb b/app/jobs/delete_monthly_invoices_job.rb new file mode 100644 index 000000000..8a38d0d14 --- /dev/null +++ b/app/jobs/delete_monthly_invoices_job.rb @@ -0,0 +1,10 @@ +class DeleteMonthlyInvoicesJob < ApplicationJob + queue_as :default + + def perform + @month = Time.zone.now - 1.month + invoices = Invoice.where(monthly_invoice: true, issue_date: @month.end_of_month.to_date, + in_directo: false, e_invoice_sent_at: nil) + invoices.delete_all + end +end \ No newline at end of file diff --git a/app/jobs/send_e_invoice_job.rb b/app/jobs/send_e_invoice_job.rb index 33a2745c6..4e77926b0 100644 --- a/app/jobs/send_e_invoice_job.rb +++ b/app/jobs/send_e_invoice_job.rb @@ -17,7 +17,7 @@ class SendEInvoiceJob < ApplicationJob def need_to_process_invoice?(invoice:, payable:) logger.info "Checking if need to process e-invoice #{invoice}, payable: #{payable}" return false if invoice.blank? - return false if invoice.do_not_send_e_invoice? && payable + return false if invoice.do_not_send_e_invoice? && (invoice.monthly_invoice ? true : payable) true end diff --git a/app/jobs/send_monthly_invoices_job.rb b/app/jobs/send_monthly_invoices_job.rb new file mode 100644 index 000000000..ddb4bd80c --- /dev/null +++ b/app/jobs/send_monthly_invoices_job.rb @@ -0,0 +1,155 @@ +class SendMonthlyInvoicesJob < ApplicationJob + queue_as :default + + def perform(dry: false) + @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) + + 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 + + def send_monthly_invoices + Registrar.where.not(test_registrar: true).find_each do |registrar| + next unless registrar.cash_account + + summary = registrar.monthly_summary(month: @month) + next if summary.nil? + + invoice = registrar.monthly_invoice(month: @month) || create_invoice(summary, registrar) + next if invoice.nil? || @dry + + InvoiceMailer.invoice_email(invoice: invoice, + recipient: registrar.billing_email) + .deliver_now + + SendEInvoiceJob.set(wait: 1.minute).perform_now(invoice.id, payable: false) + + 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 + end + end + + def create_invoice(summary, registrar) + vat_rate = ::Invoice::VatRateCalculator.new(registrar: registrar).calculate + invoice = Invoice.new( + number: assign_monthly_number, + issue_date: summary['date'].to_date, + due_date: summary['date'].to_date, + currency: 'EUR', + description: I18n.t('invoice.monthly_invoice_description'), + seller_name: Setting.registry_juridical_name, + seller_reg_no: Setting.registry_reg_no, + seller_iban: Setting.registry_iban, + seller_bank: Setting.registry_bank, + seller_swift: Setting.registry_swift, + seller_vat_no: Setting.registry_vat_no, + seller_country_code: Setting.registry_country_code, + seller_state: Setting.registry_state, + seller_street: Setting.registry_street, + seller_city: Setting.registry_city, + seller_zip: Setting.registry_zip, + seller_phone: Setting.registry_phone, + seller_url: Setting.registry_url, + seller_email: Setting.registry_email, + seller_contact_name: Setting.registry_invoice_contact, + buyer: registrar, + buyer_name: registrar.name, + buyer_reg_no: registrar.reg_no, + buyer_country_code: registrar.address_country_code, + buyer_state: registrar.address_state, + buyer_street: registrar.address_street, + buyer_city: registrar.address_city, + buyer_zip: registrar.address_zip, + buyer_phone: registrar.phone, + buyer_url: registrar.website, + buyer_email: registrar.email, + reference_no: registrar.reference_no, + vat_rate: vat_rate, + monthly_invoice: true, + metadata: { items: summary['invoice_lines'] }, + total: 0 + ) + return unless invoice.save! + + update_directo_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_numbers + invoices_count = @directo_client.invoices.count + 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?(invoices_count, + last_directo_num) + + @directo_client.invoices.each do |inv| + last_directo_num += 1 + inv.number = last_directo_num + end + 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(res: res, req: req, invoice: inv) + end + end + + def mark_invoice_as_sent(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_directo_number(num:) + return unless num.to_i > Setting.directo_monthly_number_last.to_i + + Setting.directo_monthly_number_last = num.to_i + end +end diff --git a/app/mailers/invoice_mailer.rb b/app/mailers/invoice_mailer.rb index a9d544d63..95b7fefd6 100644 --- a/app/mailers/invoice_mailer.rb +++ b/app/mailers/invoice_mailer.rb @@ -4,6 +4,7 @@ class InvoiceMailer < ApplicationMailer subject = default_i18n_subject(invoice_number: invoice.number) subject << I18n.t('invoice.already_paid') if paid + subject << I18n.t('invoice.monthly_invoice') if invoice.monthly_invoice attachments["invoice-#{invoice.number}.pdf"] = invoice.as_pdf mail(to: recipient, subject: subject) end diff --git a/app/models/concerns/registrar/book_keeping.rb b/app/models/concerns/registrar/book_keeping.rb index 76535ce12..4431df9b8 100644 --- a/app/models/concerns/registrar/book_keeping.rb +++ b/app/models/concerns/registrar/book_keeping.rb @@ -55,6 +55,11 @@ module Registrar::BookKeeping .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) price = load_price(activity) line = { @@ -68,7 +73,7 @@ module Registrar::BookKeeping 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) : price.price.amount + 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) diff --git a/app/models/invoice.rb b/app/models/invoice.rb index b7e60abfb..327a107cd 100644 --- a/app/models/invoice.rb +++ b/app/models/invoice.rb @@ -32,11 +32,14 @@ class Invoice < ApplicationRecord # rubocop:enable Layout/LineLength # rubocop:enable Style/MultilineBlockLayout validates :due_date, :currency, :seller_name, - :seller_iban, :buyer_name, :items, presence: true + :seller_iban, :buyer_name, presence: true + validates :items, presence: true, unless: -> { monthly_invoice } before_create :set_invoice_number 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 } attribute :vat_rate, ::Type::VatRate.new @@ -118,7 +121,7 @@ class Invoice < ApplicationRecord end def subtotal - items.map(&:item_sum_without_vat).reduce(:+) + items.map(&:item_sum_without_vat).reduce(:+) || 0 end def vat_amount @@ -131,7 +134,11 @@ class Invoice < ApplicationRecord end def each(&block) - items.each(&block) + if monthly_invoice + metadata['items'].map { |el| OpenStruct.new(el) }.each(&block) + else + items.each(&block) + end end def as_pdf diff --git a/app/models/invoice/e_invoice_generator.rb b/app/models/invoice/e_invoice_generator.rb index 2361656a7..28373b1fb 100644 --- a/app/models/invoice/e_invoice_generator.rb +++ b/app/models/invoice/e_invoice_generator.rb @@ -46,10 +46,17 @@ class Invoice i.price = invoice_item.price i.quantity = invoice_item.quantity i.unit = invoice_item.unit - i.subtotal = invoice_item.subtotal - i.vat_rate = invoice_item.vat_rate - i.vat_amount = invoice_item.vat_amount - i.total = invoice_item.total + if invoice.monthly_invoice + i.subtotal = 0 + i.vat_rate = 0 + i.vat_amount = 0 + i.total = 0 + else + i.subtotal = invoice_item.subtotal + i.vat_rate = invoice_item.vat_rate + i.vat_amount = invoice_item.vat_amount + i.total = invoice_item.total + end end e_invoice_invoice_items << e_invoice_invoice_item end @@ -66,9 +73,15 @@ class Invoice i.beneficiary_name = invoice.seller_name i.beneficiary_account_number = invoice.seller_iban i.payer_name = invoice.buyer_name - i.subtotal = invoice.subtotal - i.vat_amount = invoice.vat_amount - i.total = invoice.total + if invoice.monthly_invoice + i.subtotal = 0 + i.vat_amount = 0 + i.total = 0 + else + i.subtotal = invoice.subtotal + i.vat_amount = invoice.vat_amount + i.total = invoice.total + end i.currency = invoice.currency i.delivery_channel = %i[internet_bank portal] i.payable = payable diff --git a/app/models/invoice/pdf_generator.rb b/app/models/invoice/pdf_generator.rb index 14fb99814..7762456c4 100644 --- a/app/models/invoice/pdf_generator.rb +++ b/app/models/invoice/pdf_generator.rb @@ -14,7 +14,8 @@ class Invoice private def invoice_html - ApplicationController.render(template: 'invoice/pdf', assigns: { invoice: invoice }) + template = invoice.monthly_invoice ? 'invoice/monthly_pdf' : 'invoice/pdf' + ApplicationController.render(template: template, assigns: { invoice: invoice }) end end end diff --git a/app/views/admin/invoices/show.haml b/app/views/admin/invoices/show.haml index b121c8337..f64bd9cc4 100644 --- a/app/views/admin/invoices/show.haml +++ b/app/views/admin/invoices/show.haml @@ -4,11 +4,12 @@ = @invoice .col-sm-8 %h1.text-right.text-center-xs - - if @invoice.unpaid? - = link_to(t(:payment_received), new_admin_bank_statement_path(invoice_id: @invoice.id), class: 'btn btn-default') + - unless @invoice.monthly_invoice + - if @invoice.unpaid? + = link_to(t(:payment_received), new_admin_bank_statement_path(invoice_id: @invoice.id), class: 'btn btn-default') - - if @invoice.paid? and !@invoice.cancelled? - = link_to(t(:cancel_payment), cancel_paid_admin_invoices_path(invoice_id: @invoice.id), method: 'post', data: { confirm: t(:are_you_sure) }, class: 'btn btn-warning') + - if @invoice.paid? && !@invoice.cancelled? + = link_to(t(:cancel_payment), cancel_paid_admin_invoices_path(invoice_id: @invoice.id), method: 'post', data: { confirm: t(:are_you_sure) }, class: 'btn btn-warning') = link_to(t('.download_btn'), download_admin_invoice_path(@invoice), class: 'btn btn-default') = link_to(t('.deliver_btn'), new_admin_invoice_delivery_path(@invoice), class: 'btn btn-default') diff --git a/app/views/invoice/monthly_pdf.haml b/app/views/invoice/monthly_pdf.haml new file mode 100644 index 000000000..c7e179d03 --- /dev/null +++ b/app/views/invoice/monthly_pdf.haml @@ -0,0 +1,277 @@ +%html{lang: I18n.locale.to_s} + %head + %meta{charset: "utf-8"} + :css + .container { + margin: auto; + font-size: 12px; + } + + .col-md-12 { + + } + + .col-md-6 { + width: 49%; + display: inline-block; + } + + .col-xs-4 { + width: 33%; + } + + .col-xs-2 { + width: 16%; + } + + .col-md-3 { + width: 24%; + display: inline-block; + } + + .left { + float: left; + } + + .left { + padding-right: 5px; + } + + .right { + float: right; + } + + .text-right { + text-align: right; + } + + dt { + float: left; + width: 100px; + clear: left; + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: bold; + line-height: 1.42857; + } + + dd { + margin-left: 120px; + line-height: 1.42857; + } + + table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + } + + th { + text-align: left; + border: 0px; + border-top: 1px solid #DDD; + padding: 6px; + } + + thead th { + border-bottom: 2px solid #DDD; + border-top: 0px; + } + + td { + border-top: 1px solid #DDD; + } + + td { + padding: 6px; + } + + .no-border { + border: 0px; + } + + hr { + height: 1px; + border: 0; + color: #DDD; + background-color: #DDD; + } + + .clear { + clear: both; + } + + .pull-down { + margin-top: 50px; + } + + #header { + position: relative; + min-height: 100px; + } + + img { + width: 106px; + height: 102px; + } + + #header-content { + position: absolute; + bottom: 0; + } + + #footer { + position: absolute; + bottom: 0px; + width: 99%; + } + + h1 { + margin-bottom: 5px; + } + %body + .container + #header.row + .col-sm-6.left + #header-content + %h1 + = @invoice + .col-sm-6.right + %img{src: "#{Rails.root}/public/eis-logo-black-et.png"} + .clear + %hr + .row + .col-md-6.left + %h4 + Details + %hr + %dl.dl-horizontal + %dt= t(:issue_date) + %dd= l @invoice.issue_date + + - if @invoice.cancelled? + %dt= Invoice.human_attribute_name :cancelled_at + %dd= l @invoice.cancelled_at + + %dt= t(:due_date) + - if @invoice.cancelled? + %dd= t(:cancelled) + - else + %dd= l @invoice.due_date + + %dt= t(:issuer) + %dd= @invoice.seller_contact_name + + - if @invoice.description.present? + %dt= t(:description) + %dd=@invoice.description + + %dt= Invoice.human_attribute_name :reference_no + %dd= @invoice.reference_no + + .col-md-6.right + %h4= t(:client) + %hr + %dl.dl-horizontal + %dt= t(:name) + %dd= @invoice.buyer_name + + %dt= t(:reg_no) + %dd= @invoice.buyer_reg_no + + - if @invoice.buyer_address.present? + %dt= Invoice.human_attribute_name :address + %dd= @invoice.buyer_address + + - if @invoice.buyer_country.present? + %dt= t(:country) + %dd= @invoice.buyer_country + + - if @invoice.buyer_phone.present? + %dt= t(:phone) + %dd= @invoice.buyer_phone + + - if @invoice.buyer_url.present? + %dt= t(:url) + %dd= @invoice.buyer_url + + - if @invoice.buyer_email.present? + %dt= t(:email) + %dd= @invoice.buyer_email + + .clear + .row.pull-down + .col-md-12 + .table-responsive + %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-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 + - if invoice_item.price && invoice_item.quantity + %td= currency(invoice_item.price) + %td= "#{currency((invoice_item.price * invoice_item.quantity).round(3))} #{@invoice.currency}" + - else + %td= '' + %td= '' + %tfoot + %tr + %th{colspan: 4} + %th= Invoice.human_attribute_name :subtotal + %td= number_to_currency(0) + %tr + %th.no-border{colspan: 4} + %th= "VAT #{number_to_percentage(@invoice.vat_rate, precision: 1)}" + %td= number_to_currency(0) + %tr + %th.no-border{colspan: 4} + %th= t(:total) + %td= number_to_currency(0) + + #footer + %hr + .row + .col-md-3.left + = @invoice.seller_name + %br + = @invoice.seller_address + %br + = @invoice.seller_country + %br + = "#{t('reg_no')} #{@invoice.seller_reg_no}" + %br + = "#{Registrar.human_attribute_name :vat_no} #{@invoice.seller_vat_no}" + + .col-md-3.left + = @invoice.seller_phone + %br + = @invoice.seller_email + %br + = @invoice.seller_url + + .col-md-3.text-right.left + = t(:bank) + %br + = t(:iban) + %br + = t(:swift) + + .col-md-3.left + = @invoice.seller_bank + %br + = @invoice.seller_iban + %br + = @invoice.seller_swift \ No newline at end of file diff --git a/app/views/invoice/pdf.haml b/app/views/invoice/pdf.haml index 9f10acdad..dc3d4370a 100644 --- a/app/views/invoice/pdf.haml +++ b/app/views/invoice/pdf.haml @@ -234,7 +234,7 @@ %td= invoice_item.unit %td= invoice_item.quantity %td= currency(invoice_item.price) - %td= "#{currency(invoice_item.item_sum_without_vat)} #{@invoice.currency}" + %td= "#{currency(invoice_item.item_sum_without_vat)} #{@invoice.currency}" %tfoot %tr %th{colspan: 3} diff --git a/config/locales/en.yml b/config/locales/en.yml index ec8953a84..9021eff60 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -484,6 +484,8 @@ en: invoice: title: 'Invoice' already_paid: " (already paid)" + monthly_invoice: " (monthly invoice)" + monthly_invoice_description: 'Monthly invoice' bank_statements: 'Bank statements' back_to_bank_statements: 'Back to bank statements' back_to_bank_statement: 'Back to bank statement' diff --git a/config/locales/et.yml b/config/locales/et.yml index ad04db007..84672ed3c 100644 --- a/config/locales/et.yml +++ b/config/locales/et.yml @@ -9,3 +9,5 @@ et: invoice: title: 'Arve' already_paid: " (juba makstud)" + monthly_invoice: " (kuuaruanne)" + monthly_invoice_description: 'Kuuaruanne' diff --git a/db/migrate/20220818075833_add_monthly_invoice_type_columns.rb b/db/migrate/20220818075833_add_monthly_invoice_type_columns.rb new file mode 100644 index 000000000..33c98124c --- /dev/null +++ b/db/migrate/20220818075833_add_monthly_invoice_type_columns.rb @@ -0,0 +1,6 @@ +class AddMonthlyInvoiceTypeColumns < ActiveRecord::Migration[6.1] + def change + add_column :invoices, :monthly_invoice, :boolean, default: false + add_column :invoices, :metadata, :jsonb + end +end diff --git a/db/structure.sql b/db/structure.sql index a96474d53..6e4c145f1 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -955,14 +955,14 @@ CREATE TABLE public.domains ( pending_json jsonb, force_delete_date date, statuses character varying[], + status_notes public.hstore, upid integer, up_date timestamp without time zone, uuid uuid DEFAULT public.gen_random_uuid() NOT NULL, locked_by_registrant_at timestamp without time zone, force_delete_start timestamp without time zone, force_delete_data public.hstore, - json_statuses_history jsonb, - status_notes public.hstore + json_statuses_history jsonb ); @@ -985,6 +985,98 @@ CREATE SEQUENCE public.domains_id_seq ALTER SEQUENCE public.domains_id_seq OWNED BY public.domains.id; +-- +-- Name: email_address_verifications; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.email_address_verifications ( + id bigint NOT NULL, + email public.citext NOT NULL, + verified_at timestamp without time zone, + success boolean DEFAULT false NOT NULL, + domain public.citext NOT NULL +); + + +-- +-- Name: email_address_verifications_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.email_address_verifications_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: email_address_verifications_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.email_address_verifications_id_seq OWNED BY public.email_address_verifications.id; + + +-- +-- Name: email_addresses_validations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.email_addresses_validations ( + id bigint NOT NULL, + email character varying NOT NULL, + validated_at timestamp without time zone +); + + +-- +-- Name: email_addresses_validations_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.email_addresses_validations_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: email_addresses_validations_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.email_addresses_validations_id_seq OWNED BY public.email_addresses_validations.id; + + +-- +-- Name: email_addresses_verifications; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.email_addresses_verifications ( + id bigint NOT NULL, + email character varying NOT NULL, + validated_at timestamp without time zone +); + + +-- +-- Name: email_addresses_verifications_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.email_addresses_verifications_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: email_addresses_verifications_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.email_addresses_verifications_id_seq OWNED BY public.email_addresses_verifications.id; + + -- -- Name: epp_sessions; Type: TABLE; Schema: public; Owner: - -- @@ -1104,6 +1196,8 @@ CREATE TABLE public.invoices ( issue_date date NOT NULL, e_invoice_sent_at timestamp without time zone, payment_link character varying, + monthly_invoice boolean DEFAULT false, + metadata jsonb, CONSTRAINT invoices_due_date_is_not_before_issue_date CHECK ((due_date >= issue_date)) ); @@ -2190,6 +2284,74 @@ CREATE SEQUENCE public.payment_orders_id_seq ALTER SEQUENCE public.payment_orders_id_seq OWNED BY public.payment_orders.id; +-- +-- Name: pghero_query_stats; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.pghero_query_stats ( + id bigint NOT NULL, + database text, + "user" text, + query text, + query_hash bigint, + total_time double precision, + calls bigint, + captured_at timestamp without time zone +); + + +-- +-- Name: pghero_query_stats_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.pghero_query_stats_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: pghero_query_stats_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.pghero_query_stats_id_seq OWNED BY public.pghero_query_stats.id; + + +-- +-- Name: pghero_space_stats; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.pghero_space_stats ( + id bigint NOT NULL, + database text, + schema text, + relation text, + size bigint, + captured_at timestamp without time zone +); + + +-- +-- Name: pghero_space_stats_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.pghero_space_stats_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: pghero_space_stats_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.pghero_space_stats_id_seq OWNED BY public.pghero_space_stats.id; + + -- -- Name: prices; Type: TABLE; Schema: public; Owner: - -- @@ -2228,6 +2390,48 @@ CREATE SEQUENCE public.prices_id_seq ALTER SEQUENCE public.prices_id_seq OWNED BY public.prices.id; +-- +-- Name: que_jobs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.que_jobs ( + priority smallint DEFAULT 100 NOT NULL, + run_at timestamp with time zone DEFAULT now() NOT NULL, + job_id bigint NOT NULL, + job_class text NOT NULL, + args json DEFAULT '[]'::json NOT NULL, + error_count integer DEFAULT 0 NOT NULL, + last_error text, + queue text DEFAULT ''::text NOT NULL +); + + +-- +-- Name: TABLE que_jobs; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.que_jobs IS '3'; + + +-- +-- Name: que_jobs_job_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.que_jobs_job_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: que_jobs_job_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.que_jobs_job_id_seq OWNED BY public.que_jobs.job_id; + + -- -- Name: registrant_verifications; Type: TABLE; Schema: public; Owner: - -- @@ -2508,8 +2712,7 @@ CREATE TABLE public.validation_events ( validation_eventable_type character varying, validation_eventable_id bigint, created_at timestamp(6) without time zone NOT NULL, - updated_at timestamp(6) without time zone NOT NULL, - event_type public.validation_type + updated_at timestamp(6) without time zone NOT NULL ); @@ -2813,6 +3016,27 @@ ALTER TABLE ONLY public.domain_transfers ALTER COLUMN id SET DEFAULT nextval('pu ALTER TABLE ONLY public.domains ALTER COLUMN id SET DEFAULT nextval('public.domains_id_seq'::regclass); +-- +-- Name: email_address_verifications id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_address_verifications ALTER COLUMN id SET DEFAULT nextval('public.email_address_verifications_id_seq'::regclass); + + +-- +-- Name: email_addresses_validations id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_addresses_validations ALTER COLUMN id SET DEFAULT nextval('public.email_addresses_validations_id_seq'::regclass); + + +-- +-- Name: email_addresses_verifications id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_addresses_verifications ALTER COLUMN id SET DEFAULT nextval('public.email_addresses_verifications_id_seq'::regclass); + + -- -- Name: epp_sessions id; Type: DEFAULT; Schema: public; Owner: - -- @@ -3030,6 +3254,20 @@ ALTER TABLE ONLY public.notifications ALTER COLUMN id SET DEFAULT nextval('publi ALTER TABLE ONLY public.payment_orders ALTER COLUMN id SET DEFAULT nextval('public.payment_orders_id_seq'::regclass); +-- +-- Name: pghero_query_stats id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.pghero_query_stats ALTER COLUMN id SET DEFAULT nextval('public.pghero_query_stats_id_seq'::regclass); + + +-- +-- Name: pghero_space_stats id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.pghero_space_stats ALTER COLUMN id SET DEFAULT nextval('public.pghero_space_stats_id_seq'::regclass); + + -- -- Name: prices id; Type: DEFAULT; Schema: public; Owner: - -- @@ -3037,6 +3275,13 @@ ALTER TABLE ONLY public.payment_orders ALTER COLUMN id SET DEFAULT nextval('publ ALTER TABLE ONLY public.prices ALTER COLUMN id SET DEFAULT nextval('public.prices_id_seq'::regclass); +-- +-- Name: que_jobs job_id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.que_jobs ALTER COLUMN job_id SET DEFAULT nextval('public.que_jobs_job_id_seq'::regclass); + + -- -- Name: registrant_verifications id; Type: DEFAULT; Schema: public; Owner: - -- @@ -3274,6 +3519,30 @@ ALTER TABLE ONLY public.domains ADD CONSTRAINT domains_pkey PRIMARY KEY (id); +-- +-- Name: email_address_verifications email_address_verifications_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_address_verifications + ADD CONSTRAINT email_address_verifications_pkey PRIMARY KEY (id); + + +-- +-- Name: email_addresses_validations email_addresses_validations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_addresses_validations + ADD CONSTRAINT email_addresses_validations_pkey PRIMARY KEY (id); + + +-- +-- Name: email_addresses_verifications email_addresses_verifications_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_addresses_verifications + ADD CONSTRAINT email_addresses_verifications_pkey PRIMARY KEY (id); + + -- -- Name: epp_sessions epp_sessions_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -3522,6 +3791,22 @@ ALTER TABLE ONLY public.payment_orders ADD CONSTRAINT payment_orders_pkey PRIMARY KEY (id); +-- +-- Name: pghero_query_stats pghero_query_stats_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.pghero_query_stats + ADD CONSTRAINT pghero_query_stats_pkey PRIMARY KEY (id); + + +-- +-- Name: pghero_space_stats pghero_space_stats_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.pghero_space_stats + ADD CONSTRAINT pghero_space_stats_pkey PRIMARY KEY (id); + + -- -- Name: prices prices_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -3530,6 +3815,14 @@ ALTER TABLE ONLY public.prices ADD CONSTRAINT prices_pkey PRIMARY KEY (id); +-- +-- Name: que_jobs que_jobs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.que_jobs + ADD CONSTRAINT que_jobs_pkey PRIMARY KEY (queue, priority, run_at, job_id); + + -- -- Name: registrant_verifications registrant_verifications_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -3941,6 +4234,13 @@ CREATE INDEX index_domains_on_registrar_id ON public.domains USING btree (regist CREATE INDEX index_domains_on_statuses ON public.domains USING gin (statuses); +-- +-- Name: index_email_address_verifications_on_domain; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_email_address_verifications_on_domain ON public.email_address_verifications USING btree (domain); + + -- -- Name: index_epp_sessions_on_updated_at; Type: INDEX; Schema: public; Owner: - -- @@ -4277,6 +4577,20 @@ CREATE INDEX index_notifications_on_registrar_id ON public.notifications USING b CREATE INDEX index_payment_orders_on_invoice_id ON public.payment_orders USING btree (invoice_id); +-- +-- Name: index_pghero_query_stats_on_database_and_captured_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_pghero_query_stats_on_database_and_captured_at ON public.pghero_query_stats USING btree (database, captured_at); + + +-- +-- Name: index_pghero_space_stats_on_database_and_captured_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_pghero_space_stats_on_database_and_captured_at ON public.pghero_space_stats USING btree (database, captured_at); + + -- -- Name: index_prices_on_zone_id; Type: INDEX; Schema: public; Owner: - -- @@ -4333,13 +4647,6 @@ CREATE INDEX index_users_on_registrar_id ON public.users USING btree (registrar_ CREATE INDEX index_validation_events_on_event_data ON public.validation_events USING gin (event_data); --- --- Name: index_validation_events_on_event_type; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_validation_events_on_event_type ON public.validation_events USING btree (event_type); - - -- -- Name: index_validation_events_on_validation_eventable; Type: INDEX; Schema: public; Owner: - -- @@ -5085,9 +5392,11 @@ INSERT INTO "schema_migrations" (version) VALUES ('20210708131814'), ('20210729131100'), ('20210729134625'), -('20210827185249'), -('20211029073644'), +('20211028122103'), +('20211028125245'), +('20211029082225'), ('20211124071418'), +('20211124084308'), ('20211125181033'), ('20211125184334'), ('20211126085139'), @@ -5098,12 +5407,12 @@ INSERT INTO "schema_migrations" (version) VALUES ('20220124105717'), ('20220228093211'), ('20220316140727'), -('20220406085500'), ('20220412130856'), ('20220413073315'), ('20220413084536'), ('20220413084748'), ('20220504090512'), -('20220524130709'); +('20220524130709'), +('20220818075833'); diff --git a/lib/serializers/repp/invoice.rb b/lib/serializers/repp/invoice.rb index 3b8efbd58..686eaac99 100644 --- a/lib/serializers/repp/invoice.rb +++ b/lib/serializers/repp/invoice.rb @@ -23,7 +23,8 @@ module Serializers created_at: obj.created_at, updated_at: obj.updated_at, due_date: obj.due_date, currency: obj.currency, seller: seller, buyer: buyer, items: items, - recipient: obj.buyer.billing_email + recipient: obj.buyer.billing_email, + monthly_invoice: obj.monthly_invoice } end @@ -54,11 +55,15 @@ module Serializers end def items - invoice.items.map do |item| - { description: item.description, unit: item.unit, - quantity: item.quantity, price: item.price, - sum_without_vat: item.item_sum_without_vat, - vat_amount: item.vat_amount, total: item.total } + if invoice.monthly_invoice + invoice.metadata['items'] + else + invoice.items.map do |item| + { description: item.description, unit: item.unit, + quantity: item.quantity, price: item.price, + sum_without_vat: item.item_sum_without_vat, + vat_amount: item.vat_amount, total: item.total } + end end end @@ -75,6 +80,7 @@ module Serializers due_date: invoice.due_date, total: invoice.total, recipient: invoice.buyer.billing_email, + monthly_invoice: invoice.monthly_invoice, } end # rubocop:enable Metrics/MethodLength diff --git a/test/jobs/directo_invoice_forward_job_test.rb b/test/jobs/directo_invoice_forward_job_test.rb index 33a05f644..11ed657f6 100644 --- a/test/jobs/directo_invoice_forward_job_test.rb +++ b/test/jobs/directo_invoice_forward_job_test.rb @@ -8,9 +8,9 @@ class DirectoInvoiceForwardJobTest < ActiveSupport::TestCase end def teardown - Setting.directo_monthly_number_min = 309901 - Setting.directo_monthly_number_max = 309999 - Setting.directo_monthly_number_last = 309901 + Setting.directo_monthly_number_min = 309_901 + Setting.directo_monthly_number_max = 309_999 + Setting.directo_monthly_number_last = 309_901 end def test_directo_json_sends_customer_as_hash @@ -49,7 +49,7 @@ class DirectoInvoiceForwardJobTest < ActiveSupport::TestCase price = billing_prices(:create_one_year) activity.update!(activity_type: 'create', price: price) - Setting.directo_monthly_number_max = 30991 + Setting.directo_monthly_number_max = 30_991 assert_raises 'RuntimeError' do DirectoInvoiceForwardJob.perform_now(monthly: true, dry: false) diff --git a/test/jobs/send_monthly_invoices_job_test.rb b/test/jobs/send_monthly_invoices_job_test.rb new file mode 100644 index 000000000..38c6252f4 --- /dev/null +++ b/test/jobs/send_monthly_invoices_job_test.rb @@ -0,0 +1,265 @@ +require 'test_helper' + +class SendMonthlyInvoicesJobTest < ActiveSupport::TestCase + include ActionMailer::TestHelper + + setup do + @user = registrars(:bestnames) + @date = Time.zone.parse('2010-08-06') + travel_to @date + ActionMailer::Base.deliveries.clear + EInvoice.provider = EInvoice::Providers::TestProvider.new + EInvoice::Providers::TestProvider.deliveries.clear + end + + def teardown + Setting.directo_monthly_number_min = 309_901 + Setting.directo_monthly_number_max = 309_999 + Setting.directo_monthly_number_last = 309_901 + EInvoice.provider = EInvoice::Providers::TestProvider.new + EInvoice::Providers::TestProvider.deliveries.clear + end + + def test_fails_if_directo_bounds_exceedable + activity = account_activities(:one) + price = billing_prices(:create_one_year) + activity.update!(activity_type: 'create', price: price) + + Setting.directo_monthly_number_max = 30_991 + + assert_no_difference 'Directo.count' do + assert_raises 'RuntimeError' do + SendMonthlyInvoicesJob.perform_now + end + end + + assert_nil Invoice.find_by_monthly_invoice(true) + assert_emails 0 + assert_equal 0, EInvoice::Providers::TestProvider.deliveries.count + end + + def test_monthly_summary_is_not_delivered_if_dry + activity = account_activities(:one) + price = billing_prices(:create_one_year) + activity.update!(activity_type: 'create', price: price) + @user.update(language: 'et') + + assert_difference 'Setting.directo_monthly_number_last' do + assert_no_difference 'Directo.count' do + SendMonthlyInvoicesJob.perform_now(dry: true) + end + end + + invoice = Invoice.last + assert_equal 309_902, invoice.number + refute invoice.in_directo + assert invoice.e_invoice_sent_at.blank? + + assert_emails 0 + assert_equal 0, EInvoice::Providers::TestProvider.deliveries.count + end + + def test_monthly_summary_is_delivered_if_invoice_already_exists + @monthly_invoice = invoices(:one) + @monthly_invoice.update(number: 309_902, monthly_invoice: true, + issue_date: @date.last_month.end_of_month, + due_date: @date.last_month.end_of_month, + metadata: metadata, + in_directo: false, + e_invoice_sent_at: nil) + + activity = account_activities(:one) + price = billing_prices(:create_one_year) + activity.update!(activity_type: 'create', price: price) + @user.update(language: 'et') + + response = <<-XML + + + + + XML + + stub_request(:post, ENV['directo_invoice_url']).with do |request| + body = CGI.unescape(request.body) + + (body.include? '.test registreerimine: 1 aasta(t)') && + (body.include? 'Domeenide ettemaks') && + (body.include? '309902') + end.to_return(status: 200, body: response) + + assert_no_difference 'Setting.directo_monthly_number_last' do + assert_difference('Directo.count', 1) do + SendMonthlyInvoicesJob.perform_now + end + end + + invoice = Invoice.last + assert_equal 309_902, invoice.number + assert invoice.in_directo + assert_not invoice.e_invoice_sent_at.blank? + + assert_emails 1 + email = ActionMailer::Base.deliveries.last + assert_equal ['billing@bestnames.test'], email.to + assert_equal 'Invoice no. 309902 (monthly invoice)', email.subject + assert email.attachments['invoice-309902.pdf'] + + assert_equal 1, EInvoice::Providers::TestProvider.deliveries.count + end + + def test_monthly_summary_is_delivered_in_estonian + activity = account_activities(:one) + price = billing_prices(:create_one_year) + activity.update!(activity_type: 'create', price: price) + @user.update(language: 'et') + + response = <<-XML + + + + + XML + + stub_request(:post, ENV['directo_invoice_url']).with do |request| + body = CGI.unescape(request.body) + + (body.include? '.test registreerimine: 1 aasta(t)') && + (body.include? 'Domeenide ettemaks') && + (body.include? '309902') + end.to_return(status: 200, body: response) + + assert_difference 'Setting.directo_monthly_number_last' do + assert_difference('Directo.count', 1) do + SendMonthlyInvoicesJob.perform_now + end + end + + invoice = Invoice.last + assert_equal 309_902, invoice.number + assert invoice.in_directo + assert_not invoice.e_invoice_sent_at.blank? + + assert_emails 1 + email = ActionMailer::Base.deliveries.last + assert_equal ['billing@bestnames.test'], email.to + assert_equal 'Invoice no. 309902 (monthly invoice)', email.subject + assert email.attachments['invoice-309902.pdf'] + + assert_equal 1, EInvoice::Providers::TestProvider.deliveries.count + end + + def test_multi_year_purchases_have_duration_assigned + activity = account_activities(:one) + price = billing_prices(:create_one_year) + price.update(duration: 3.years) + activity.update(activity_type: 'create', price: price) + + response = <<-XML + + + + + XML + + stub_request(:post, ENV['directo_invoice_url']).with do |request| + body = CGI.unescape(request.body) + (body.include? 'StartDate') && (body.include? 'EndDate') + end.to_return(status: 200, body: response) + + assert_difference 'Setting.directo_monthly_number_last' do + SendMonthlyInvoicesJob.perform_now + end + + invoice = Invoice.last + assert_equal 309_902, invoice.number + assert invoice.in_directo + assert_not invoice.e_invoice_sent_at.blank? + end + + def test_monthly_duration_products_are_present_in_summary + activity = account_activities(:one) + price = billing_prices(:create_one_month) + activity.update(activity_type: 'create', price: price) + + response = <<-XML + + + + + XML + + stub_request(:post, ENV['directo_invoice_url']).with do |request| + body = CGI.unescape(request.body) + body.include? 'month(s)' + end.to_return(status: 200, body: response) + + assert_difference 'Setting.directo_monthly_number_last' do + SendMonthlyInvoicesJob.perform_now + end + + invoice = Invoice.last + assert_equal 309_902, invoice.number + assert invoice.in_directo + assert_not invoice.e_invoice_sent_at.blank? + end + + def test_sends_each_monthly_invoice_separately + WebMock.reset! + + activity = account_activities(:one) + price = billing_prices(:create_one_year) + price.update(duration: 3.years) + activity.update(activity_type: 'create', price: price) + + # Creating account activity for second action + another_activity = activity.dup + another_activity.account = accounts(:two) + + AccountActivity.skip_callback(:create, :after, :update_balance) + another_activity.created_at = Time.zone.parse('2010-07-05 10:00') + another_activity.save + AccountActivity.set_callback(:create, :after, :update_balance) + + response = <<-XML + + + + + XML + + first_registrar_stub = stub_request(:post, ENV['directo_invoice_url']).with do |request| + body = CGI.unescape(request.body) + (body.include? 'StartDate') && (body.include? 'EndDate') && (body.include? 'bestnames') + end.to_return(status: 200, body: response) + + second_registrar_stub = stub_request(:post, ENV['directo_invoice_url']).with do |request| + body = CGI.unescape(request.body) + (body.include? 'StartDate') && (body.include? 'EndDate') && (body.include? 'goodnames') + end.to_return(status: 200, body: response) + + assert_difference('Invoice.count', 2) do + assert_difference('Directo.count', 2) do + SendMonthlyInvoicesJob.perform_now + end + end + + assert_requested first_registrar_stub + assert_requested second_registrar_stub + + assert_emails 2 + assert_equal 2, EInvoice::Providers::TestProvider.deliveries.count + end + + private + + def metadata + { + "items" => [ + { "description" => "Domeenide registreerimine - Juuli 2010" }, + { "product_id" => nil, "quantity" => 1, "unit" => "tk", "price" => 10.0, "description" => ".test registreerimine: 1 aasta(t)" }, + { "product_id" => "ETTEM06", "description" => "Domeenide ettemaks", "quantity" => -1, "price" => 10.0, "unit" => "tk" }, + ], + } + end +end \ No newline at end of file