#!/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 DateTime())->sub(new DateInterval('P45D')); // 45 days, a 15 day grace period before Patrons Circle members are dropped off $lastYear = (new DateTime())->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){ if($pendingPayment->ChannelId == PAYMENT_CHANNEL_FA){ $log->Write('Processing donation ' . $pendingPayment->TransactionId . ' ...'); if(Db::QueryInt(' SELECT count(*) from Payments where TransactionId = ? ', [$pendingPayment->TransactionId]) > 0){ $log->Write('Donation already exists in database.'); continue; } $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(), 'auth0.com')){ $log->Write('Logging in to Fractured Atlas ...'); // We were redirected to the login page, so try to log in $emailField = $driver->wait(20, 250)->until(WebDriverExpectedCondition::visibilityOfElementLocated(WebDriverBy::xpath('//input[@type="email"]'))); $passwordField = $driver->wait(20, 250)->until(WebDriverExpectedCondition::visibilityOfElementLocated(WebDriverBy::xpath('//input[@type="password"]'))); $submitButton = $driver->wait(20, 250)->until(WebDriverExpectedCondition::visibilityOfElementLocated(WebDriverBy::xpath('//button[@type="submit"]'))); // Fill out and submit the form $emailField->sendKeys($faUsername); $passwordField->sendKeys($faPassword); $submitButton->click(); } // 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{ $toggleButton = $driver->wait(20, 250)->until(WebDriverExpectedCondition::visibilityOfElementLocated(WebDriverBy::xpath('//button[contains(@class, "button-toggle")]'))); } catch(Exception){ $log->Write('Error: Couldn\'t find donation.'); continue; } $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 $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->ChannelId = $pendingPayment->ChannelId; 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()); } 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. // Example bbf87b83-d341-426f-b6c9-9091e3222e57 if($payment->User->Name == 'American Online Giving Foundation'){ $payment->IsMatchingDonation = true; } // We can get here via an AOGF donation that is anonymous if($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 = DateTime::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(str_replace('$', '', trim($detailsRow->findElement(WebDriverBy::xpath('//td[preceding-sibling::th[normalize-space(.) = "Total Amount"]]'))->getText()))); $payment->Fee = floatval(str_replace('$', '', trim($detailsRow->findElement(WebDriverBy::xpath('//td[preceding-sibling::th[normalize-space(.) = "Fee"]]'))->getText()))); $transactionId = $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 && 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.'); continue; } // Does this payment put us in the Patrons Circle? if( ( $payment->IsRecurring && $payment->Amount >= 10 && $payment->Created >= $lastMonth ) || ( !$payment->IsRecurring && $payment->Amount >= 100 && $payment->Created >= $lastYear ) ){ // This payment is eligible for the Patrons Circle! if($payment->User !== null){ // Are we already a patron? if(Db::QueryInt(' SELECT count(*) from Patrons where UserId = ? and Ended is null ', [$payment->UserId]) == 0){ // 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){ } $log->Write('Adding donor as patron ...'); $patron->Create(); } } elseif(!$payment->IsRecurring && !$payment->IsMatchingDonation){ // Fully-anonymous, non-recurring donation eligible for the Patrons Circle. We can't notify them, but 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(); } } else{ // Not eligible to be a patron; send a thank you email anyway, but only if this is a non-recurring donation, or if it's their very first recurring donation if($payment->User !== null){ $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->quit(); } ?>