2019-07-30 21:29:24 +00:00
# frozen_string_literal: true
2018-03-25 16:15:39 +00:00
# License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later
2018-03-25 17:30:42 +00:00
require 'qexpr'
require 'psql'
module QueryRecurringDonations
# Calculate a nonprofit's total recurring donations
def self . monthly_total ( np_id )
2019-07-30 21:29:24 +00:00
Qx . select ( 'coalesce(sum(amount), 0) AS sum' )
. from ( 'recurring_donations' )
2018-03-25 17:30:42 +00:00
. 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 (
2019-07-30 21:29:24 +00:00
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
2018-03-25 17:30:42 +00:00
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' ] )
2019-07-30 21:29:24 +00:00
{
2018-03-25 17:30:42 +00:00
'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
2019-07-30 21:29:24 +00:00
. 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 )
2018-03-25 17:30:42 +00:00
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
2019-07-30 21:29:24 +00:00
if failed_or_active_clauses . any?
expr = expr . where ( failed_or_active_clauses . join ( ' OR ' ) . to_s )
2018-03-25 17:30:42 +00:00
end
2019-07-30 21:29:24 +00:00
expr = expr . where ( " paid_charges.id IS NULL OR paid_charges.status != 'failed' " )
. group_by ( 'recurring_donations.id' )
. order_by ( 'recurring_donations.created_at' )
2018-03-25 17:30:42 +00:00
if query [ :search ] . present?
matcher = " % #{ query [ :search ] . downcase . split ( ' ' ) . join ( '%' ) } % "
2019-07-30 21:29:24 +00:00
expr = expr . where ( %( (
2018-03-25 17:30:42 +00:00
lower ( supporters . name ) LIKE $name
OR lower ( supporters . email ) LIKE $email
OR recurring_donations . amount = $amount
OR recurring_donations . id = $id
2019-07-30 21:29:24 +00:00
) ) , name : matcher , email : matcher , amount : query [ :search ] . to_i , id : query [ :search ] . to_i )
2018-03-25 17:30:42 +00:00
end
2019-07-30 21:29:24 +00:00
expr
2018-03-25 17:30:42 +00:00
end
# Fetch the full table of results for the dashboard
2019-07-30 21:29:24 +00:00
def self . full_list ( np_id , query = { } )
2018-03-25 17:30:42 +00:00
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' ,
2019-07-30 21:29:24 +00:00
'SUM(paid_charges.amount) AS total_given'
)
. limit ( limit ) . offset ( offset )
2018-03-25 17:30:42 +00:00
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 )
2019-07-30 21:29:24 +00:00
{
2018-03-25 17:30:42 +00:00
data : data ,
total_amount : total_amount ,
total_count : total_count ,
2019-07-30 21:29:24 +00:00
remaining : Qexpr . remaining_count ( total_count , limit , query [ :page ] )
2018-03-25 17:30:42 +00:00
}
end
2019-07-30 21:29:24 +00:00
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 } )
2018-03-25 17:30:42 +00:00
2019-07-30 21:29:24 +00:00
QexprQueryChunker . for_export_enumerable ( chunk_limit ) do | offset , limit , skip_header |
2018-03-25 17:30:42 +00:00
get_chunk_of_export ( npo_id , query , offset , limit , skip_header )
end
end
2019-07-30 21:29:24 +00:00
def self . get_chunk_of_export ( npo_id , query , offset = nil , limit = nil , skip_header = false )
2018-03-25 17:30:42 +00:00
root_url = query [ :root_url ]
2019-07-30 21:29:24 +00:00
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
2018-03-25 17:30:42 +00:00
end
def self . recurring_donations_without_cards
2019-07-30 21:29:24 +00:00
RecurringDonation . active . includes ( :card ) . includes ( :charges ) . includes ( :donation ) . includes ( :nonprofit ) . includes ( :supporter ) . where ( 'cards.id IS NULL' ) . order ( 'recurring_donations.created_at DESC' )
2018-03-25 17:30:42 +00:00
end
2019-07-30 21:29:24 +00:00
def self . _splitting_rd_supporters_without_cards
2018-03-25 17:30:42 +00:00
supporters_with_valid_rds = [ ]
supporters_without_valid_rds = [ ]
send_to_wendy = [ ]
2019-07-30 21:29:24 +00:00
supporters_with_cardless_rds = recurring_donations_without_cards . map ( & :supporter ) . uniq ( & :id )
2018-03-25 17:30:42 +00:00
2019-07-30 21:29:24 +00:00
# does the supporter have even one rd with a valid card
supporters_with_cardless_rds . each do | s |
2018-03-25 17:30:42 +00:00
valid_rd = find_recurring_donation_with_a_card ( s )
# they have a recurring donation with a card for the same org
2019-07-30 21:29:24 +00:00
if valid_rd
if s . recurring_donations . length > 2
# they have too many recurring_donations. Send to wendy
2018-03-25 17:30:42 +00:00
send_to_wendy . push ( s )
else
# are the recurring_donations the same amount?
2019-07-30 21:29:24 +00:00
if s . recurring_donations [ 0 ] . amount == s . recurring_donations [ 1 ] . amount
2018-03-25 17:30:42 +00:00
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
2019-07-30 21:29:24 +00:00
end
2018-03-25 17:30:42 +00:00
2019-07-30 21:29:24 +00:00
[ supporters_with_valid_rds , send_to_wendy , supporters_without_valid_rds ]
2018-03-25 17:30:42 +00:00
end
# @param [Array<Supporter>] wendy_list_of_supporters
# @param [String] path
2019-07-30 21:29:24 +00:00
# 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
2018-03-25 17:30:42 +00:00
# @param [Supporter] supporter
def self . find_recurring_donation_with_a_card ( supporter )
2019-07-30 21:29:24 +00:00
supporter . recurring_donations . select do | rd |
! rd . donation . nil? && ! rd . donation . card . nil?
end . first
2018-03-25 17:30:42 +00:00
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
2019-07-30 21:29:24 +00:00
. where ( 'recurring_donations.id=$id' , id : rd_id )
2018-03-25 17:30:42 +00:00
) . 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
2019-07-30 21:29:24 +00:00
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 ( %(
2018-03-25 17:30:42 +00:00
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
)
)
)
2019-07-30 21:29:24 +00:00
) ,
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' )
2018-03-25 17:30:42 +00:00
end
2019-07-30 21:29:24 +00:00
2018-03-25 17:30:42 +00:00
# Some general statistics for a nonprofit
def self . overall_stats ( np_id )
2019-07-30 21:29:24 +00:00
Psql . execute (
2018-03-25 17:30:42 +00:00
Qexpr . new . from ( :recurring_donations )
. select (
2019-07-30 21:29:24 +00:00
'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'
2018-03-25 17:30:42 +00:00
)
. 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' ) } " )
2019-07-30 21:29:24 +00:00
. where ( 'recurring_donations.nonprofit_id=$id' , id : np_id )
2018-03-25 17:30:42 +00:00
) . 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
2018-06-01 21:10:32 +00:00
def self . last_charge
2019-07-30 21:29:24 +00:00
Qexpr . new . select ( :donation_id , 'MAX(created_at) AS created_at' )
. from ( :charges )
. where ( " status != 'failed' " )
. group_by ( 'donation_id' )
. as ( 'last_charge' )
2018-06-01 21:10:32 +00:00
end
def self . export_for_transfer ( nonprofit_id )
2019-07-30 21:29:24 +00:00
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
2018-06-01 21:10:32 +00:00
end
2018-03-25 17:30:42 +00:00
end