houdini/lib/query/query_recurring_donations.rb

332 lines
14 KiB
Ruby
Raw Normal View History

# 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'])
return {
'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 ')}")
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(%Q((
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
return 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)
return {
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=35000)
ParamValidation.new({npo_id: npo_id, query:query}, {npo_id: {required: true, is_int: true},
query: {required:true, is_hash: true}})
return 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]
return QexprQueryChunker.get_chunk_of_query(offset, limit, skip_header) {
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
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 {|rd| rd.supporter}.uniq{|s| s.id}
#does the supporter have even one rd with a valid card
supporters_with_cardless_rds.each {|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
}
return 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{|rd|
rd.donation != nil && rd.donation.card != nil}.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(%Q(
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)
return 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
end