diff --git a/Gemfile b/Gemfile index 3857b22e..f51911ef 100755 --- a/Gemfile +++ b/Gemfile @@ -42,7 +42,7 @@ gem 'font_assets' # Database (postgres) gem 'pg' # Postgresql -gem 'qx', git: 'https://github.com/commitchange/ruby-qx.git' +gem 'qx', git: 'https://github.com/ericschultz/ruby-qx.git' gem 'dalli' gem 'memcachier' diff --git a/Gemfile.lock b/Gemfile.lock index a0678661..2fd82ec2 100755 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,14 +5,6 @@ GIT param_validation (0.0.2) chronic -GIT - remote: https://github.com/commitchange/ruby-qx.git - revision: 3582c9a3c5d03f23480bc9b8ff1948a351ed8d6c - specs: - qx (0.1.1) - activerecord (>= 3.0) - colorize (~> 0.8) - GIT remote: https://github.com/commitchange/stripe-ruby-mock.git revision: ee4471a8f654672d5596218c2b68a2913ea3f4cc @@ -32,6 +24,14 @@ GIT grape (> 0.7) rails (> 3.2, < 5) +GIT + remote: https://github.com/ericschultz/ruby-qx.git + revision: 179467cd4a8c791beb36428975c287acc11b98b2 + specs: + qx (0.1.1) + activerecord (>= 3.0) + colorize (~> 0.8) + GIT remote: https://github.com/ruby-grape/grape-entity.git revision: 0e04aa561373b510c2486282979085eaef2ae663 diff --git a/db/migrate/20180713213748_add_charge_id_indexes.rb b/db/migrate/20180713213748_add_charge_id_indexes.rb new file mode 100644 index 00000000..52d50a5e --- /dev/null +++ b/db/migrate/20180713213748_add_charge_id_indexes.rb @@ -0,0 +1,6 @@ +class AddChargeIdIndexes < ActiveRecord::Migration + def change + add_index :refunds, :charge_id + end + +end diff --git a/db/migrate/20180713215825_add_payment_id_to_tickets.rb b/db/migrate/20180713215825_add_payment_id_to_tickets.rb new file mode 100644 index 00000000..bbfb275c --- /dev/null +++ b/db/migrate/20180713215825_add_payment_id_to_tickets.rb @@ -0,0 +1,5 @@ +class AddPaymentIdToTickets < ActiveRecord::Migration + def change + add_index :tickets, :payment_id + end +end diff --git a/db/migrate/20180713220028_add_indexes_to_refunds.rb b/db/migrate/20180713220028_add_indexes_to_refunds.rb new file mode 100644 index 00000000..f618006b --- /dev/null +++ b/db/migrate/20180713220028_add_indexes_to_refunds.rb @@ -0,0 +1,5 @@ +class AddIndexesToRefunds < ActiveRecord::Migration + def change + add_index :refunds, :payment_id + end +end diff --git a/db/structure.sql b/db/structure.sql index c1525056..1aaa1958 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -3162,6 +3162,20 @@ CREATE INDEX index_exports_on_nonprofit_id ON public.exports USING btree (nonpro CREATE INDEX index_exports_on_user_id ON public.exports USING btree (user_id); +-- +-- Name: index_refunds_on_charge_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_refunds_on_charge_id ON public.refunds USING btree (charge_id); + + +-- +-- Name: index_refunds_on_payment_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_refunds_on_payment_id ON public.refunds USING btree (payment_id); + + -- -- Name: index_sessions_on_session_id; Type: INDEX; Schema: public; Owner: - -- @@ -3232,6 +3246,13 @@ CREATE INDEX index_supporters_on_name ON public.supporters USING btree (name); CREATE INDEX index_tickets_on_event_id ON public.tickets USING btree (event_id); +-- +-- Name: index_tickets_on_payment_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_tickets_on_payment_id ON public.tickets USING btree (payment_id); + + -- -- Name: index_tickets_on_supporter_id; Type: INDEX; Schema: public; Owner: - -- @@ -4300,3 +4321,9 @@ INSERT INTO schema_migrations (version) VALUES ('20180608205049'); INSERT INTO schema_migrations (version) VALUES ('20180608212658'); +INSERT INTO schema_migrations (version) VALUES ('20180713213748'); + +INSERT INTO schema_migrations (version) VALUES ('20180713215825'); + +INSERT INTO schema_migrations (version) VALUES ('20180713220028'); + diff --git a/lib/insert/insert_refunds.rb b/lib/insert/insert_refunds.rb index a895608f..c50a7b47 100644 --- a/lib/insert/insert_refunds.rb +++ b/lib/insert/insert_refunds.rb @@ -16,7 +16,7 @@ module InsertRefunds def self.with_stripe(charge, h) ParamValidation.new(charge, { payment_id: {required: true, is_integer: true}, - stripe_charge_id: {required: true, format: /^ch_.*$/}, + stripe_charge_id: {required: true, format: /^(test_)?ch_.*$/}, amount: {required: true, is_integer: true, min: 1}, id: {required: true, is_integer: true}, nonprofit_id: {required: true, is_integer: true}, diff --git a/lib/query/query_payments.rb b/lib/query/query_payments.rb index 6d8d90a8..a958d031 100644 --- a/lib/query/query_payments.rb +++ b/lib/query/query_payments.rb @@ -105,13 +105,49 @@ module QueryPayments end + # we must provide payments.*, supporters.*, donations.*, associated event_id, associated campaign_id def self.full_search_expr(npo_id, query) expr = Qexpr.new.from('payments') .left_outer_join('supporters', "supporters.id=payments.supporter_id") .left_outer_join('donations', 'donations.id=payments.donation_id' ) - .where('payments.nonprofit_id=$id', id: npo_id.to_i) + .join("(#{select_to_filter_search(npo_id, query)}) AS \"filtered_payments\"", 'payments.id = filtered_payments.id') .order_by('payments.date DESC') + if ['asc', 'desc'].include? query[:sort_amount] + expr = expr.order_by("payments.gross_amount #{query[:sort_amount]}") + end + if ['asc', 'desc'].include? query[:sort_date] + expr = expr.order_by("payments.date #{query[:sort_date]}") + end + if ['asc', 'desc'].include? query[:sort_name] + expr = expr.order_by("coalesce(NULLIF(supporters.name, ''), NULLIF(supporters.email, '')) #{query[:sort_name]}") + end + if ['asc', 'desc'].include? query[:sort_type] + expr = expr.order_by("payments.kind #{query[:sort_type]}") + end + if ['asc', 'desc'].include? query[:sort_towards] + expr = expr.order_by("NULLIF(payments.towards, '') #{query[:sort_towards]}") + end + + return expr + end + + # perform the search but only get the relevant payment_ids + def self.select_to_filter_search(npo_id, query) + + inner_donation_search = Qexpr.new.select('donations.*').from('donations') + if (query[:event_id].present?) + inner_donation_search = inner_donation_search.where('donations.event_id=$id', id: query[:event_id]) + end + if (query[:campaign_id].present?) + inner_donation_search = inner_donation_search.where('donations.campaign_id=$id', id: query[:campaign_id]) + end + expr = Qexpr.new.select('payments.id').from('payments') + .left_outer_join('supporters', "supporters.id=payments.supporter_id") + .left_outer_join(inner_donation_search.as('donations'), 'donations.id=payments.donation_id' ) + .where('payments.nonprofit_id=$id', id: npo_id.to_i) + + if query[:search].present? expr = SearchVector.query(query[:search], expr) end @@ -156,16 +192,105 @@ module QueryPayments end if query[:campaign_id].present? expr = expr - .left_outer_join("campaigns", "campaigns.id=donations.campaign_id" ) - .where("campaigns.id=$id", id: query[:campaign_id]) + .left_outer_join("campaigns", "campaigns.id=donations.campaign_id" ) + .where("campaigns.id=$id", id: query[:campaign_id]) end if query[:event_id].present? - tickets_subquery = Qexpr.new.select("payment_id", "MAX(event_id) AS event_id").from("tickets").group_by("payment_id").as("tix") + tickets_subquery = Qexpr.new.select("payment_id", "MAX(event_id) AS event_id").from("tickets").where('tickets.event_id=$event_id', event_id: query[:event_id]).group_by("payment_id").as("tix") expr = expr - .left_outer_join(tickets_subquery, "tix.payment_id=payments.id") - .where("tix.event_id=$id OR donations.event_id=$id", id: query[:event_id]) + .left_outer_join(tickets_subquery, "tix.payment_id=payments.id") + .where("tix.event_id=$id OR donations.event_id=$id", id: query[:event_id]) + end - return expr + + expr = expr + + #we have the first part of the search. We need to create the second in certain situations + filtered_payment_id_search = expr.parse + + if query[:event_id].present? || query[:campaign_id].present? + filtered_payment_id_search = filtered_payment_id_search + " UNION DISTINCT " + create_reverse_select(npo_id, query).parse + end + + + + filtered_payment_id_search + end + + # we use this when we need to get the refund info + def self.create_reverse_select(npo_id, query) + inner_donation_search = Qexpr.new.select('donations.*').from('donations') + if (query[:event_id].present?) + inner_donation_search = inner_donation_search.where('donations.event_id=$id', id: query[:event_id]) + end + if (query[:campaign_id].present?) + inner_donation_search = inner_donation_search.where('donations.campaign_id=$id', id: query[:campaign_id]) + end + expr = Qexpr.new.select('payments.id').from('payments') + .left_outer_join('supporters', "supporters.id=payments.supporter_id") + .left_outer_join('refunds', 'payments.id=refunds.payment_id') + .left_outer_join('charges', 'refunds.charge_id=charges.id') + .left_outer_join('payments AS payments_orig', 'payments_orig.id=charges.payment_id') + .left_outer_join(inner_donation_search.as('donations'), 'donations.id=payments_orig.donation_id' ) + .where('payments.nonprofit_id=$id', id: npo_id.to_i) + + + if query[:search].present? + expr = SearchVector.query(query[:search], expr) + end + if ['asc', 'desc'].include? query[:sort_amount] + expr = expr.order_by("payments.gross_amount #{query[:sort_amount]}") + end + if ['asc', 'desc'].include? query[:sort_date] + expr = expr.order_by("payments.date #{query[:sort_date]}") + end + if ['asc', 'desc'].include? query[:sort_name] + expr = expr.order_by("coalesce(NULLIF(supporters.name, ''), NULLIF(supporters.email, '')) #{query[:sort_name]}") + end + if ['asc', 'desc'].include? query[:sort_type] + expr = expr.order_by("payments.kind #{query[:sort_type]}") + end + if ['asc', 'desc'].include? query[:sort_towards] + expr = expr.order_by("NULLIF(payments.towards, '') #{query[:sort_towards]}") + end + if query[:after_date].present? + expr = expr.where('payments.date >= $date', date: query[:after_date]) + end + if query[:before_date].present? + expr = expr.where('payments.date <= $date', date: query[:before_date]) + end + if query[:amount_greater_than].present? + expr = expr.where('payments.gross_amount >= $amt', amt: query[:amount_greater_than].to_i * 100) + end + if query[:amount_less_than].present? + expr = expr.where('payments.gross_amount <= $amt', amt: query[:amount_less_than].to_i * 100) + end + if query[:year].present? + expr = expr.where("to_char(payments.date, 'YYYY')=$year", year: query[:year]) + end + if query[:designation].present? + expr = expr.where("donations.designation @@ $s", s: "#{query[:designation]}") + end + if query[:dedication].present? + expr = expr.where("donations.dedication @@ $s", s: "#{query[:dedication]}") + end + if query[:donation_type].present? + expr = expr.where('payments.kind IN ($kinds)', kinds: query[:donation_type].split(',')) + end + if query[:campaign_id].present? + expr = expr + .left_outer_join("campaigns", "campaigns.id=donations.campaign_id" ) + .where("campaigns.id=$id", id: query[:campaign_id]) + end + if query[:event_id].present? + tickets_subquery = Qexpr.new.select("payment_id", "MAX(event_id) AS event_id").from("tickets").where('tickets.event_id=$event_id', event_id: query[:event_id]).group_by("payment_id").as("tix") + expr = expr + .left_outer_join(tickets_subquery, "tix.payment_id=payments_orig.id") + .where("tix.event_id=$id OR donations.event_id=$id", id: query[:event_id]) + + end + + expr end def self.for_export_enumerable(npo_id, query, chunk_limit=35000) diff --git a/spec/lib/insert/insert_donation_spec.rb b/spec/lib/insert/insert_donation_spec.rb index 542841d0..a3c3349c 100644 --- a/spec/lib/insert/insert_donation_spec.rb +++ b/spec/lib/insert/insert_donation_spec.rb @@ -8,6 +8,10 @@ describe InsertDonation do Settings.payment_provider.stripe_connect = true } + after(:each) { + Settings.reload! + } + include_context :shared_rd_donation_value_context describe 'param validation' do diff --git a/spec/lib/query/query_payments_spec.rb b/spec/lib/query/query_payments_spec.rb index 09ef376d..7345e08b 100644 --- a/spec/lib/query/query_payments_spec.rb +++ b/spec/lib/query/query_payments_spec.rb @@ -164,4 +164,235 @@ describe QueryPayments do end end + + describe '.full_search' do + + include_context :shared_rd_donation_value_context + before(:each) { + nonprofit.stripe_account_id = Stripe::Account.create()['id'] + nonprofit.save! + card.stripe_customer_id = 'some other id' + cust = Stripe::Customer.create() + card.stripe_customer_id = cust['id'] + card.save! + expect(Stripe::Charge).to receive(:create).exactly(3).times.and_wrap_original {|m, *args| a = m.call(*args); + @stripe_charge_id = a['id'] + a + } + + allow(QueueDonations).to receive(:execute_for_donation) + + } + + let(:charge_amount_small) { 200} + let(:charge_amount_medium) { 400} + let(:charge_amount_large) { 600} + + def generate_donation(h) + token = h[:token] + date = h[:date] + amount = h[:amount] + + input = {amount: amount, + nonprofit_id: nonprofit.id, + supporter_id: supporter.id, + token: token, + + date: date, + dedication: 'dedication', + designation: 'designation'} + if h[:event_id] + input[:event_id] = h[:event_id] + end + + if h[:campaign_id] + input[:campaign_id] = h[:campaign_id] + end + + InsertDonation.with_stripe(input) + end + + describe 'general donations' do + let(:donation_result_yesterday) { + generate_donation(amount: charge_amount_small, + + token: source_tokens[0].token, + date: (Time.now - 1.day).to_s) + + + } + + let(:donation_result_today) { + + generate_donation(amount: charge_amount_medium, + + token: source_tokens[1].token, + + date: (Time.now).to_s + ) + } + + let(:donation_result_tomorrow) { + + generate_donation(amount: charge_amount_large, + + token: source_tokens[2].token, + date: (Time.now - 1.day).to_s + ) + + } + + let (:first_refund_of_yesterday) { + charge = donation_result_yesterday['charge'] + + InsertRefunds.with_stripe(charge.attributes, {amount: 100}.with_indifferent_access) + + } + + let(:second_refund_of_yesterday) { + charge = donation_result_yesterday['charge'] + + InsertRefunds.with_stripe(charge.attributes, {amount: 50}.with_indifferent_access) + + } + + + it 'empty filter returns all' do + donation_result_yesterday + donation_result_today + donation_result_tomorrow + first_refund_of_yesterday + second_refund_of_yesterday + + result = QueryPayments::full_search(nonprofit.id, {}) + + expect(result[:data].count).to eq 5 + end + + + + + + + + end + + describe 'event donations' do + let(:donation_result_yesterday) { + generate_donation(amount: charge_amount_small, + event_id: event.id, + token: source_tokens[0].token, + date: (Time.now - 1.day).to_s) + + + } + + let(:donation_result_today) { + + generate_donation(amount: charge_amount_medium, + event_id: event.id, + token: source_tokens[1].token, + + date: (Time.now).to_s + ) + } + + let(:donation_result_tomorrow) { + + generate_donation(amount: charge_amount_large, + + token: source_tokens[2].token, + date: (Time.now - 1.day).to_s + ) + + } + + let (:first_refund_of_yesterday) { + charge = donation_result_yesterday['charge'] + + InsertRefunds.with_stripe(charge.attributes, {amount: 100}.with_indifferent_access) + + } + + let(:second_refund_of_yesterday) { + charge = donation_result_yesterday['charge'] + + InsertRefunds.with_stripe(charge.attributes, {amount: 50}.with_indifferent_access) + + } + + it 'search includes refunds for that event ' do + donation_result_yesterday + donation_result_today + donation_result_tomorrow + first_refund_of_yesterday + second_refund_of_yesterday + + result = QueryPayments::full_search(nonprofit.id, {event_id: event.id}) + + expect(result[:data].count).to eq 4 + expect(result[:data]).to_not satisfy {|i| i.any?{|j| j['id'] == donation_result_tomorrow['payment']['id']}} + end + end + + describe 'campaign donations' do + let(:donation_result_yesterday) { + generate_donation(amount: charge_amount_small, + campaign_id:campaign.id, + token: source_tokens[0].token, + date: (Time.now - 1.day).to_s) + + + } + + let(:donation_result_today) { + + generate_donation(amount: charge_amount_medium, + campaign_id:campaign.id, + token: source_tokens[1].token, + + date: (Time.now).to_s + ) + } + + let(:donation_result_tomorrow) { + + generate_donation(amount: charge_amount_large, + + token: source_tokens[2].token, + date: (Time.now - 1.day).to_s + ) + + } + + let (:first_refund_of_yesterday) { + charge = donation_result_yesterday['charge'] + + InsertRefunds.with_stripe(charge.attributes, {amount: 100}.with_indifferent_access) + + } + + let(:second_refund_of_yesterday) { + charge = donation_result_yesterday['charge'] + + InsertRefunds.with_stripe(charge.attributes, {amount: 50}.with_indifferent_access) + + } + + + it 'search includes refunds for that campaign ' do + donation_result_yesterday + donation_result_today + donation_result_tomorrow + first_refund_of_yesterday + second_refund_of_yesterday + + result = QueryPayments::full_search(nonprofit.id, {campaign_id: campaign.id}) + + expect(result[:data].count).to eq 4 + expect(result[:data]).to_not satisfy {|i| i.any?{|j| j['id'] == donation_result_tomorrow['payment']['id']}} + end + end + + end end diff --git a/spec/support/contexts/shared_donation_charge_context.rb b/spec/support/contexts/shared_donation_charge_context.rb index de921fb6..2e0d2a63 100644 --- a/spec/support/contexts/shared_donation_charge_context.rb +++ b/spec/support/contexts/shared_donation_charge_context.rb @@ -2,7 +2,7 @@ require 'stripe_mock' RSpec.shared_context :shared_donation_charge_context do - let(:nonprofit) { force_create(:nonprofit, name: "nonprofit name")} + let(:nonprofit) { force_create(:nonprofit, name: "nonprofit name", slug: 'nonprofit_nameo')} let(:other_nonprofit) { force_create(:nonprofit)} let(:supporter) {force_create(:supporter, :nonprofit => nonprofit, locale: 'locale_one')} let(:other_nonprofit_supporter) { force_create(:supporter, nonprofit: other_nonprofit, locale: 'locale_two')} diff --git a/spec/support/contexts/shared_rd_donation_value_context.rb b/spec/support/contexts/shared_rd_donation_value_context.rb index 4cba0c7f..6a95a85c 100644 --- a/spec/support/contexts/shared_rd_donation_value_context.rb +++ b/spec/support/contexts/shared_rd_donation_value_context.rb @@ -6,7 +6,13 @@ RSpec.shared_context :shared_rd_donation_value_context do let(:fake_uuid) {"53a6bc06-0789-11e8-bb3f-f34cac607737"} let(:valid_uuid) {'fcf61bac-078a-11e8-aa53-cba5bdb8dcdd'} let(:other_uuid) {'a713018c-078f-11e8-ae3b-bf5007844fea'} - let(:source_token) {force_create(:source_token, tokenizable: card, expiration: Time.now + 1.day, max_uses: 1, token: valid_uuid)} + let(:source_token) {force_create(:source_token, tokenizable: card, expiration: Time.now + 1.day, max_uses: 1, token: valid_uuid) } + let(:source_tokens) { + (0..10).map { |i| + force_create(:source_token, tokenizable: card, expiration: Time.now + 1.day, max_uses: 1, token: SecureRandom.uuid) + } + } + let(:other_source_token) {force_create(:source_token, tokenizable: card_for_other_supporter, expiration: Time.now + 1.day, max_uses: 1, token: other_uuid)} let(:charge_amount) {100} @@ -147,6 +153,11 @@ RSpec.shared_context :shared_rd_donation_value_context do result end + def generate_expected_refund(data={}) + result = {}.with_indifferent_access + result + end + def validation_unauthorized expect(QuerySourceToken).to receive(:get_and_increment_source_token).with(fake_uuid, nil).and_raise(AuthenticationError) expect {yield()}.to raise_error {|e| @@ -382,6 +393,24 @@ RSpec.shared_context :shared_rd_donation_value_context do end end + def before_each_successful_refund() + expect(InsertRefund).to receive(:with_stripe).and_wrap_original do |m, *args| + result = m.call(*args); + @all_refunds&.push(result) || @all_refunds = [result] + @fifo_refunds&.unshift(result) || @fifo_refunds = [ result] + + result + end + + expect(Stripe::Refund).to receive(:create).and_wrap_original do |m, *args| + a = m.call(*args); + @stripe_refund_ids&.unshift(a['id']) || @stripe_refund_ids = [a['id']]; + a + end + + end + + def before_each_sepa_success() expect(InsertDonation).to receive(:insert_donation).and_wrap_original do |m, *args| result = m.call(*args); @@ -406,6 +435,8 @@ RSpec.shared_context :shared_rd_donation_value_context do if (data[:recurring_donation]) expect(result['recurring_donation'].attributes).to eq expected[:recurring_donation] end + + return result end def process_campaign_donation(data = {}) @@ -422,6 +453,7 @@ RSpec.shared_context :shared_rd_donation_value_context do if (data[:recurring_donation]) expect(result['recurring_donation'].attributes).to eq expected[:recurring_donation] end + return result end def process_general_donation(data = {}) @@ -447,6 +479,17 @@ RSpec.shared_context :shared_rd_donation_value_context do if (data[:recurring_donation]) expect(result['recurring_donation'].attributes).to eq expected[:recurring_donation] end + return result + end + + def process_general_refund(data = {}) + result = yield + + expected = generate_expected_refund() + + expect(result['payment']).to eq expected[:payment] + expect(result['refund']).to eq expected[:refund] + return result end def nil_or_true(item)