# frozen_string_literal: true # License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later require 'qexpr' require 'psql' module QueryRecurringDonations # Calculate a nonprofit's total recurring donations def self.monthly_total(np_id) Qx.select('coalesce(sum(amount), 0) AS sum') .from('recurring_donations') .where(nonprofit_id: np_id) .and_where(is_external_active_clause('recurring_donations')) .execute.first['sum'] end # Fetch a single recurring donation for its edit page def self.fetch_for_edit(id) recurring_donation = Psql.execute( Qexpr.new.select( 'recurring_donations.*', 'nonprofits.id AS nonprofit_id', 'nonprofits.name AS nonprofit_name', 'cards.name AS card_name' ).from('recurring_donations') .left_outer_join('donations', 'donations.id=recurring_donations.donation_id') .left_outer_join('cards', 'donations.card_id=cards.id') .left_outer_join('nonprofits', 'nonprofits.id=recurring_donations.nonprofit_id') .where('recurring_donations.id=$id', id: id) ).first return recurring_donation if !recurring_donation || !recurring_donation['id'] supporter = Psql.execute( Qexpr.new.select('*') .from('supporters') .where('id=$id', id: recurring_donation['supporter_id']) ).first nonprofit = Nonprofit.find(recurring_donation['nonprofit_id']) { 'recurring_donation' => recurring_donation, 'supporter' => supporter, 'nonprofit' => nonprofit } end # Construct a full query for the dashboard/export listings def self.full_search_expr(np_id, query) expr = Qexpr.new .from('recurring_donations') .left_outer_join('supporters', 'supporters.id=recurring_donations.supporter_id') .join('donations', 'donations.id=recurring_donations.donation_id') .left_outer_join('charges paid_charges', 'paid_charges.donation_id=donations.id') .where('recurring_donations.nonprofit_id=$id', id: np_id.to_i) failed_or_active_clauses = [] if query.key?(:active_and_not_failed) clause = query[:active_and_not_failed] ? is_external_active_clause('recurring_donations') : is_external_cancelled_clause('recurring_donations') failed_or_active_clauses.push("(#{clause})") end if query.key?(:active) clause = query[:active] ? is_active_clause('recurring_donations') : is_cancelled_clause('recurring_donations') failed_or_active_clauses.push("(#{clause})") end if query.key?(:failed) clause = query[:failed] ? is_failed_clause('recurring_donations') : is_not_failed_clause('recurring_donations') failed_or_active_clauses.push("(#{clause})") end if failed_or_active_clauses.any? expr = expr.where(failed_or_active_clauses.join(' OR ').to_s) end expr = expr.where("paid_charges.id IS NULL OR paid_charges.status != 'failed'") .group_by('recurring_donations.id') .order_by('recurring_donations.created_at') if query[:search].present? matcher = "%#{query[:search].downcase.split(' ').join('%')}%" expr = expr.where(%(( lower(supporters.name) LIKE $name OR lower(supporters.email) LIKE $email OR recurring_donations.amount=$amount OR recurring_donations.id=$id )), name: matcher, email: matcher, amount: query[:search].to_i, id: query[:search].to_i) end expr end # Fetch the full table of results for the dashboard def self.full_list(np_id, query = {}) limit = 30 offset = Qexpr.page_offset(limit, query[:page]) expr = full_search_expr(np_id, query).select( 'recurring_donations.start_date', 'recurring_donations.interval', 'recurring_donations.time_unit', 'recurring_donations.n_failures', 'recurring_donations.amount', 'recurring_donations.id AS id', 'MAX(supporters.email) AS email', 'MAX(supporters.name) AS name', 'MAX(supporters.id) AS supporter_id', 'SUM(paid_charges.amount) AS total_given' ) .limit(limit).offset(offset) data = Psql.execute(expr) total_count = Psql.execute( Qexpr.new.select('COUNT(rds)') .from(full_search_expr(np_id, query).remove(:order_by).select('recurring_donations.id'), 'rds') ).first['count'] total_amount = monthly_total(np_id) { data: data, total_amount: total_amount, total_count: total_count, remaining: Qexpr.remaining_count(total_count, limit, query[:page]) } end def self.for_export_enumerable(npo_id, query, chunk_limit = 35_000) ParamValidation.new({ npo_id: npo_id, query: query }, npo_id: { required: true, is_int: true }, query: { required: true, is_hash: true }) QexprQueryChunker.for_export_enumerable(chunk_limit) do |offset, limit, skip_header| get_chunk_of_export(npo_id, query, offset, limit, skip_header) end end def self.get_chunk_of_export(npo_id, query, offset = nil, limit = nil, skip_header = false) root_url = query[:root_url] QexprQueryChunker.get_chunk_of_query(offset, limit, skip_header) do full_search_expr(npo_id, query).select( 'recurring_donations.created_at', '(recurring_donations.amount / 100.0)::money::text AS amount', "concat('Every ', recurring_donations.interval, ' ', recurring_donations.time_unit, '(s)') AS interval", '(SUM(paid_charges.amount) / 100.0)::money::text AS total_contributed', 'MAX(campaigns.name) AS campaign_name', 'MAX(supporters.name) AS supporter_name', 'MAX(supporters.email) AS supporter_email', 'MAX(supporters.phone) AS phone', 'MAX(supporters.address) AS address', 'MAX(supporters.city) AS city', 'MAX(supporters.state_code) AS state', 'MAX(supporters.zip_code) AS zip_code', 'MAX(cards.name) AS card_name', 'recurring_donations.id AS "Recurring Donation ID"', 'MAX(donations.id) AS "Donation ID"', "CASE WHEN #{is_cancelled_clause('recurring_donations')} THEN 'true' ELSE 'false' END AS Cancelled", "CASE WHEN #{is_failed_clause('recurring_donations')} THEN 'true' ELSE 'false' END AS Failed", 'recurring_donations.cancelled_at AS "Cancelled At"', "CASE WHEN #{is_active_clause('recurring_donations')} THEN concat('#{root_url}recurring_donations/', recurring_donations.id, '/edit?t=', recurring_donations.edit_token) ELSE '' END AS \"Donation Management Url\"" ) .left_outer_join('campaigns', 'campaigns.id=donations.campaign_id') .left_outer_join('cards', 'cards.id=donations.card_id') end end def self.recurring_donations_without_cards RecurringDonation.active.includes(:card).includes(:charges).includes(:donation).includes(:nonprofit).includes(:supporter).where('cards.id IS NULL').order('recurring_donations.created_at DESC') end def self._splitting_rd_supporters_without_cards supporters_with_valid_rds = [] supporters_without_valid_rds = [] send_to_wendy = [] supporters_with_cardless_rds = recurring_donations_without_cards.map(&:supporter).uniq(&:id) # does the supporter have even one rd with a valid card supporters_with_cardless_rds.each do |s| valid_rd = find_recurring_donation_with_a_card(s) # they have a recurring donation with a card for the same org if valid_rd if s.recurring_donations.length > 2 # they have too many recurring_donations. Send to wendy send_to_wendy.push(s) else # are the recurring_donations the same amount? if s.recurring_donations[0].amount == s.recurring_donations[1].amount supporters_with_valid_rds.push(s) else # they're not the same amount. We got no clue. Send to Wendy send_to_wendy.push(s) end end else # they have no other recurring donations supporters_without_valid_rds.push(s) end end [supporters_with_valid_rds, send_to_wendy, supporters_without_valid_rds] end # @param [Array] wendy_list_of_supporters # @param [String] path # def self.create_wendy_csv(path, wendy_list_of_supporters) # CSV.open(path, 'wb') {|csv| # csv << ['supporter id', 'nonprofit id', 'supporter name', 'supporter address', 'supporter city', 'supporter state', 'supporter ZIP', 'supporter country', 'supporter phone', 'supporter email', 'supporter rd amounts'] # wendy_list_of_supporters.each { |s| # amounts = '$'+ s.recurring_donations.active.collect {|rd| Format::Currency.cents_to_dollars(rd.amount)}.join(", $") # csv << [s.id, s.nonprofit.id, s.name, s.address, s.city, s.state_code, s.zip_code, s.country, s.phone, s.email, amounts] # } # } # end # @param [Supporter] supporter def self.find_recurring_donation_with_a_card(supporter) supporter.recurring_donations.select do |rd| !rd.donation.nil? && !rd.donation.card.nil? end .first end # Check if a single recdon is due -- used in PayRecurringDonation.with_stripe def self.is_due?(rd_id) Psql.execute( _all_that_are_due .where('recurring_donations.id=$id', id: rd_id) ).any? end # Sql partial expression # Select all due recurring donations # Can use this for all donations in the db, or extend the query for only those with a nonprofit_id, supporter_id, etc (see is_due?) # XXX horrendous conditional --what is wrong with me? def self._all_that_are_due now = Time.current Qexpr.new.select('recurring_donations.id') .from(:recurring_donations) .where("recurring_donations.active='t'") .where('coalesce(recurring_donations.n_failures, 0) < 3') .where('recurring_donations.start_date IS NULL OR recurring_donations.start_date <= $now', now: now) .where('recurring_donations.end_date IS NULL OR recurring_donations.end_date > $now', now: now) .join('donations', 'recurring_donations.donation_id=donations.id and (donations.payment_provider IS NULL OR donations.payment_provider!=\'sepa\')') .left_outer_join( # Join the most recent paid charge Qexpr.new.select(:donation_id, 'MAX(created_at) AS created_at') .from(:charges) .where("status != 'failed'") .group_by('donation_id') .as('last_charge'), 'last_charge.donation_id=recurring_donations.donation_id' ) .where(%( last_charge.donation_id IS NULL OR ( (recurring_donations.time_unit != 'month' OR recurring_donations.interval != 1) AND last_charge.created_at + concat_ws(' ', recurring_donations.interval, recurring_donations.time_unit)::interval <= $now ) OR ( recurring_donations.time_unit='month' AND recurring_donations.interval=1 AND (last_charge.created_at < $beginning_of_last_month) OR ( recurring_donations.time_unit='month' AND recurring_donations.interval=1 AND (last_charge.created_at < $beginning_of_month) AND ( recurring_donations.paydate IS NOT NULL AND recurring_donations.paydate <= $today OR recurring_donations.paydate IS NULL AND extract(day FROM last_charge.created_at) <= $today ) ) ) ), now: now, beginning_of_month: now.beginning_of_month, beginning_of_last_month: (now - 1.month).beginning_of_month, today: now.day) .order_by('recurring_donations.created_at') end # Some general statistics for a nonprofit def self.overall_stats(np_id) Psql.execute( Qexpr.new.from(:recurring_donations) .select( 'money(avg(recurring_donations.amount) / 100.0) AS average', 'money(coalesce(sum(rds_active.amount), 0) / 100.0) AS active_sum', 'coalesce(count(rds_active), 0) AS active_count', 'money(coalesce(sum(rds_inactive.amount), 0) / 100.0) AS inactive_sum', 'coalesce(count(rds_inactive), 0) AS inactive_count', 'money(coalesce(sum(rds_failed.amount), 0) / 100.0) AS failed_sum', 'coalesce(count(rds_failed), 0) AS failed_count', 'money(coalesce(sum(rds_cancelled.amount), 0) / 100.0) AS cancelled_sum', 'coalesce(count(rds_cancelled), 0) AS cancelled_count' ) .left_outer_join('recurring_donations rds_active', "rds_active.id=recurring_donations.id AND #{is_external_active_clause('rds_active')}") .left_outer_join('recurring_donations rds_inactive', "rds_inactive.id=recurring_donations.id AND #{is_external_cancelled_clause('rds_inactive')}") .left_outer_join('recurring_donations rds_failed', "rds_failed.id=recurring_donations.id AND #{is_failed_clause('rds_failed')}") .left_outer_join('recurring_donations rds_cancelled', "rds_cancelled.id=recurring_donations.id AND #{is_cancelled_clause('rds_cancelled')}") .where('recurring_donations.nonprofit_id=$id', id: np_id) ).first end # External active means what a user would consider active, i.e. a recurring donation that will be paid. # This means it hasn't be cancelled "active='t'" and that it hasn't failed 'n_failures < 3' def self.is_external_active_clause(field_for_rd) "#{is_active_clause(field_for_rd)} AND #{is_not_failed_clause(field_for_rd)}" end def self.is_external_cancelled_clause(field_for_rd) "#{is_cancelled_clause(field_for_rd)} AND #{is_not_failed_clause(field_for_rd)}" end def self.is_active_clause(field_for_rd) "#{field_for_rd}.active='t'" end def self.is_cancelled_clause(field_for_rd) "NOT (#{is_active_clause(field_for_rd)})" end def self.is_not_failed_clause(field_for_rd) "coalesce(#{field_for_rd}.n_failures, 0) < 3" end def self.is_failed_clause(field_for_rd) "coalesce(#{field_for_rd}.n_failures, 0) >= 3" end def self.last_charge Qexpr.new.select(:donation_id, 'MAX(created_at) AS created_at') .from(:charges) .where("status != 'failed'") .group_by('donation_id') .as('last_charge') end def self.export_for_transfer(nonprofit_id) items = RecurringDonation.where('nonprofit_id = ?', nonprofit_id).active.includes('supporter').includes('card').to_a output = items.map do |i| { supporter: i.supporter.id, supporter_name: i.supporter.name, supporter_email: i.supporter.email, amount: i.amount, paydate: i.paydate, card: i.card.stripe_card_id } end output.to_a end end