houdini/lib/query/query_recurring_donations.rb
2020-06-15 10:26:57 -05:00

349 lines
15 KiB
Ruby

# frozen_string_literal: true
# License: AGPL-3.0-or-later WITH WTO-AP-3.0-or-later
# Full license explanation at https://github.com/houdiniproject/houdini/blob/master/LICENSE
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<Supporter>] 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