From dcdbf28bee4317a74664f318cabe75073f9c0845 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Fri, 16 Oct 2026 14:22:46 +0300 Subject: [PATCH 1/3] Allow bank transfer payment of business registry reservation invoices - Generate a reference number and due date for each reservation invoice - Render invoice PDF before payment with reference number, due date and amount due - Add JWT-protected PUT /eis_billing/business_registry_payment for billing to confirm bank payments - Share reservation completion between EveryPay callback and bank payment - Fix cancelling intersecting invoices after paid callback (record was bound instead of domain names) - Stop invoices_test from replacing EisBilling::BaseController for all tests --- CHANGELOG.md | 6 + .../long_reserve_domains_controller.rb | 2 + ...reserved_domains_invoice_pdf_controller.rb | 8 +- .../business_registry_callback_controller.rb | 22 +-- .../business_registry_payments_controller.rb | 38 ++++++ app/models/reserve_domain_invoice.rb | 63 +++++++-- .../reserve_domain_invoice/pdf_generator.rb | 15 +- .../eis_billing/get_reference_number.rb | 11 +- app/views/reserve_domain_invoices/pdf.haml | 27 +++- config/routes.rb | 1 + ...and_due_date_to_reserve_domain_invoices.rb | 10 ++ db/structure.sql | 14 +- .../long_reserve_domains_controller_test.rb | 20 +++ ...ved_domains_invoice_pdf_controller_test.rb | 64 +++++++-- .../business_registry_callback_test.rb | 40 ++++++ .../business_registry_payments_test.rb | 129 ++++++++++++++++++ test/integration/eis_billing/invoices_test.rb | 13 +- .../pdf_generator_test.rb | 43 +++++- test/models/reserve_domain_invoice_test.rb | 71 ++++++++++ 19 files changed, 522 insertions(+), 75 deletions(-) create mode 100644 app/controllers/eis_billing/business_registry_payments_controller.rb create mode 100644 db/migrate/20261016100000_add_reference_no_and_due_date_to_reserve_domain_invoices.rb create mode 100644 test/integration/eis_billing/business_registry_payments_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 84a0c8020b..016d4f6466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +16.10.2026 +* Business Registry reservation invoices get a reference number and due date; invoice PDF is available before payment so it can be paid by bank transfer +* New billing endpoint PUT /eis_billing/business_registry_payment reserves domains when EIS billing matches a bank transfer by reference number +* Fixed cancelling of intersecting reservation invoices after a paid callback +* Deploy: run migrations; set business_registry_payment_url in eis_billing_system + 15.10.2026 * Fix for whois duplicate records https://github.com/internetee/registry/pull/2966 diff --git a/app/controllers/api/v1/business_registry/long_reserve_domains_controller.rb b/app/controllers/api/v1/business_registry/long_reserve_domains_controller.rb index 5d36872341..aeb6e9144c 100644 --- a/app/controllers/api/v1/business_registry/long_reserve_domains_controller.rb +++ b/app/controllers/api/v1/business_registry/long_reserve_domains_controller.rb @@ -22,6 +22,8 @@ def create linkpay_url: decode_linkpay_url(result.linkpay_url), invoice_number: result.invoice_number, user_unique_id: result.user_unique_id, + reference_no: result.reference_no, + due_date: result.due_date, available_domains: ReserveDomainInvoice.filter_available_domains(@domain_names) }, :created) else diff --git a/app/controllers/api/v1/business_registry/reserved_domains_invoice_pdf_controller.rb b/app/controllers/api/v1/business_registry/reserved_domains_invoice_pdf_controller.rb index 8f487a3621..9bebcabc76 100644 --- a/app/controllers/api/v1/business_registry/reserved_domains_invoice_pdf_controller.rb +++ b/app/controllers/api/v1/business_registry/reserved_domains_invoice_pdf_controller.rb @@ -5,14 +5,14 @@ class ReservedDomainsInvoicePdfController < BaseController include ReservedDomainInvoiceScoped def show - result = @reserved_domain_invoice.invoice_state + paid = @reserved_domain_invoice.invoice_state.paid? - unless result.paid? - render_error('Invoice is not paid', :unprocessable_entity, { status: result.status, message: result.message }) + unless paid || @reserved_domain_invoice.pending? + render_error('Invoice is cancelled', :unprocessable_entity, { status: @reserved_domain_invoice.status }) return end - send_data @reserved_domain_invoice.as_pdf(invoice_context), + send_data @reserved_domain_invoice.as_pdf(invoice_context.merge(paid: paid)), filename: "invoice-#{@reserved_domain_invoice.invoice_number}.pdf", type: 'application/pdf', disposition: 'attachment' diff --git a/app/controllers/eis_billing/business_registry_callback_controller.rb b/app/controllers/eis_billing/business_registry_callback_controller.rb index 72e63707f6..455aacd0e4 100644 --- a/app/controllers/eis_billing/business_registry_callback_controller.rb +++ b/app/controllers/eis_billing/business_registry_callback_controller.rb @@ -11,29 +11,15 @@ def callback if result.code == '200' reserve_domain_invoice = ReserveDomainInvoice.find_by(invoice_number: invoice_number, metainfo: user_unique_id) - filtered_domains = BusinessRegistry::DomainAvailabilityCheckerService.filter_available(reserve_domain_invoice.domain_names) - unless filtered_domains.count == reserve_domain_invoice.domain_names.count - return render status: :unprocessable_entity, json: { message: 'Failed to reserve domains' } + if reserve_domain_invoice.reserve_paid_domains == :reserved + render status: :ok, json: { message: 'Callback received' } + else + render status: :unprocessable_entity, json: { message: 'Failed to reserve domains' } end - - reserved_domains = [] - ReservedDomain::Audit.set(reason: 'paid_reservation', reason_note: nil, registrar_id: nil) do - filtered_domains.each do |domain| - reserved_domains << ReservedDomain.create( - name: domain, - expire_at: ReservedDomain.expire_at_for(ReservedDomain::PAID_RESERVATION_EXPIRY) - ) - end - end - reserve_domain_invoice.paid! - ReserveDomainInvoice.cancel_intersecting_invoices(reserve_domain_invoice) - - render status: :ok, json: { message: 'Callback received' } else render status: :internal_server_error, json: { message: 'Callback failed' } end - end private diff --git a/app/controllers/eis_billing/business_registry_payments_controller.rb b/app/controllers/eis_billing/business_registry_payments_controller.rb new file mode 100644 index 0000000000..47369a86fa --- /dev/null +++ b/app/controllers/eis_billing/business_registry_payments_controller.rb @@ -0,0 +1,38 @@ +module EisBilling + class BusinessRegistryPaymentsController < EisBilling::BaseController + around_action :set_audit_context + + def update + reserve_domain_invoice = find_reserve_domain_invoice + return render status: :not_found, json: { message: 'Invoice not found' } if reserve_domain_invoice.nil? + + if reserve_domain_invoice.cancelled? || reserve_domain_invoice.failed? + return render status: :unprocessable_entity, json: { message: 'Invoice is cancelled' } + end + + case reserve_domain_invoice.reserve_paid_domains + when :reserved + render status: :ok, json: { message: 'Payment processed' } + when :already_paid + render status: :ok, json: { message: 'Invoice already paid' } + else + render status: :unprocessable_entity, json: { message: 'Failed to reserve domains' } + end + end + + private + + def find_reserve_domain_invoice + return if params[:reference_number].blank? + + ReserveDomainInvoice.find_by(reference_no: params[:reference_number]) + end + + def set_audit_context + ::PaperTrail.request(whodunnit: 'EIS billing payment') do + ReservedDomain::Audit.set(source: 'eis_billing', reason: nil, + reason_note: nil, registrar_id: nil) { yield } + end + end + end +end diff --git a/app/models/reserve_domain_invoice.rb b/app/models/reserve_domain_invoice.rb index 7aff58e2bd..90b81665e7 100644 --- a/app/models/reserve_domain_invoice.rb +++ b/app/models/reserve_domain_invoice.rb @@ -7,7 +7,7 @@ class InvoiceStruct < Struct.new(:total, :number, :buyer_name, :buyer_email, end class InvoiceResponseStruct < Struct.new(:status_code_success, :linkpay_url, :invoice_number, :details, :reserved_domain_names, :user_unique_id, - keyword_init: true) + :reference_no, :due_date, keyword_init: true) end INITIATOR = 'business_registry' @@ -30,18 +30,20 @@ def create_list_of_domains(domain_names) return build_error_response('Some intersecting invoices are paid') if is_any_intersecting_invoice_paid?(available_names) - user_unique_id = generate_unique_id + user_unique_id = generate_unique_id + reference_no = fetch_reference_no(user_unique_id) invoice = if are_domains_intersect?(available_names) invoice_number = get_invoice_number_from_intersecting_invoice(available_names).to_i - create_invoice_with_domains(available_names, invoice_number: invoice_number, user_unique_id: user_unique_id) + create_invoice_with_domains(available_names, invoice_number: invoice_number, user_unique_id: user_unique_id, + reference_no: reference_no) else - create_invoice_with_domains(available_names, user_unique_id: user_unique_id) + create_invoice_with_domains(available_names, user_unique_id: user_unique_id, reference_no: reference_no) end result = process_invoice(invoice) - create_reserve_domain_invoice(invoice.number, available_names, invoice.user_unique_id) - build_response(result, invoice.number, invoice.user_unique_id, available_names) + reserve_domain_invoice = create_reserve_domain_invoice(invoice, available_names) + build_response(result, invoice, reserve_domain_invoice) end def are_domains_intersect?(domain_names) @@ -100,12 +102,13 @@ def normalize_name(name) SimpleIDN.to_unicode(name).mb_chars.downcase.strip.to_s end - def create_invoice_with_domains(domain_names, invoice_number: nil, user_unique_id: nil) + def create_invoice_with_domains(domain_names, invoice_number: nil, user_unique_id: nil, reference_no: nil) invoice_number = fetch_invoice_number if invoice_number.nil? build_invoice( total: domain_price_calculation(domain_names), number: invoice_number, + reference_no: reference_no, reserved_domain_names: domain_names, user_unique_id: user_unique_id ) @@ -116,6 +119,11 @@ def fetch_invoice_number JSON.parse(response.body).fetch('invoice_number').to_i end + def fetch_reference_no(owner) + response = EisBilling::GetReferenceNumber.call(owner: owner, initiator: INITIATOR) + JSON.parse(response.body).fetch('reference_number') + end + def build_invoice(attributes) InvoiceStruct.new( total: attributes[:total], @@ -124,7 +132,7 @@ def build_invoice(attributes) buyer_email: nil, description: 'description', initiator: INITIATOR, - reference_no: nil, + reference_no: attributes[:reference_no], reserved_domain_names: attributes[:reserved_domain_names], user_unique_id: attributes[:user_unique_id] ) @@ -138,24 +146,28 @@ def process_invoice(invoice) EisBilling::AddDeposits.new(invoice).call end - def create_reserve_domain_invoice(invoice_number, domain_names, user_unique_id) + def create_reserve_domain_invoice(invoice, domain_names) create( - invoice_number: invoice_number, + invoice_number: invoice.number, domain_names: domain_names, - metainfo: user_unique_id + metainfo: invoice.user_unique_id, + reference_no: invoice.reference_no, + due_date: (Time.zone.now + Setting.days_to_keep_invoices_active.days).to_date ) end - def build_response(result, invoice_number, user_unique_id, domain_names) + def build_response(result, invoice, reserve_domain_invoice) parsed_result = JSON.parse(result.body) # link = JSON.parse(result.body)['everypay_link'] InvoiceResponseStruct.new( status_code_success: success_status?(result.code), linkpay_url: parsed_result['everypay_link'], - invoice_number: invoice_number, - user_unique_id: user_unique_id, - reserved_domain_names: domain_names, + invoice_number: invoice.number, + user_unique_id: invoice.user_unique_id, + reserved_domain_names: invoice.reserved_domain_names, + reference_no: reserve_domain_invoice.reference_no, + due_date: reserve_domain_invoice.due_date, details: parsed_result ) end @@ -197,6 +209,22 @@ def create_paid_reserved_domains end end + # Returns :reserved, :already_paid or :unavailable (nothing is reserved when any domain is taken) + def reserve_paid_domains + with_lock do + if paid? + :already_paid + elsif all_domains_available? + create_paid_reserved_domains + paid! + self.class.cancel_intersecting_invoices(domain_names) + :reserved + else + :unavailable + end + end + end + def build_reserved_domains_output domain_names.map do |name| domain = ReservedDomain.find_by(name: name) @@ -212,4 +240,9 @@ def build_reserved_domains_output end end + private + + def all_domains_available? + self.class.filter_available_domains(domain_names).size == domain_names.size + end end diff --git a/app/models/reserve_domain_invoice/pdf_generator.rb b/app/models/reserve_domain_invoice/pdf_generator.rb index 8548f166d2..67c34e4efa 100644 --- a/app/models/reserve_domain_invoice/pdf_generator.rb +++ b/app/models/reserve_domain_invoice/pdf_generator.rb @@ -1,6 +1,8 @@ class ReserveDomainInvoice class PdfGenerator - attr_reader :invoice, :customer_name, :customer_address, :customer_vat_no, :payment_date + attr_reader :invoice, :customer_name, :customer_address, :customer_vat_no + + delegate :reference_no, :due_date, to: :invoice def initialize(invoice, context = {}) @invoice = invoice @@ -9,6 +11,15 @@ def initialize(invoice, context = {}) @customer_vat_no = context[:customer_vat_no] @private_individual = context[:private_individual] @payment_date = context[:payment_date] + @paid = context[:paid] + end + + def paid? + !!@paid + end + + def payment_date + @payment_date if paid? end def amount_paid @@ -20,7 +31,7 @@ def as_pdf end def rows - invoice.domain_names.map { |name| { name: name, reserved: true, price: net_price_per_domain } } + invoice.domain_names.map { |name| { name: name, reserved: paid?, price: net_price_per_domain } } end def rows_sum diff --git a/app/services/eis_billing/get_reference_number.rb b/app/services/eis_billing/get_reference_number.rb index f942baf501..49c18a4d02 100644 --- a/app/services/eis_billing/get_reference_number.rb +++ b/app/services/eis_billing/get_reference_number.rb @@ -1,14 +1,15 @@ module EisBilling class GetReferenceNumber < EisBilling::Base - attr_reader :owner + attr_reader :owner, :initiator # rubocop:disable Lint/MissingSuper - def initialize(owner:) + def initialize(owner:, initiator: INITIATOR) @owner = owner + @initiator = initiator end - def self.call(owner:) - new(owner: owner).call + def self.call(owner:, initiator: INITIATOR) + new(owner: owner, initiator: initiator).call end def call @@ -18,7 +19,7 @@ def call def payload { - initiator: INITIATOR, + initiator: initiator, owner: owner } end diff --git a/app/views/reserve_domain_invoices/pdf.haml b/app/views/reserve_domain_invoices/pdf.haml index f3c0ad3538..e6770864bc 100644 --- a/app/views/reserve_domain_invoices/pdf.haml +++ b/app/views/reserve_domain_invoices/pdf.haml @@ -154,9 +154,23 @@ .row .col-md-6.left %dl.dl-horizontal + %dt Arve number + %dd= @pdf.invoice.invoice_number + %dt Arve kuupäev %dd= l(@pdf.invoice_date) + - if @pdf.due_date + %dt Maksetähtaeg + %dd= l(@pdf.due_date) + + - if @pdf.reference_no.present? + %dt Viitenumber + %dd= @pdf.reference_no + + %dt Makse staatus + %dd= @pdf.paid? ? 'Tasutud' : 'Tasumata' + - if @pdf.payment_date %dt Makse kuupäev %dd= l(@pdf.payment_date) @@ -190,7 +204,7 @@ - @pdf.rows.each do |row| %tr %td= row[:name] - %td= row[:reserved] ? 'Reserveeritud' : 'Reserveerimine ebaõnnestus' + %td= row[:reserved] ? 'Reserveeritud' : 'Ootab tasumist' %td.text-right= eur.(row[:price]) %tfoot %tr @@ -202,9 +216,14 @@ %tr %th.no-border.text-right{colspan: 2} Summa kokku %td.text-right= eur.(@pdf.grand_total) - %tr - %th.no-border.text-right{colspan: 2} Tasutud summa - %td.text-right= eur.(@pdf.amount_paid) + - if @pdf.paid? + %tr + %th.no-border.text-right{colspan: 2} Tasutud summa + %td.text-right= eur.(@pdf.amount_paid) + - else + %tr + %th.no-border.text-right{colspan: 2} Tasuda + %td.text-right= eur.(@pdf.grand_total) #footer %hr diff --git a/config/routes.rb b/config/routes.rb index 98a9161957..a6cb1f82a4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -17,6 +17,7 @@ put '/e_invoice_response', to: 'e_invoice_response#update', as: 'e_invoice_response' post '/lhv_connect_transactions', to: 'lhv_connect_transactions#create', as: 'lhv_connect_transactions' get 'callback', to: 'business_registry_callback#callback', as: 'callback' + put '/business_registry_payment', to: 'business_registry_payments#update', as: 'business_registry_payment' resource :invoices, only: [:update] end diff --git a/db/migrate/20261016100000_add_reference_no_and_due_date_to_reserve_domain_invoices.rb b/db/migrate/20261016100000_add_reference_no_and_due_date_to_reserve_domain_invoices.rb new file mode 100644 index 0000000000..547cfa16cd --- /dev/null +++ b/db/migrate/20261016100000_add_reference_no_and_due_date_to_reserve_domain_invoices.rb @@ -0,0 +1,10 @@ +class AddReferenceNoAndDueDateToReserveDomainInvoices < ActiveRecord::Migration[6.1] + disable_ddl_transaction! + + def change + add_column :reserve_domain_invoices, :reference_no, :string + add_column :reserve_domain_invoices, :due_date, :date + add_index :reserve_domain_invoices, :reference_no, unique: true, where: 'reference_no IS NOT NULL', + algorithm: :concurrently + end +end diff --git a/db/structure.sql b/db/structure.sql index 62fb3d5012..6bff083524 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -2809,7 +2809,9 @@ CREATE TABLE public.reserve_domain_invoices ( created_at timestamp(6) without time zone NOT NULL, updated_at timestamp(6) without time zone NOT NULL, status integer DEFAULT 0, - metainfo character varying + metainfo character varying, + reference_no character varying, + due_date date ); @@ -5132,6 +5134,13 @@ CREATE INDEX index_registrars_on_accreditation_date ON public.registrars USING b CREATE INDEX index_reserve_domain_invoices_on_invoice_number ON public.reserve_domain_invoices USING btree (invoice_number); +-- +-- Name: index_reserve_domain_invoices_on_reference_no; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_reserve_domain_invoices_on_reference_no ON public.reserve_domain_invoices USING btree (reference_no) WHERE (reference_no IS NOT NULL); + + -- -- Name: index_reserved_domain_lifecycles_on_created_at; Type: INDEX; Schema: public; Owner: - -- @@ -6075,6 +6084,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20261005090000'), ('20261005090100'), ('20261005090200'), -('20261012120000'); +('20261012120000'), +('20261016100000'); diff --git a/test/integration/api/business_registry/long_reserve_domains_controller_test.rb b/test/integration/api/business_registry/long_reserve_domains_controller_test.rb index 63b58d7376..6ec7d33105 100644 --- a/test/integration/api/business_registry/long_reserve_domains_controller_test.rb +++ b/test/integration/api/business_registry/long_reserve_domains_controller_test.rb @@ -6,6 +6,7 @@ def setup stub_invoice_number_request stub_add_deposits_request + stub_reference_number_request stub_oneoff_request @valid_ip = '127.0.0.1' @@ -196,6 +197,20 @@ def setup end end + test "should return bank transfer details in response" do + travel_to Time.zone.parse('2026-10-16 12:00') do + post api_v1_business_registry_long_reserve_domains_path, + params: { domain_names: @valid_domain_names }, + headers: @auth_headers + end + + assert_response :created + json_response = JSON.parse(response.body) + assert_equal '1234566', json_response['reference_no'] + assert_equal '2026-11-15', json_response['due_date'] + assert_equal '1234566', ReserveDomainInvoice.last.reference_no + end + private def stub_invoice_number_request @@ -208,6 +223,11 @@ def stub_add_deposits_request .to_return(status: 201, body: { everypay_link: 'https://pay.test' }.to_json) end + def stub_reference_number_request + stub_request(:post, "https://eis_billing_system:3000/api/v1/invoice_generator/reference_number_generator") + .to_return(status: 200, body: { reference_number: '1234566' }.to_json, headers: {}) + end + def stub_oneoff_request stub_request(:post, "https://eis_billing_system:3000/api/v1/invoice_generator/oneoff") .to_return(status: 200, body: { oneoff_redirect_link: 'https://payment.test' }.to_json) diff --git a/test/integration/api/business_registry/reserved_domains_invoice_pdf_controller_test.rb b/test/integration/api/business_registry/reserved_domains_invoice_pdf_controller_test.rb index ca61fa668e..306e55fc8a 100644 --- a/test/integration/api/business_registry/reserved_domains_invoice_pdf_controller_test.rb +++ b/test/integration/api/business_registry/reserved_domains_invoice_pdf_controller_test.rb @@ -89,16 +89,43 @@ def setup assert_response :not_found end - test 'returns error when invoice is not paid' do - stub_billing_request( - status: 200, - body: { - message: 'Payment pending', - invoice_status: 'unpaid', - invoice_number: @invoice.invoice_number - }, - user_unique_id: @invoice.metainfo - ) + test 'returns unpaid pdf when invoice is pending' do + stub_unpaid_billing_status + + captured_context = nil + generator = ->(_invoice, context) { captured_context = context; Struct.new(:as_pdf).new('%PDF-1.4 unpaid') } + + ReserveDomainInvoice::PdfGenerator.stub :new, generator do + get api_v1_business_registry_reserved_domains_invoice_pdf_path(invoice_number: @invoice.invoice_number, + user_unique_id: @invoice.metainfo), + headers: @auth_headers + end + + assert_response :success + assert_equal 'application/pdf', response.headers['Content-Type'] + assert_includes response.body, '%PDF-1.4 unpaid' + assert_equal false, captured_context[:paid] + end + + test 'passes paid flag when billing reports invoice as paid' do + stub_billing_request(status: 200, body: { invoice_status: 'paid' }, user_unique_id: @invoice.metainfo) + + captured_context = nil + generator = ->(_invoice, context) { captured_context = context; Struct.new(:as_pdf).new('%PDF-1.4 paid') } + + ReserveDomainInvoice::PdfGenerator.stub :new, generator do + get api_v1_business_registry_reserved_domains_invoice_pdf_path(invoice_number: @invoice.invoice_number, + user_unique_id: @invoice.metainfo), + headers: @auth_headers + end + + assert_response :success + assert_equal true, captured_context[:paid] + end + + test 'returns error when unpaid invoice is cancelled' do + @invoice.cancelled! + stub_unpaid_billing_status get api_v1_business_registry_reserved_domains_invoice_pdf_path(invoice_number: @invoice.invoice_number, user_unique_id: @invoice.metainfo), @@ -106,13 +133,24 @@ def setup assert_response :unprocessable_entity json_response = JSON.parse(response.body) - assert_equal 'Invoice is not paid', json_response['error'] - assert_equal 'unpaid', json_response.dig('details', 'status') - assert_equal 'Payment pending', json_response.dig('details', 'message') + assert_equal 'Invoice is cancelled', json_response['error'] + assert_equal 'cancelled', json_response.dig('details', 'status') end private + def stub_unpaid_billing_status + stub_billing_request( + status: 200, + body: { + message: 'Payment pending', + invoice_status: 'unpaid', + invoice_number: @invoice.invoice_number + }, + user_unique_id: @invoice.metainfo + ) + end + def stub_billing_request(status:, body:, user_unique_id: nil) stub_request(:get, "https://eis_billing_system:3000/api/v1/invoice/reserved_domains_invoice_statuses?invoice_number=#{@invoice.invoice_number}&user_unique_id=#{user_unique_id}") .with( diff --git a/test/integration/eis_billing/business_registry_callback_test.rb b/test/integration/eis_billing/business_registry_callback_test.rb index 291fced5c3..cc33423ad7 100644 --- a/test/integration/eis_billing/business_registry_callback_test.rb +++ b/test/integration/eis_billing/business_registry_callback_test.rb @@ -27,4 +27,44 @@ class EisBilling::BusinessRegistryCallbackTest < ApplicationIntegrationTest assert_equal 'paid-callback.test', version.domain_name assert_equal 'EIS billing callback', version.whodunnit end + + test 'paid callback cancels pending invoices with intersecting domains' do + invoice = ReserveDomainInvoice.create!(invoice_number: '555556', + domain_names: ['shared-callback.test'], + metainfo: 'uid-456') + intersecting_invoice = ReserveDomainInvoice.create!(invoice_number: '555557', + domain_names: ['shared-callback.test', 'other-callback.test'], + metainfo: 'uid-789') + + result = Struct.new(:code, :body).new('200', { 'custom_field_1' => 'uid-456' }.to_json) + + EisBilling::SendCallbackService.stub :call, result do + BusinessRegistry::DomainAvailabilityCheckerService.stub :filter_available, ['shared-callback.test'] do + get eis_billing_callback_path, params: { payment_reference: 'ref-2', order_reference: invoice.invoice_number } + end + end + + assert_response :ok + assert invoice.reload.paid? + assert intersecting_invoice.reload.cancelled? + end + + test 'paid callback does not reserve anything when some domain is unavailable' do + invoice = ReserveDomainInvoice.create!(invoice_number: '555558', + domain_names: ['free-callback.test', 'taken-callback.test'], + metainfo: 'uid-321') + + result = Struct.new(:code, :body).new('200', { 'custom_field_1' => 'uid-321' }.to_json) + + EisBilling::SendCallbackService.stub :call, result do + BusinessRegistry::DomainAvailabilityCheckerService.stub :filter_available, ['free-callback.test'] do + get eis_billing_callback_path, params: { payment_reference: 'ref-3', order_reference: invoice.invoice_number } + end + end + + assert_response :unprocessable_entity + assert_equal 'Failed to reserve domains', JSON.parse(response.body)['message'] + assert invoice.reload.pending? + assert_nil ReservedDomain.find_by(name: 'free-callback.test') + end end diff --git a/test/integration/eis_billing/business_registry_payments_test.rb b/test/integration/eis_billing/business_registry_payments_test.rb new file mode 100644 index 0000000000..c15684944f --- /dev/null +++ b/test/integration/eis_billing/business_registry_payments_test.rb @@ -0,0 +1,129 @@ +require 'test_helper' + +class EisBilling::BusinessRegistryPaymentsTest < ApplicationIntegrationTest + setup do + @invoice = ReserveDomainInvoice.create!(invoice_number: '777001', + domain_names: ['bank-transfer.test'], + metainfo: 'uid-bank', + reference_no: '7770011', + due_date: Date.new(2026, 11, 15)) + end + + test 'reserves domains and marks invoice paid' do + BusinessRegistry::DomainAvailabilityCheckerService.stub :filter_available, ['bank-transfer.test'] do + put_payment reference_number: @invoice.reference_no + end + + assert_response :ok + assert_equal 'Payment processed', JSON.parse(response.body)['message'] + assert @invoice.reload.paid? + + reserved_domain = ReservedDomain.find_by(name: 'bank-transfer.test') + assert reserved_domain.present? + + version = Version::ReservedDomainVersion.where(item_id: reserved_domain.id).last + assert_equal 'eis_billing', version.source + assert_equal 'paid_reservation', version.reason + assert_equal 'EIS billing payment', version.whodunnit + end + + test 'cancels pending invoices with intersecting domains' do + intersecting_invoice = ReserveDomainInvoice.create!(invoice_number: '777002', + domain_names: ['bank-transfer.test'], + metainfo: 'uid-other') + + BusinessRegistry::DomainAvailabilityCheckerService.stub :filter_available, ['bank-transfer.test'] do + put_payment reference_number: @invoice.reference_no + end + + assert_response :ok + assert intersecting_invoice.reload.cancelled? + end + + test 'responds ok without reserving again when invoice is already paid' do + @invoice.paid! + + assert_no_difference 'ReservedDomain.count' do + put_payment reference_number: @invoice.reference_no + end + + assert_response :ok + assert_equal 'Invoice already paid', JSON.parse(response.body)['message'] + end + + test 'denies access without valid token' do + put eis_billing_business_registry_payment_path, params: payment_params(reference_number: @invoice.reference_no), + headers: { 'Authorization' => 'Bearer invalid' }, + as: :json + + assert_response :unauthorized + assert @invoice.reload.pending? + end + + test 'responds not found for unknown reference number' do + put_payment reference_number: '0000000' + + assert_response :not_found + assert_equal 'Invoice not found', JSON.parse(response.body)['message'] + end + + test 'does not match invoices without reference number when reference is missing' do + legacy_invoice = ReserveDomainInvoice.create!(invoice_number: '777003', + domain_names: ['legacy-transfer.test'], + metainfo: 'legacy-uid') + + put_payment reference_number: nil + + assert_response :not_found + assert legacy_invoice.reload.pending? + end + + test 'rejects payment for cancelled invoice' do + @invoice.cancelled! + + assert_no_difference 'ReservedDomain.count' do + put_payment reference_number: @invoice.reference_no + end + + assert_response :unprocessable_entity + assert_equal 'Invoice is cancelled', JSON.parse(response.body)['message'] + assert @invoice.reload.cancelled? + end + + test 'rejects payment when some domain is no longer available' do + @invoice.update!(domain_names: ['bank-transfer.test', 'taken-transfer.test']) + + BusinessRegistry::DomainAvailabilityCheckerService.stub :filter_available, ['bank-transfer.test'] do + assert_no_difference 'ReservedDomain.count' do + put_payment reference_number: @invoice.reference_no + end + end + + assert_response :unprocessable_entity + assert_equal 'Failed to reserve domains', JSON.parse(response.body)['message'] + assert @invoice.reload.pending? + end + + private + + def put_payment(reference_number:) + put eis_billing_business_registry_payment_path, params: payment_params(reference_number: reference_number), + headers: billing_headers, + as: :json + end + + def payment_params(reference_number:) + { + reference_number: reference_number, + invoice_number: @invoice.invoice_number, + amount: '10.0', + currency: 'EUR', + paid_at: '2026-10-16' + } + end + + def billing_headers + token = JWT.encode({ initiator: 'billing' }, ENV['billing_secret'], 'HS256') + { 'Authorization' => "Bearer #{token}" } + end +end diff --git a/test/integration/eis_billing/invoices_test.rb b/test/integration/eis_billing/invoices_test.rb index abb688fabc..e1ef3ff6ef 100644 --- a/test/integration/eis_billing/invoices_test.rb +++ b/test/integration/eis_billing/invoices_test.rb @@ -1,18 +1,9 @@ require 'test_helper' -class StubAuthorization < ApplicationController - skip_authorization_check - - def authorized - true - end -end - -EisBilling::BaseController = StubAuthorization - -class EInvoiceResponseTest < ApplicationIntegrationTest +class EisBillingInvoicesTest < ApplicationIntegrationTest setup do sign_in users(:api_bestnames) + Spy.on_instance_method(EisBilling::BaseController, :authorized).and_return(true) @invoice = invoices(:one) response_message = { diff --git a/test/models/reserve_domain_invoice/pdf_generator_test.rb b/test/models/reserve_domain_invoice/pdf_generator_test.rb index 1a7219fbe6..f974116592 100644 --- a/test/models/reserve_domain_invoice/pdf_generator_test.rb +++ b/test/models/reserve_domain_invoice/pdf_generator_test.rb @@ -5,7 +5,9 @@ def setup @invoice = ReserveDomainInvoice.create!( invoice_number: '67890', domain_names: ['unregistered.test', 'sample.test'], - metainfo: 'meta-uid' + metainfo: 'meta-uid', + reference_no: '6789012', + due_date: Date.new(2026, 11, 15) ) end @@ -69,6 +71,45 @@ def setup refute_includes html, 'eraisik' end + test 'renders unpaid invoice with bank transfer details and amount due' do + html = render_for(paid: false, payment_date: Date.new(2026, 10, 20)) + + assert_includes html, 'Arve number' + assert_includes html, 'Viitenumber' + assert_includes html, '6789012' + assert_includes html, 'Maksetähtaeg' + assert_includes html, I18n.l(@invoice.due_date) + assert_includes html, 'Tasumata' + assert_includes html, 'Tasuda' + assert_includes html, 'Ootab tasumist' + refute_includes html, 'Tasutud summa' + refute_includes html, 'Reserveeritud' + refute_includes html, 'Makse kuupäev' + end + + test 'renders paid invoice with paid amount and payment date' do + html = render_for(paid: true, payment_date: Date.new(2026, 10, 20)) + + assert_includes html, 'Viitenumber' + assert_includes html, 'Maksetähtaeg' + assert_includes html, 'Tasutud summa' + assert_includes html, 'Reserveeritud' + assert_includes html, 'Makse kuupäev' + assert_includes html, I18n.l(Date.new(2026, 10, 20)) + refute_includes html, 'Tasumata' + refute_includes html, 'Tasuda' + end + + test 'hides reference number and due date for older invoices' do + @invoice.update!(reference_no: nil, due_date: nil) + + html = render_for(paid: true) + + refute_includes html, 'Viitenumber' + refute_includes html, 'Maksetähtaeg' + assert_includes html, 'Tasutud summa' + end + private def render_for(context = {}) diff --git a/test/models/reserve_domain_invoice_test.rb b/test/models/reserve_domain_invoice_test.rb index 1184e4d8ee..d3923844ed 100644 --- a/test/models/reserve_domain_invoice_test.rb +++ b/test/models/reserve_domain_invoice_test.rb @@ -2,6 +2,7 @@ class ReserveDomainInvoiceTest < ActiveSupport::TestCase TEST_USER_UNIQUE_ID = 'test123' + REFERENCE_NO = '1234566' INVOICE_NUMBER = '12345' def setup @@ -9,6 +10,7 @@ def setup stub_invoice_number_request stub_add_deposits_request + stub_reference_number_request stub_reserved_domains_invoice_status # Mock generate_unique_id to return consistent value @@ -244,6 +246,70 @@ def teardown assert ReservedDomain.exists?(id: permanent_domain.id) end + test "stores business registry reference number and due date on new invoice" do + WebMock::RequestRegistry.instance.reset! + + travel_to Time.zone.parse('2026-10-16 12:00') do + result = ReserveDomainInvoice.create_list_of_domains(@domain_names) + + invoice = ReserveDomainInvoice.last + assert_equal REFERENCE_NO, invoice.reference_no + assert_equal Date.new(2026, 11, 15), invoice.due_date + assert_equal REFERENCE_NO, result.reference_no + assert_equal Date.new(2026, 11, 15), result.due_date + end + + assert_requested :post, "https://eis_billing_system:3000/api/v1/invoice_generator/reference_number_generator", + body: { initiator: 'business_registry', owner: TEST_USER_UNIQUE_ID }.to_json + assert_requested(:post, "https://eis_billing_system:3000/api/v1/invoice_generator/invoice_generator") do |request| + JSON.parse(request.body)['reference_number'] == REFERENCE_NO + end + end + + test "reserves domains and marks invoice paid" do + invoice = ReserveDomainInvoice.create!(invoice_number: '12347', domain_names: @domain_names, metainfo: TEST_USER_UNIQUE_ID) + + BusinessRegistry::DomainAvailabilityCheckerService.stub :filter_available, @domain_names do + assert_difference 'ReservedDomain.count', 2 do + assert_equal :reserved, invoice.reserve_paid_domains + end + end + + assert invoice.reload.paid? + end + + test "reserving paid domains cancels pending invoices with intersecting domains" do + invoice = ReserveDomainInvoice.create!(invoice_number: '12348', domain_names: [@domain_names.first], metainfo: TEST_USER_UNIQUE_ID) + intersecting_invoice = ReserveDomainInvoice.create!(invoice_number: '12349', domain_names: @domain_names, metainfo: 'other') + + BusinessRegistry::DomainAvailabilityCheckerService.stub :filter_available, [@domain_names.first] do + invoice.reserve_paid_domains + end + + assert intersecting_invoice.reload.cancelled? + end + + test "does not reserve domains again for already paid invoice" do + invoice = ReserveDomainInvoice.create!(invoice_number: '12350', domain_names: @domain_names, metainfo: TEST_USER_UNIQUE_ID, + status: :paid) + + assert_no_difference 'ReservedDomain.count' do + assert_equal :already_paid, invoice.reserve_paid_domains + end + end + + test "reserves nothing when some domain is unavailable" do + invoice = ReserveDomainInvoice.create!(invoice_number: '12351', domain_names: @domain_names, metainfo: TEST_USER_UNIQUE_ID) + + BusinessRegistry::DomainAvailabilityCheckerService.stub :filter_available, [@domain_names.first] do + assert_no_difference 'ReservedDomain.count' do + assert_equal :unavailable, invoice.reserve_paid_domains + end + end + + assert invoice.reload.pending? + end + private def stub_invoice_number_request @@ -256,6 +322,11 @@ def stub_add_deposits_request .to_return(status: 201, body: { everypay_link: 'https://pay.test' }.to_json) end + def stub_reference_number_request + stub_request(:post, "https://eis_billing_system:3000/api/v1/invoice_generator/reference_number_generator") + .to_return(status: 200, body: { reference_number: REFERENCE_NO }.to_json, headers: {}) + end + def stub_reserved_domains_invoice_status stub_request(:get, "https://eis_billing_system:3000/api/v1/invoice/reserved_domains_invoice_statuses?invoice_number=#{INVOICE_NUMBER}&user_unique_id=#{TEST_USER_UNIQUE_ID}") .to_return(status: 200, body: { invoice_status: 'paid' }.to_json, headers: {}) From 92fd0943c928df498ce68a390bac6d2b76cf370f Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Mon, 19 Oct 2026 11:28:39 +0300 Subject: [PATCH 2/3] Cancel reservation invoice when bank payment arrives for taken domains Reservation is first-come-first-served: unpaid invoice PDF says the invoice does not guarantee the reservation, and a bank payment for domains that are no longer available cancels the invoice so billing can refund it. --- CHANGELOG.md | 1 + .../business_registry_payments_controller.rb | 2 ++ app/views/reserve_domain_invoices/pdf.haml | 11 +++++++++++ .../eis_billing/business_registry_payments_test.rb | 2 +- .../reserve_domain_invoice/pdf_generator_test.rb | 2 ++ 5 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 016d4f6466..f6143d9956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ 16.10.2026 * Business Registry reservation invoices get a reference number and due date; invoice PDF is available before payment so it can be paid by bank transfer * New billing endpoint PUT /eis_billing/business_registry_payment reserves domains when EIS billing matches a bank transfer by reference number +* Reservation is first-come-first-served: unpaid invoice PDF warns that it does not guarantee the reservation; a bank payment for domains that are no longer available cancels the invoice and EIS billing asks admin to refund it * Fixed cancelling of intersecting reservation invoices after a paid callback * Deploy: run migrations; set business_registry_payment_url in eis_billing_system diff --git a/app/controllers/eis_billing/business_registry_payments_controller.rb b/app/controllers/eis_billing/business_registry_payments_controller.rb index 47369a86fa..57d830d989 100644 --- a/app/controllers/eis_billing/business_registry_payments_controller.rb +++ b/app/controllers/eis_billing/business_registry_payments_controller.rb @@ -16,6 +16,8 @@ def update when :already_paid render status: :ok, json: { message: 'Invoice already paid' } else + # First-come-first-served: billing refunds the payment of a cancelled invoice + reserve_domain_invoice.cancelled! render status: :unprocessable_entity, json: { message: 'Failed to reserve domains' } end end diff --git a/app/views/reserve_domain_invoices/pdf.haml b/app/views/reserve_domain_invoices/pdf.haml index e6770864bc..ef7aefa5b0 100644 --- a/app/views/reserve_domain_invoices/pdf.haml +++ b/app/views/reserve_domain_invoices/pdf.haml @@ -225,6 +225,17 @@ %th.no-border.text-right{colspan: 2} Tasuda %td.text-right= eur.(@pdf.grand_total) + - unless @pdf.paid? + %p + Arve väljastamine ei taga domeeninimede broneerimist. Broneerimine, nagu ka + registreerimine, toimub põhimõttel "kes ees, see mees". Kui makse laekumise ajaks + ei ole broneerimine enam võimalik, arve tühistatakse ja makstud summa tagastatakse. + %p + Issuing this invoice does not guarantee the reservation of the domain names. + Reservation, like registration, is first-come-first-served. If the reservation is + no longer possible when the payment arrives, the invoice is cancelled and the + payment is refunded. + #footer %hr .row diff --git a/test/integration/eis_billing/business_registry_payments_test.rb b/test/integration/eis_billing/business_registry_payments_test.rb index c15684944f..d91c9810d9 100644 --- a/test/integration/eis_billing/business_registry_payments_test.rb +++ b/test/integration/eis_billing/business_registry_payments_test.rb @@ -101,7 +101,7 @@ class EisBilling::BusinessRegistryPaymentsTest < ApplicationIntegrationTest assert_response :unprocessable_entity assert_equal 'Failed to reserve domains', JSON.parse(response.body)['message'] - assert @invoice.reload.pending? + assert @invoice.reload.cancelled? end private diff --git a/test/models/reserve_domain_invoice/pdf_generator_test.rb b/test/models/reserve_domain_invoice/pdf_generator_test.rb index f974116592..1953e444f9 100644 --- a/test/models/reserve_domain_invoice/pdf_generator_test.rb +++ b/test/models/reserve_domain_invoice/pdf_generator_test.rb @@ -85,6 +85,7 @@ def setup refute_includes html, 'Tasutud summa' refute_includes html, 'Reserveeritud' refute_includes html, 'Makse kuupäev' + assert_includes html, 'first-come-first-served' end test 'renders paid invoice with paid amount and payment date' do @@ -98,6 +99,7 @@ def setup assert_includes html, I18n.l(Date.new(2026, 10, 20)) refute_includes html, 'Tasumata' refute_includes html, 'Tasuda' + refute_includes html, 'first-come-first-served' end test 'hides reference number and due date for older invoices' do From 51df779461194947ba9134b401fac55c4fab48b4 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Mon, 19 Oct 2026 13:52:18 +0300 Subject: [PATCH 3/3] Add PR links to CHANGELOG --- CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6143d9956..7924472195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,9 @@ 16.10.2026 -* Business Registry reservation invoices get a reference number and due date; invoice PDF is available before payment so it can be paid by bank transfer -* New billing endpoint PUT /eis_billing/business_registry_payment reserves domains when EIS billing matches a bank transfer by reference number -* Reservation is first-come-first-served: unpaid invoice PDF warns that it does not guarantee the reservation; a bank payment for domains that are no longer available cancels the invoice and EIS billing asks admin to refund it -* Fixed cancelling of intersecting reservation invoices after a paid callback -* Deploy: run migrations; set business_registry_payment_url in eis_billing_system +* Business Registry reservation invoices get a reference number and due date; invoice PDF is available before payment so it can be paid by bank transfer https://github.com/internetee/registry/pull/2972 +* New billing endpoint PUT /eis_billing/business_registry_payment reserves domains when EIS billing matches a bank transfer by reference number https://github.com/internetee/registry/pull/2972 +* Reservation is first-come-first-served: unpaid invoice PDF warns that it does not guarantee the reservation; a bank payment for domains that are no longer available cancels the invoice and EIS billing asks admin to refund it https://github.com/internetee/registry/pull/2972 +* Fixed cancelling of intersecting reservation invoices after a paid callback https://github.com/internetee/registry/pull/2972 +* Deploy: run migrations; set business_registry_payment_url in eis_billing_system (https://github.com/internetee/eis_billing_system/pull/268) 15.10.2026 * Fix for whois duplicate records https://github.com/internetee/registry/pull/2966