diff --git a/.gitignore b/.gitignore index a3cfb8d..084eed2 100644 --- a/.gitignore +++ b/.gitignore @@ -42,7 +42,9 @@ EESTIINTERNETISA.p12 docker-compose.yml -vendor/gems +Dockerfile.dev + +# vendor/gems /public/assets /public/packs /public/packs-test diff --git a/Gemfile b/Gemfile index 417f3f7..242964f 100644 --- a/Gemfile +++ b/Gemfile @@ -4,7 +4,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" } ruby '3.2.0' # Bundle edge Rails instead: gem 'rails', github: 'rails/rails', branch: 'main' -gem 'rails', '~> 7.1.3' +gem 'rails', '~> 7.1.3.4' gem 'pg', '~> 1.1' gem 'puma', '~> 6.4.0' gem 'redis', '~> 5.0' diff --git a/Gemfile.lock b/Gemfile.lock index 37044a2..79b7681 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -37,35 +37,35 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (7.1.3.2) - actionpack (= 7.1.3.2) - activesupport (= 7.1.3.2) + actioncable (7.1.3.4) + actionpack (= 7.1.3.4) + activesupport (= 7.1.3.4) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.3.2) - actionpack (= 7.1.3.2) - activejob (= 7.1.3.2) - activerecord (= 7.1.3.2) - activestorage (= 7.1.3.2) - activesupport (= 7.1.3.2) + actionmailbox (7.1.3.4) + actionpack (= 7.1.3.4) + activejob (= 7.1.3.4) + activerecord (= 7.1.3.4) + activestorage (= 7.1.3.4) + activesupport (= 7.1.3.4) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.1.3.2) - actionpack (= 7.1.3.2) - actionview (= 7.1.3.2) - activejob (= 7.1.3.2) - activesupport (= 7.1.3.2) + actionmailer (7.1.3.4) + actionpack (= 7.1.3.4) + actionview (= 7.1.3.4) + activejob (= 7.1.3.4) + activesupport (= 7.1.3.4) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.2) - actionpack (7.1.3.2) - actionview (= 7.1.3.2) - activesupport (= 7.1.3.2) + actionpack (7.1.3.4) + actionview (= 7.1.3.4) + activesupport (= 7.1.3.4) nokogiri (>= 1.8.5) racc rack (>= 2.2.4) @@ -73,35 +73,35 @@ GEM rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.3.2) - actionpack (= 7.1.3.2) - activerecord (= 7.1.3.2) - activestorage (= 7.1.3.2) - activesupport (= 7.1.3.2) + actiontext (7.1.3.4) + actionpack (= 7.1.3.4) + activerecord (= 7.1.3.4) + activestorage (= 7.1.3.4) + activesupport (= 7.1.3.4) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.3.2) - activesupport (= 7.1.3.2) + actionview (7.1.3.4) + activesupport (= 7.1.3.4) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.1.3.2) - activesupport (= 7.1.3.2) + activejob (7.1.3.4) + activesupport (= 7.1.3.4) globalid (>= 0.3.6) - activemodel (7.1.3.2) - activesupport (= 7.1.3.2) - activerecord (7.1.3.2) - activemodel (= 7.1.3.2) - activesupport (= 7.1.3.2) + activemodel (7.1.3.4) + activesupport (= 7.1.3.4) + activerecord (7.1.3.4) + activemodel (= 7.1.3.4) + activesupport (= 7.1.3.4) timeout (>= 0.4.0) - activestorage (7.1.3.2) - actionpack (= 7.1.3.2) - activejob (= 7.1.3.2) - activerecord (= 7.1.3.2) - activesupport (= 7.1.3.2) + activestorage (7.1.3.4) + actionpack (= 7.1.3.4) + activejob (= 7.1.3.4) + activerecord (= 7.1.3.4) + activesupport (= 7.1.3.4) marcel (~> 1.0) - activesupport (7.1.3.2) + activesupport (7.1.3.4) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) @@ -360,20 +360,20 @@ GEM rackup (1.0.0) rack (< 3) webrick - rails (7.1.3.2) - actioncable (= 7.1.3.2) - actionmailbox (= 7.1.3.2) - actionmailer (= 7.1.3.2) - actionpack (= 7.1.3.2) - actiontext (= 7.1.3.2) - actionview (= 7.1.3.2) - activejob (= 7.1.3.2) - activemodel (= 7.1.3.2) - activerecord (= 7.1.3.2) - activestorage (= 7.1.3.2) - activesupport (= 7.1.3.2) + rails (7.1.3.4) + actioncable (= 7.1.3.4) + actionmailbox (= 7.1.3.4) + actionmailer (= 7.1.3.4) + actionpack (= 7.1.3.4) + actiontext (= 7.1.3.4) + actionview (= 7.1.3.4) + activejob (= 7.1.3.4) + activemodel (= 7.1.3.4) + activerecord (= 7.1.3.4) + activestorage (= 7.1.3.4) + activesupport (= 7.1.3.4) bundler (>= 1.15.0) - railties (= 7.1.3.2) + railties (= 7.1.3.4) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -384,9 +384,9 @@ GEM rails-i18n (7.0.8) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) - railties (7.1.3.2) - actionpack (= 7.1.3.2) - activesupport (= 7.1.3.2) + railties (7.1.3.4) + actionpack (= 7.1.3.4) + activesupport (= 7.1.3.4) irb rackup (>= 1.0.0) rake (>= 12.2) @@ -571,7 +571,7 @@ DEPENDENCIES pg (~> 1.1) pg_search puma (~> 6.4.0) - rails (~> 7.1.3) + rails (~> 7.1.3.4) redis (~> 5.0) rexml rspec-rails diff --git a/app/controllers/api/v1/invoice_generator/oneoff_controller.rb b/app/controllers/api/v1/invoice_generator/oneoff_controller.rb index abbca5e..f86df67 100644 --- a/app/controllers/api/v1/invoice_generator/oneoff_controller.rb +++ b/app/controllers/api/v1/invoice_generator/oneoff_controller.rb @@ -9,11 +9,13 @@ class OneoffController < Api::V1::InvoiceGenerator::BaseController The link where the user must be redirected after payment. Along with the transition also on this link comes the data about the payment. This is a kind of redirect_url and callback_url HERE param :reference_number, String, required: false + param :amount, String, required: false def create response = Oneoff.call(invoice_number: params[:invoice_number], customer_url: params[:customer_url], - reference_number: params[:reference_number]) + reference_number: params[:reference_number], + amount: params[:amount]) if response.result? render json: { 'message' => 'Link created', 'oneoff_redirect_link' => response.instance['payment_link'] }, diff --git a/app/models/invoice.rb b/app/models/invoice.rb index efea4f5..34d6217 100644 --- a/app/models/invoice.rb +++ b/app/models/invoice.rb @@ -15,7 +15,7 @@ class Invoice < ApplicationRecord } enum affiliation: { regular: 0, auction_deposit: 1, linkpay: 2 } - enum status: { unpaid: 0, paid: 1, cancelled: 2, failed: 3, refunded: 4, overdue: 5 } + enum status: { unpaid: 0, paid: 1, cancelled: 2, failed: 3, refunded: 4, overdue: 5, partially_paid: 6 } scope :with_status, lambda { |status| where(status:) if status.present? @@ -29,6 +29,10 @@ class Invoice < ApplicationRecord where(transaction_amount: low.to_f..high.to_f) if low.present? && high.present? } + validate :payment_reference_must_change, if: :payment_reference_present_in_params? + + attr_accessor :payment_reference_in_params + def self.search(params = {}) sort_column = params[:sort].presence_in(%w[invoice_number status affiliation]) || 'id' sort_direction = params[:direction].presence_in(%w[asc desc]) || 'desc' @@ -61,6 +65,10 @@ def billing_system? initiator == BILLING_SYSTEM end + def fully_paid?(amount) + amount.to_f >= transaction_amount.to_f + end + def to_h { invoice_number:, @@ -74,4 +82,16 @@ def to_h sent_at_omniva: } end + + private + + def payment_reference_present_in_params? + payment_reference_in_params + end + + def payment_reference_must_change + return unless payment_reference.present? && payment_reference == payment_reference_was + + errors.add(:payment_reference, 'must be different from the existing payment reference') + end end diff --git a/app/services/notify.rb b/app/services/notify.rb index 623ff82..f942dba 100644 --- a/app/services/notify.rb +++ b/app/services/notify.rb @@ -29,8 +29,8 @@ def self.call(response:) end return if invoice.paid? - notifier.update_invoice_state(parsed_response:, invoice:) - return unless invoice.paid? + return unless notifier.update_invoice_state(parsed_response:, invoice:) + return if !invoice.paid? && !invoice.partially_paid? return if invoice.billing_system? url = notifier.get_update_payment_url[invoice.initiator.to_sym] @@ -68,12 +68,21 @@ def notify(title:, error_message:) end def update_invoice_state(parsed_response:, invoice:) - status = parsed_response[:payment_state] == SETTLED ? :paid : :failed + paid_status = invoice.fully_paid?(parsed_response[:initial_amount]) ? :paid : :partially_paid + status = parsed_response[:payment_state] == SETTLED ? paid_status : :failed - invoice.update(payment_reference: parsed_response[:payment_reference], - status:, - transaction_time: parsed_response[:transaction_time], - everypay_response: parsed_response) + invoice.payment_reference_in_params = parsed_response.key?(:payment_reference) + invoice.assign_attributes( + payment_reference: parsed_response[:payment_reference], + status:, + transaction_time: parsed_response[:transaction_time], + everypay_response: parsed_response + ) + + return true if invoice.save + + Rails.logger.info("Error saving invoice #{invoice.invoice_number}: #{invoice.errors.full_messages.to_sentence}") + false end def invoice_numbers_from_multi_payment(invoice) diff --git a/app/services/oneoff.rb b/app/services/oneoff.rb index 1191278..837c454 100644 --- a/app/services/oneoff.rb +++ b/app/services/oneoff.rb @@ -4,22 +4,24 @@ class Oneoff include Request include ApplicationService - attr_reader :invoice_number, :customer_url, :reference_number, :bulk, :bulk_invoices + attr_reader :invoice_number, :customer_url, :reference_number, :amount, :bulk, :bulk_invoices - def initialize(invoice_number:, customer_url:, reference_number:, bulk: false, bulk_invoices: []) + def initialize(invoice_number:, customer_url:, reference_number:, amount: nil, bulk: false, bulk_invoices: []) @invoice = Invoice.find_by(invoice_number: invoice_number) @invoice_number = invoice_number @customer_url = customer_url @reference_number = reference_number + @amount = amount @bulk = bulk @bulk_invoices = bulk_invoices end - def self.call(invoice_number:, customer_url:, reference_number:, bulk: false, bulk_invoices: []) + def self.call(invoice_number:, customer_url:, reference_number:, amount: nil, bulk: false, bulk_invoices: []) new(invoice_number: invoice_number, customer_url: customer_url, reference_number: reference_number, + amount: amount, bulk: bulk, bulk_invoices: bulk_invoices).call end @@ -52,6 +54,7 @@ def call def base_request uri = URI("#{GlobalVariable::BASE_ENDPOINT}#{GlobalVariable::ONEOFF_ENDPOINT}") + post(direction: 'everypay', path: uri, params: body) end @@ -61,12 +64,12 @@ def body { 'api_username' => GlobalVariable::API_USERNAME, 'account_name' => GlobalVariable::ACCOUNT_NAME, - 'amount' => @invoice.transaction_amount.to_f, + 'amount' => amount ? amount.to_f : @invoice.transaction_amount.to_f, 'order_reference' => bulk ? bulk_description.to_s : @invoice.invoice_number.to_s, # 'token_agreement' => 'unscheduled', 'request_token' => false, - 'nonce' => "#{rand(10 ** 30).to_s.rjust(30,'0')}", - 'timestamp' => "#{Time.zone.now.to_formatted_s(:iso8601)}", + 'nonce' => rand(10**30).to_s.rjust(30, '0'), + 'timestamp' => Time.zone.now.to_formatted_s(:iso8601), 'customer_url' => customer_url, 'preferred_country' => 'EE', 'locale' => 'en', diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 607fe95..5324345 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -69,6 +69,7 @@ config.before(:each) do DatabaseCleaner.strategy = :transaction + allow(Rails.logger).to receive(:info) end config.before(:each, :js => true) do diff --git a/spec/requests/api/v1/invoice_generator/oneoff_spec.rb b/spec/requests/api/v1/invoice_generator/oneoff_spec.rb index 5d9c39d..f857c52 100644 --- a/spec/requests/api/v1/invoice_generator/oneoff_spec.rb +++ b/spec/requests/api/v1/invoice_generator/oneoff_spec.rb @@ -18,7 +18,7 @@ .to_return(status: 200, body: payment_link.to_json, headers: {}) end - it 'should return the payment link if intiiator is eeid' do + it 'should return the payment link if initiator is eeid' do payload = { invoice_number: invoice.invoice_number, customer_url: customer_url_eeid, diff --git a/spec/services/notify_spec.rb b/spec/services/notify_spec.rb index 2ccbebc..8e2101c 100644 --- a/spec/services/notify_spec.rb +++ b/spec/services/notify_spec.rb @@ -32,7 +32,8 @@ payment_state: 'settled', transaction_time: Time.zone.now - 1.hour, order_reference: invoice.invoice_number.to_s, - payment_reference: 'test' + payment_reference: 'test', + initial_amount: invoice.transaction_amount } Notify.call(response: JSON.parse(everypay_response.to_json)) @@ -63,7 +64,8 @@ payment_state: 'settled', transaction_time: Time.zone.now - 1.hour, order_reference: "ref:#{invoice_three.invoice_number}, #{invoice_three.description.split(' ').join(', ')}", - payment_reference: 'test' + payment_reference: 'test', + initial_amount: invoice_three.transaction_amount } Notify.call(response: JSON.parse(everypay_response.to_json)) @@ -78,6 +80,51 @@ end end + describe 'partial payment process' do + let(:everypay_response) do + { + payment_state: 'settled', + transaction_time: Time.zone.now - 1.hour, + order_reference: invoice.invoice_number.to_s, + payment_reference: 'test', + initial_amount: invoice.transaction_amount.to_f - 1 + } + end + + it 'should mark single invoice as partially_paid if settled and not fully paid' do + invoice.initiator = 'auction' + invoice.invoice_number = 3 + invoice.save + expect(invoice.initiator).to eq('auction') + expect(invoice.status).to eq('unpaid') + + Notify.call(response: JSON.parse(everypay_response.to_json)) + invoice.reload + + expect(invoice.status).to eq('partially_paid') + + end + + it 'should not update invoice state if payment_reference is the same' do + invoice.save + expect(invoice.status).to eq('unpaid') + + Notify.call(response: JSON.parse(everypay_response.to_json)) + invoice.reload + + expect(invoice.status).to eq('partially_paid') + expect(invoice.payment_reference).to eq('test') + updated_at = invoice.updated_at + + Notify.call(response: JSON.parse(everypay_response.to_json)) + + expect(Rails.logger).to have_received(:info).with( + "Error saving invoice #{invoice.invoice_number}: Payment reference must be different from the existing payment reference" + ) + expect(invoice.updated_at).to eq(updated_at) + end + end + describe 'email notifier' do let!(:admin) { create(:user) } @@ -116,7 +163,8 @@ payment_state: 'settled', transaction_time: Time.zone.now - 1.hour, order_reference: invoice.invoice_number.to_s, - payment_reference: 'test' + payment_reference: 'test', + initial_amount: invoice.transaction_amount } response = Notify.call(response: JSON.parse(everypay_response.to_json)) diff --git a/spec/services/oneoff_spec.rb b/spec/services/oneoff_spec.rb index 3e29ceb..0047fc9 100644 --- a/spec/services/oneoff_spec.rb +++ b/spec/services/oneoff_spec.rb @@ -1,4 +1,6 @@ RSpec.describe Oneoff do + payment_link = { 'payment_link' => 'https://everypay.ee' } + describe 'successful case' do let(:invoice) { create(:invoice) } let(:customer_url_registry) { GlobalVariable::BASE_REGISTRY } @@ -7,8 +9,6 @@ let(:customer_url_auction) { GlobalVariable::BASE_AUCTION } let(:reference) { create(:reference) } - payment_link = { 'payment_link' => 'https://everypay.ee' } - before(:each) do stub_request(:post, "#{GlobalVariable::BASE_ENDPOINT}#{GlobalVariable::ONEOFF_ENDPOINT}") .to_return(status: 200, body: payment_link.to_json, headers: {}) @@ -50,6 +50,25 @@ end end + describe 'with custom amount' do + let(:invoice) { create(:invoice) } + let(:customer_url_auction) { GlobalVariable::BASE_AUCTION } + + before do + stub_request(:post, "#{GlobalVariable::BASE_ENDPOINT}#{GlobalVariable::ONEOFF_ENDPOINT}") + .with(body: /"amount":100.0/) + .to_return(status: 200, body: payment_link.to_json, headers: {}) + end + + it 'should generate oneoff link for auction' do + response = described_class.call(invoice_number: invoice.invoice_number.to_s, + customer_url: customer_url_auction, + reference_number: nil, + amount: 100) + expect(response.instance).to a_hash_including(payment_link) + end + end + describe 'invalid case' do let(:invoice) { create(:invoice) }