From 9a2b095b709202501dff2848065ff8f896561ac1 Mon Sep 17 00:00:00 2001 From: Alex Cabal Date: Thu, 5 Dec 2024 14:32:46 -0600 Subject: [PATCH] Allow adjustment of Patrons Circle cost --- config/sql/se/Patrons.sql | 2 + lib/Constants.php | 3 + lib/Enums/CycleType.php | 8 +++ lib/Patron.php | 75 +++++++++++++++++++++- scripts/process-pending-payments | 75 ++++++++++++---------- scripts/update-patrons-circle | 105 +++++++------------------------ templates/DonationCounter.php | 2 +- www/donate/index.php | 6 +- 8 files changed, 153 insertions(+), 123 deletions(-) create mode 100644 lib/Enums/CycleType.php diff --git a/config/sql/se/Patrons.sql b/config/sql/se/Patrons.sql index a3143564..c393c25b 100644 --- a/config/sql/se/Patrons.sql +++ b/config/sql/se/Patrons.sql @@ -3,6 +3,8 @@ CREATE TABLE IF NOT EXISTS `Patrons` ( `IsAnonymous` tinyint(1) unsigned NOT NULL DEFAULT 0, `AlternateName` varchar(80) DEFAULT NULL, `IsSubscribedToEmails` tinyint(1) NOT NULL DEFAULT 1, + `BaseCost` DECIMAL(5,2) UNSIGNED NULL DEFAULT, + `CycleType` enum('monthly','yearly','unlimited') NULL DEFAULT NULL, `Created` datetime NOT NULL, `Ended` datetime DEFAULT NULL, KEY `index2` (`IsAnonymous`,`Ended`), diff --git a/lib/Constants.php b/lib/Constants.php index 410c797b..6b27bb16 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -50,6 +50,9 @@ const ARTWORK_IMAGE_MINIMUM_HEIGHT = 300; const CAPTCHA_IMAGE_HEIGHT = 72; const CAPTCHA_IMAGE_WIDTH = 230; +const PATRONS_CIRCLE_MONTHLY_COST = 15; +const PATRONS_CIRCLE_YEARLY_COST = 150; + // These are defined for convenience, so that getting HTTP input isn't so wordy. const GET = Enums\HttpVariableSource::Get; const POST = Enums\HttpVariableSource::Post; diff --git a/lib/Enums/CycleType.php b/lib/Enums/CycleType.php new file mode 100644 index 00000000..55f13e36 --- /dev/null +++ b/lib/Enums/CycleType.php @@ -0,0 +1,8 @@ +_LastPayment)){ + $this->_LastPayment = Db::Query(' + SELECT * + from Payments + where UserId = ? + order by Created desc + limit 1 + ', [$this->UserId], Payment::class)[0] ?? null; + } + + return $this->_LastPayment; + } + + // ******* // METHODS // ******* @@ -24,13 +47,14 @@ class Patron{ public function Create(): void{ $this->Created = NOW; Db::Query(' - INSERT into Patrons (Created, UserId, IsAnonymous, AlternateName, IsSubscribedToEmails) + INSERT into Patrons (Created, UserId, IsAnonymous, AlternateName, IsSubscribedToEmails, BaseCost, CycleType) values(?, ?, ?, ?, + ?, ?) - ', [$this->Created, $this->UserId, $this->IsAnonymous, $this->AlternateName, $this->IsSubscribedToEmails]); + ', [$this->Created, $this->UserId, $this->IsAnonymous, $this->AlternateName, $this->IsSubscribedToEmails, $this->BaseCost, $this->CycleType]); Db::Query(' INSERT into Benefits (UserId, CanVote, CanAccessFeeds, CanBulkDownload) @@ -76,6 +100,49 @@ class Patron{ } } + public function End(?int $ebooksThisYear): void{ + if($ebooksThisYear === null){ + $ebooksThisYear = Db::QueryInt('SELECT count(*) from Ebooks where EbookCreated >= ? - interval 1 year', [NOW]); + } + + Db::Query(' + UPDATE Patrons + set Ended = ? + where UserId = ? + ', [NOW, $this->UserId]); + + Db::Query(' + UPDATE Benefits + set CanAccessFeeds = false, + CanVote = false, + CanBulkDownload = false + where UserId = ? + ', [$this->UserId]); + + // Email the patron to notify them their term has ended. + if($this->LastPayment !== null && $this->User->Email !== null){ + $em = new Email(); + $em->From = EDITOR_IN_CHIEF_EMAIL_ADDRESS; + $em->FromName = EDITOR_IN_CHIEF_NAME; + $em->To = $this->User->Email; + $em->ToName = $this->User->Name ?? ''; + $em->Subject = 'Will you continue to help us make free, beautiful digital literature?'; + + if($this->CycleType == Enums\CycleType::Monthly){ + // Email recurring donors who have lapsed. + $em->Body = Template::EmailPatronsCircleRecurringCompleted(); + $em->TextBody = Template::EmailPatronsCircleRecurringCompletedText(); + } + else{ + // Email one time donors who have expired after one year. + $em->Body = Template::EmailPatronsCircleCompleted(['ebooksThisYear' => $ebooksThisYear]); + $em->TextBody = Template::EmailPatronsCircleCompletedText(['ebooksThisYear' => $ebooksThisYear]); + } + + $em->Send(); + } + } + // *********** // ORM METHODS @@ -93,6 +160,8 @@ class Patron{ SELECT * from Patrons where UserId = ? + order by Created desc + limit 1 ', [$userId], Patron::class); return $result[0] ?? throw new Exceptions\PatronNotFoundException();; @@ -111,6 +180,8 @@ class Patron{ from Patrons p inner join Users u using(UserId) where u.Email = ? + order by p.Created desc + limit 1 ', [$email], Patron::class); return $result[0] ?? throw new Exceptions\PatronNotFoundException(); diff --git a/scripts/process-pending-payments b/scripts/process-pending-payments index 0a58c4cd..fb4b0290 100755 --- a/scripts/process-pending-payments +++ b/scripts/process-pending-payments @@ -37,12 +37,12 @@ $faUsername = get_cfg_var('se.secrets.fractured_atlas.username'); $faPassword = get_cfg_var('se.secrets.fractured_atlas.password'); // Test donations -// fa000cbf-af6f-4c14-8919-da6cf81a27ea Regular donation, patrons, public, recurring -// a010dcaf-d2ab-49da-878c-cb447b12152e Regular donation, non-patrons, private, one time -// 5a544447-708d-43da-a7b8-7bd8d9804652 AOGF donation, patrons, public, one time -// e097c777-e2d8-4b21-b99c-e83da8696af8 AOGF donation, non-patrons, anonymous, one time -// 946554ca-ffc0-4259-bcc6-be6c844fbbdc Regular donation, patrons, private, recurring -// 416608c6-cbf5-4153-8956-cb9051bb849e Regular donation, patrons, public, one time, in memory of +// `fa000cbf-af6f-4c14-8919-da6cf81a27ea` Regular donation, patrons, public, recurring. +// `a010dcaf-d2ab-49da-878c-cb447b12152e` Regular donation, non-patrons, private, one time. +// `5a544447-708d-43da-a7b8-7bd8d9804652` AOGF donation, patrons, public, one time. +// `e097c777-e2d8-4b21-b99c-e83da8696af8` AOGF donation, non-patrons, anonymous, one time. +// `946554ca-ffc0-4259-bcc6-be6c844fbbdc` Regular donation, patrons, private, recurring. +// `416608c6-cbf5-4153-8956-cb9051bb849e` Regular donation, patrons, public, one time, in memory of. Db::Query('start transaction'); @@ -108,7 +108,7 @@ try{ } // Wait until the page finishes loading. - // We have to expand the row before we can select its contents, so click the 'expand' button once it's visible. + // We have to expand the row before we can select its contents, so click the "expand" button once it's visible. try{ /** @var WebDriverElement $toggleButton */ $toggleButton = $driver->wait(20, 250)->until(WebDriverExpectedCondition::visibilityOfElementLocated(WebDriverBy::xpath('//button[contains(@class, "button-toggle")]'))); @@ -132,7 +132,7 @@ try{ $payment->Processor = $pendingPayment->Processor; $hasSoftCredit = false; try{ - // If the donation is via a foundation (like American Online Giving Foundation) then there will be a 'soft credit' element. + // If the donation is via a foundation (like American Online Giving Foundation) then there will be a "soft credit" `` element. if(sizeof($detailsRow->findElements(WebDriverBy::xpath('//th[normalize-space(.) = "Soft Credit Donor Info"]'))) > 0){ // We're a foundation donation $payment->User->Name = trim($detailsRow->findElement(WebDriverBy::xpath('//td[preceding-sibling::th[normalize-space(.) = "Name"] and (ancestor::tbody[1])[(./preceding-sibling::thead[1])//th[normalize-space(.) = "Soft Credit Donor Info"]]]'))->getText()); @@ -147,7 +147,7 @@ try{ // These donations are typically (always?) employer matches. // FA does not provide a way to connect the original donation with the employer match. - // Example bbf87b83-d341-426f-b6c9-9091e3222e57 + // See donation `bbf87b83-d341-426f-b6c9-9091e3222e57`. if($payment->User->Name == 'American Online Giving Foundation'){ $payment->IsMatchingDonation = true; } @@ -173,7 +173,7 @@ try{ $payment->TransactionId = trim($transactionId); // We might also get a case where the donation is on behalf of a company match, but there's not really a way to distinguish that. Do a rough check. - // See donation 00b60a22-eafa-44cb-9850-54bef9763e8d + // See donation `00b60a22-eafa-44cb-9850-54bef9763e8d`. if($payment->User !== null && !$hasSoftCredit && preg_match('/\b(L\.?L\.?C\.?|Foundation|President|Fund|Charitable)\b/ius', $payment->User->Name ?? '')){ $payment->User = null; } @@ -194,7 +194,7 @@ try{ ( $payment->IsRecurring && - $payment->Amount >= 10 + $payment->Amount >= PATRONS_CIRCLE_MONTHLY_COST && $payment->Created >= $lastMonth ) @@ -202,22 +202,17 @@ try{ ( !$payment->IsRecurring && - $payment->Amount >= 100 + $payment->Amount >= PATRONS_CIRCLE_YEARLY_COST && $payment->Created >= $lastYear ) ){ // This payment is eligible for the Patrons Circle! if($payment->UserId !== null && $payment->User !== null){ + $patron = Db::Query('SELECT * from Patrons where UserId = ? and Ended is null', [$payment->UserId], Patron::class)[0] ?? null; + // Are we already a patron? - if(!Db::QueryBool(' - SELECT exists( - select * - from Patrons - where UserId = ? - and Ended is null - ) - ', [$payment->UserId])){ + if($patron === null){ // Not a patron yet, add them to the Patrons Circle. $patron = new Patron(); @@ -231,25 +226,39 @@ try{ $patron->AlternateName = trim($detailsRow->findElement(WebDriverBy::xpath('//td[preceding-sibling::th[normalize-space(.) = "Attribution Text"]]'))->getText()); } catch(Exception){ + // Pass. + } + + if($payment->IsRecurring){ + $patron->BaseCost = PATRONS_CIRCLE_MONTHLY_COST; + $patron->CycleType == Enums\CycleType::Monthly; + } + else{ + $patron->BaseCost = PATRONS_CIRCLE_YEARLY_COST; + $patron->CycleType == Enums\CycleType::Yearly; } $log->Write('Adding donor as patron ...'); $patron->Create(); } - elseif(!$payment->IsRecurring && !$payment->IsMatchingDonation){ - // User is already a patron, but they made another non-recurring, non-matching donation. - // Send a thank-you email. + else{ + // User is already a patron. + // We may get a case where an existing Patron makes another donation that + if(!$payment->IsRecurring && !$payment->IsMatchingDonation){ + // User is already a Patron, but they made another non-recurring, non-matching donation. + // Send a thank-you email. - $log->Write('Sending thank you email to patron donor donating extra.'); - $em = new Email(); - $em->To = $payment->User->Email ?? ''; - $em->ToName = $payment->User->Name ?? ''; - $em->From = EDITOR_IN_CHIEF_EMAIL_ADDRESS; - $em->FromName = EDITOR_IN_CHIEF_NAME; - $em->Subject = 'Thank you for supporting Standard Ebooks!'; - $em->Body = Template::EmailDonationThankYou(); - $em->TextBody = Template::EmailDonationThankYouText(); - $em->Send(); + $log->Write('Sending thank you email to patron donor donating extra.'); + $em = new Email(); + $em->To = $payment->User->Email ?? ''; + $em->ToName = $payment->User->Name ?? ''; + $em->From = EDITOR_IN_CHIEF_EMAIL_ADDRESS; + $em->FromName = EDITOR_IN_CHIEF_NAME; + $em->Subject = 'Thank you for supporting Standard Ebooks!'; + $em->Body = Template::EmailDonationThankYou(); + $em->TextBody = Template::EmailDonationThankYouText(); + $em->Send(); + } } } elseif(!$payment->IsRecurring && !$payment->IsMatchingDonation){ diff --git a/scripts/update-patrons-circle b/scripts/update-patrons-circle index 55f619f0..44e0a479 100755 --- a/scripts/update-patrons-circle +++ b/scripts/update-patrons-circle @@ -1,98 +1,35 @@ #!/usr/bin/php = 10 and Created > ? - interval 45 day) - or - (IsRecurring = false and Amount >= 100 and Created > ? - interval 1 year) - ) + select distinct p.UserId from Patrons p + inner join Payments py + using (UserId) + where + p.Ended is null and + ( + (IsRecurring = true and CycleType = ? and Amount >= p.BaseCost and py.Created > ? - interval 45 day) + or + (IsRecurring = false and CycleType = ? and Amount >= p.BaseCost and py.Created > ? - interval 1 year) + ) ) -', [NOW, NOW], Patron::class); +', [Enums\CycleType::Monthly, NOW, Enums\CycleType::Yearly, NOW], Patron::class); if(sizeof($expiredPatrons) > 0){ - $ebooksThisYear = 0; - - // We can't use the Library class to get ebooks because this script is typically run via cron or CLI, which doesn't have access PHP-FMP's APCu cache. - foreach(explode("\n", trim(shell_exec('find ' . EBOOKS_DIST_PATH . ' -name "content.opf"'))) as $filename){ - $metadata = file_get_contents($filename); - - // Don't create a new Ebook object because that's very slow. Just do a regex match for speed. - preg_match_all('/(.+?)<\/dc:date>/iu', $metadata, $matches); - - if(sizeof($matches) > 0){ - $created = new DateTimeImmutable($matches[1][0]); - if($created >= $lastYear){ - $ebooksThisYear++; - } - } - } + $ebooksThisYear = Db::QueryInt('SELECT count(*) from Ebooks where EbookCreated >= ? - interval 1 year', [NOW]); foreach($expiredPatrons as $patron){ - Db::Query(' - UPDATE Patrons - set Ended = ? - where UserId = ? - ', [NOW, $patron->UserId]); - - Db::Query(' - UPDATE Benefits - set CanAccessFeeds = false, - CanVote = false, - CanBulkDownload = false - where UserId = ? - ', [$patron->UserId]); - - // Email the patron to notify them their term has ended. - // Is the patron a recurring subscriber? - $lastPayment = Db::Query(' - SELECT * - from Payments - where UserId = ? - order by Created desc - limit 1 - ', [$patron->UserId], Payment::class); - - if(sizeof($lastPayment) > 0 && $patron->User->Email !== null){ - $em = new Email(); - $em->From = EDITOR_IN_CHIEF_EMAIL_ADDRESS; - $em->FromName = EDITOR_IN_CHIEF_NAME; - $em->To = $patron->User->Email; - $em->ToName = $patron->User->Name ?? ''; - $em->Subject = 'Will you still help us make free, beautiful digital literature?'; - - if($lastPayment[0]->IsRecurring){ - // Email recurring donors who have lapsed. - $em->Body = Template::EmailPatronsCircleRecurringCompleted(); - $em->TextBody = Template::EmailPatronsCircleRecurringCompletedText(); - } - else{ - // Email one time donors who have expired after one year. - $em->Body = Template::EmailPatronsCircleCompleted(['ebooksThisYear' => $ebooksThisYear]); - $em->TextBody = Template::EmailPatronsCircleCompletedText(['ebooksThisYear' => $ebooksThisYear]); - } - - $em->Send(); - } + $patron->End($ebooksThisYear); } } diff --git a/templates/DonationCounter.php b/templates/DonationCounter.php index 0cd634f4..df33d275 100644 --- a/templates/DonationCounter.php +++ b/templates/DonationCounter.php @@ -56,7 +56,7 @@ $digits = str_split(str_pad((string)$current, 3, "0", STR_PAD_LEFT))

Our fiscal sponsor, Fractured Atlas, is celebrating the twenty-year anniversary of their fiscal sponsorship program by distributing $1,000 to twenty different projects.

Each one-time donation of any amount to Standard Ebooks through gives us one entry in this $1,000 giveaway. The more donations we receive through , the more chances we have to win!

-

This is a great time to join our Patrons Circle with a one-time donation of $100. Not only will your donation support us directly, but it’ll give us one more entry in this big giveaway.

+

This is a great time to join our Patrons Circle with a donation of $. Not only will your donation support us directly, but it’ll give us one more entry in this big giveaway.

Will you show your support for free, beautiful digital literature?

Membership in the Patrons Circle is limited to individuals only. Organizations, please see corporate sponsorship instead.

Join now

-

Join the Patrons Circle by starting a recurring donation of $10/month or more, or join for one year with a one-time donation of $100 or more.

+

Join the Patrons Circle by starting a recurring donation of $/month or more, or join for one year with a one-time donation of $ or more.

- Donate $10/month or more - Donate $100 or more + Donate $/month or more + Donate $ or more

Important: We need to know your email address to be able to log you in to the Patrons Circle. Make sure to select either “List my name publicly” or “Don’t list publicly, but reveal to project” during checkout to be able to log in to the Patrons Circle.