6772312ea7
The primary license of the project is changing to: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later with some specific files to be licensed under the one of two licenses: CC0-1.0 LGPL-3.0-or-later This commit is one of the many steps to relicense the entire codebase. Documentation granting permission for this relicensing (from all past contributors who hold copyrights) is on file with Software Freedom Conservancy, Inc.
331 lines
14 KiB
Ruby
331 lines
14 KiB
Ruby
# 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
|