Allow adjustment of Patrons Circle cost

This commit is contained in:
Alex Cabal 2024-12-05 14:32:46 -06:00
parent a4d1e9d724
commit 9a2b095b70
8 changed files with 153 additions and 123 deletions

View file

@ -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`),

View file

@ -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;

8
lib/Enums/CycleType.php Normal file
View file

@ -0,0 +1,8 @@
<?
namespace Enums;
enum CycleType: string{
case Monthly = 'monthly';
case Unlimited = 'unlimited';
case Yearly = 'yearly';
}

View file

@ -3,6 +3,7 @@ use Safe\DateTimeImmutable;
/**
* @property User $User
* @property ?Payment $LastPayment
*/
class Patron{
use Traits\Accessor;
@ -13,10 +14,32 @@ class Patron{
public bool $IsSubscribedToEmails;
public DateTimeImmutable $Created;
public ?DateTimeImmutable $Ended = null;
public ?float $BaseCost = null;
public ?Enums\CycleType $CycleType = null;
protected ?Payment $_LastPayment = null;
protected User $_User;
// *******
// GETTERS
// *******
protected function GetLastPayment(): ?Payment{
if(!isset($this->_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();

View file

@ -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' <th> element.
// If the donation is via a foundation (like American Online Giving Foundation) then there will be a "soft credit" `<th>` 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,13 +226,26 @@ 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.
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.');
@ -252,6 +260,7 @@ try{
$em->Send();
}
}
}
elseif(!$payment->IsRecurring && !$payment->IsMatchingDonation){
// Fully-anonymous, non-recurring donation eligible for the Patrons Circle. We can't create a `Patron` or thank them, but we do notify the admins.
$patron = new Patron();

View file

@ -1,98 +1,35 @@
#!/usr/bin/php
<?
/**
* Get a list of payments that are within 1 year / 45 days of today, and deactivate Patrons Circle members who aren't in that list.
* We give a 15 day grace period to Patrons Circle members because sometimes FA can be delayed in charging.
*/
require_once('/standardebooks.org/web/lib/Core.php');
use Safe\DateTimeImmutable;
use function Safe\file_get_contents;
use function Safe\preg_match_all;
use function Safe\shell_exec;
// Get a list of payments that are within 1 year / 45 days of today, and deactivate Patrons Circle members who aren't in that list.
// We give a 15 day grace period to Patrons Circle members because sometimes FA can be delayed in charging.
$lastYear = new DateTimeImmutable('-1 year');
$expiredPatrons = Db::Query('
SELECT * from Patrons
where
Ended is null and
SELECT * from Patrons where
Ended is null
and
UserId not in
(
select distinct UserId from Payments where
UserId is not null
and
select distinct p.UserId from Patrons p
inner join Payments py
using (UserId)
where
p.Ended is null and
(
(IsRecurring = true and Amount >= 10 and Created > ? - interval 45 day)
(IsRecurring = true and CycleType = ? and Amount >= p.BaseCost and py.Created > ? - interval 45 day)
or
(IsRecurring = false and Amount >= 100 and Created > ? - interval 1 year)
(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>(.+?)<\/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);
}
}

View file

@ -56,7 +56,7 @@ $digits = str_split(str_pad((string)$current, 3, "0", STR_PAD_LEFT))
</div>
<p>Our fiscal sponsor, <a href="https://www.fracturedatlas.org">Fractured Atlas</a>, is celebrating the twenty-year anniversary of their fiscal sponsorship program by <a href="https://media.fracturedatlas.org/what-would-you-do-with-an-extra-1000">distributing $1,000 to twenty different projects</a>.</p>
<p><strong>Each one-time donation of any amount to Standard Ebooks through <?= $deadline ?> gives us one entry in this $1,000 giveaway.</strong> The more donations we receive through <?= $deadline ?>, the more chances we have to win!</p>
<p><strong>This is a great time to <a href="/donate#patrons-circle">join our Patrons Circle</a> with a one-time donation of $100.</strong> Not only will your donation support us directly, but itll give us one more entry in this big giveaway.</p>
<p><strong>This is a great time to <a href="/donate#patrons-circle">join our Patrons Circle</a> with a donation of $<?= number_format(PATRONS_CIRCLE_YEARLY_COST) ?>.</strong> Not only will your donation support us directly, but itll give us one more entry in this big giveaway.</p>
<p>Will you show your support for free, beautiful digital literature?</p>
<? if($showDonateButton){ ?>
<p class="donate-button">

View file

@ -34,10 +34,10 @@ $newsletterSubscriberCount = floor(Db::QueryInt('
<p>Membership in the Patrons Circle is limited to individuals only. Organizations, please see <a href="#corporate-sponsors">corporate sponsorship</a> instead.</p>
<div class="join-patrons-circle-callout">
<h3>Join now</h3>
<p><i>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.</i></p>
<p><i>Join the Patrons Circle by starting a recurring donation of $<?= number_format(PATRONS_CIRCLE_MONTHLY_COST) ?>/month or more, or join for one year with a one-time donation of $<?= number_format(PATRONS_CIRCLE_YEARLY_COST) ?> or more.</i></p>
<p class="button-row">
<a href="https://fundraising.fracturedatlas.org/standard-ebooks/monthly_support" class="button">Donate $10/month or more</a>
<a href="https://fundraising.fracturedatlas.org/standard-ebooks/general_support" class="button">Donate $100 or more</a>
<a href="https://fundraising.fracturedatlas.org/standard-ebooks/monthly_support" class="button">Donate $<?= number_format(PATRONS_CIRCLE_MONTHLY_COST) ?>/month or more</a>
<a href="https://fundraising.fracturedatlas.org/standard-ebooks/general_support" class="button">Donate $<?= number_format(PATRONS_CIRCLE_YEARLY_COST) ?> or more</a>
</p>
<p><strong>Important:</strong> 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 “Dont list publicly, but reveal to project” during checkout to be able to log in to the Patrons Circle.</p>
</div>