#!/usr/bin/php addArguments(['-headless']); // **Warning**: Only one dash! $capabilities = DesiredCapabilities::firefox(); $capabilities->setCapability(FirefoxOptions::CAPABILITY, $firefoxOptions); $driver = null; $log = new Log(DONATIONS_LOG_FILE_PATH); $lastMonth = (new DateTimeImmutable())->sub(new DateInterval('P45D')); // 45 days, a 15 day grace period before Patrons Circle members are dropped off. $lastYear = (new DateTimeImmutable())->sub(new DateInterval('P1Y')); $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. Db::Query('start transaction'); $pendingPayments = Db::Query(' SELECT * from PendingPayments where ProcessedOn is null '); Db::Query(' UPDATE PendingPayments set ProcessedOn = utc_timestamp() where ProcessedOn is null '); Db::Query('commit'); if(sizeof($pendingPayments) == 0){ // Don't start the very slow Selenium driver if we have nothing to process. exit(); } try{ $driver = FirefoxDriver::start($capabilities); foreach($pendingPayments as $pendingPayment){ $pendingPayment->Processor = Enums\PaymentProcessorType::from($pendingPayment->Processor); switch($pendingPayment->Processor){ case Enums\PaymentProcessorType::FracturedAtlas: $log->Write('Processing donation ' . $pendingPayment->TransactionId . ' ...'); if(Db::QueryBool(' SELECT exists( select * from Payments where TransactionId = ? ) ', [$pendingPayment->TransactionId])){ $log->Write('Donation already exists in database.'); break; } $driver->get('https://fundraising.fracturedatlas.org/admin/donations?query=' . $pendingPayment->TransactionId); // Check if we need to log in to FA. // Wait until the `` element is visible, then check the current URL. $driver->wait(20, 250)->until(WebDriverExpectedCondition::visibilityOfElementLocated(WebDriverBy::xpath('/html/body'))); if(stripos($driver->getCurrentURL(), 'auth.fracturedatlas.org')){ $log->Write('Logging in to Fractured Atlas ...'); // We were redirected to the login page, so try to log in. /** @var WebDriverElement $emailField */ $emailField = $driver->wait(20, 250)->until(WebDriverExpectedCondition::visibilityOfElementLocated(WebDriverBy::xpath('//input[@inputmode="email"]'))); $emailField->sendKeys($faUsername); /** @var WebDriverElement $passwordField */ $passwordField = $driver->wait(20, 250)->until(WebDriverExpectedCondition::visibilityOfElementLocated(WebDriverBy::xpath('//input[@type="password"]'))); // FA requires an explicit click on the password field for some reason. $passwordField->click(); $passwordField->clear(); $passwordField->sendKeys($faPassword); // Submit the form. /** @var WebDriverElement $form */ $form = $driver->wait(20, 250)->until(WebDriverExpectedCondition::visibilityOfElementLocated(WebDriverBy::xpath('//form'))); $form->submit(); } // 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. try{ /** @var WebDriverElement $toggleButton */ $toggleButton = $driver->wait(20, 250)->until(WebDriverExpectedCondition::visibilityOfElementLocated(WebDriverBy::xpath('//button[contains(@class, "button-toggle")]'))); } catch(Exception){ $log->Write('Error: Couldn\'t find donation.'); break; } $toggleButton->click(); // Our target row is now visible, extract the data! // In the FA donations table, there is a header row, and an expandable details row. The header row tells us if the donation is recurring, and the details row has the rest of the information. /** @var WebDriverElement $detailsRow */ $detailsRow = $driver->wait(20, 250)->until(WebDriverExpectedCondition::visibilityOfElementLocated(WebDriverBy::xpath('//tr[starts-with(@id, "expanded") and contains(@id, "' . $pendingPayment->TransactionId . '")]'))); $headerRow = $driver->findElement(WebDriverBy::xpath('//tr[not(starts-with(@id, "expanded")) and contains(@id, "' . $pendingPayment->TransactionId . '")]')); $payment = new Payment(); $payment->User = new User(); $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(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()); $payment->User->Email = trim($detailsRow->findElement(WebDriverBy::xpath('//td[preceding-sibling::th[normalize-space(.) = "Email"] and (ancestor::tbody[1])[(./preceding-sibling::thead[1])//th[normalize-space(.) = "Soft Credit Donor Info"]]]'))->getText()); $hasSoftCredit = true; } else{ // We're a regular donation $payment->User->Name = trim($detailsRow->findElement(WebDriverBy::xpath('//td[preceding-sibling::th[normalize-space(.) = "Name"]]'))->getText()); $payment->User->Email = trim($detailsRow->findElement(WebDriverBy::xpath('//td[preceding-sibling::th[normalize-space(.) = "Email"]]'))->getText()); } // These donations are typically (always?) employer matches. // FA does not provide a way to connect the original donation with the employer match. // See donation `bbf87b83-d341-426f-b6c9-9091e3222e57`. if($payment->User->Name == 'American Online Giving Foundation'){ $payment->IsMatchingDonation = true; } // Sometimes a soft credit is `Anonymous` or `Anonymous Anonymous`. // See donation `7f296e18-6492-48e1-aeca-5063c6a0bbbb`. if($hasSoftCredit && preg_match('/Anonymous(\s*Anonymous)*/ius', $payment->User->Name)){ $payment->User = null; } // We can get here via an AOGF donation that is anonymous. if(!$hasSoftCredit && ($payment->User?->Email == 'Not provided' || $payment->User?->Email == '')){ $payment->User = null; } } catch(Exception){ // Anonymous donations don't have these elements present and will throw an exception. $payment->User = null; } $payment->Created = DateTimeImmutable::createFromFormat('n/j/Y', trim($detailsRow->findElement(WebDriverBy::xpath('//td[preceding-sibling::th[normalize-space(.) = "Created"]]'))->getText())); $payment->IsRecurring = sizeof($headerRow->findElements(WebDriverBy::xpath('//td[contains(., "Recurring")]'))) > 0; $payment->Amount = floatval(preg_replace('/[^0-9\.]/', '', trim($detailsRow->findElement(WebDriverBy::xpath('//td[preceding-sibling::th[normalize-space(.) = "Total Amount"]]'))->getText()))); $payment->Fee = floatval(preg_replace('/[^0-9\.]/', '', trim($detailsRow->findElement(WebDriverBy::xpath('//td[preceding-sibling::th[normalize-space(.) = "Fee"]]'))->getText()))); $transactionId = (string)($detailsRow->findElement(WebDriverBy::xpath('//td[preceding-sibling::th[normalize-space(.) = "ID"]]'))->getText()); $transactionId = str_replace('View on FS', '', $transactionId); $transactionId = str_replace('View on Finance', '', $transactionId); $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`. if($payment->User !== null && !$hasSoftCredit && preg_match('/\b(L\.?L\.?C\.?|Foundation|President|Fund|Charitable)\b/ius', $payment->User->Name ?? '')){ $payment->User = null; } // All set - create the payment. try{ $payment->Create(); } catch(Exceptions\PaymentExistsException){ // Payment already exists, just continue. $log->Write('Donation already in database.'); break; } // Does this payment create a new Patron in the Patrons Circle? // If the user is *already* a Patron, then we just create the payment without further processing. if( ( $payment->IsRecurring && $payment->Amount >= PATRONS_CIRCLE_MONTHLY_COST && $payment->Created >= $lastMonth ) || ( !$payment->IsRecurring && $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($patron === null){ // Not a patron yet, add them to the Patrons Circle. $patron = new Patron(); $patron->UserId = $payment->UserId; $patron->User = $payment->User; $patron->User->Payments = [$payment]; $patron->IsAnonymous = (trim($detailsRow->findElement(WebDriverBy::xpath('//td[preceding-sibling::th[normalize-space(.) = "Attribution"]]'))->getText()) == 'Private'); $patron->IsSubscribedToEmails = $patron->User !== null && $patron->User->Email !== null; 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(); } 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(); } } } 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(); $patron->User = new User(); $em = new Email(); $em->To = ADMIN_EMAIL_ADDRESS; $em->From = ADMIN_EMAIL_ADDRESS; $em->Subject = 'New Patrons Circle member'; $em->Body = Template::EmailAdminNewPatron(patron: $patron, payment: $payment); $em->TextBody = Template::EmailAdminNewPatronText(patron: $patron, payment: $payment);; $em->Send(); } } elseif($payment->User !== null){ // Payment amount is not eligible for the Patrons Circle; send a thank you email anyway, but only if this is a non-recurring donation, or if it's their very first recurring donation. $previousPaymentCount = Db::QueryInt(' SELECT count(*) from Payments where UserId = ? and IsRecurring = true ', [$payment->UserId]); // We just added a payment to the system, so if this is their very first recurring payment, we expect the count to be exactly 1. if(!$payment->IsRecurring || $previousPaymentCount == 1){ $log->Write('Sending thank you email to non-patron donor.'); $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(); } } } Db::Query(' DELETE from PendingPayments where TransactionId = ? ', [$pendingPayment->TransactionId]); $log->Write('Donation processed.'); } } catch(Exception $ex){ $exceptionString = vds($ex); $log->Write('Error: Uncaught exception: ' . $exceptionString); $em = new Email(true); $em->To = ADMIN_EMAIL_ADDRESS; $em->Subject = 'Donation processing failed'; $em->Body = Template::EmailDonationProcessingFailed(exception: preg_replace('/^/m', "\t", $exceptionString)); $em->TextBody = Template::EmailDonationProcessingFailedText(exception: preg_replace('/^/m', "\t", $exceptionString)); $em->Send(); throw $ex; } finally{ // `$driver` may be unintialized if we ctrl + c during Selenium initialization. /** @phpstan-ignore nullsafe.neverNull */ $driver?->quit(); }