Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DON-998: set PI just before processing the payment instead of at the #1151

Open
wants to merge 31 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
06ace14
DON-998: set PI just before processing the payment instead of at the
Dec 24, 2024
1255811
Merge branch 'develop' into DON-998-2nd-3rd-donation-PI-rework
dorota-joanna Dec 27, 2024
e4140fd
DON-998 - separate function to find donations PI ready
Dec 27, 2024
9850962
DON-998 - fix psalm errors
Dec 27, 2024
7adff46
DON-998 - WIP: record unsuccessful payment attempts to limit number o…
Dec 27, 2024
4bbd055
DON-998 - extract limit as class variable
Dec 27, 2024
a78a574
DON-998 - unify datetime format
Dec 30, 2024
c191a9e
DON-998 - WIP, when running, the EM closes at ln 55
Dec 31, 2024
7c8f775
DON-998 - fix for the EM closing at 55
Jan 2, 2025
9c2a5fe
DON-998 - lint fix
Jan 2, 2025
6fdb9ca
MAT-379 – first pass handling Person upsert messages from Identity
NoelLH Dec 27, 2024
ffeb5e2
MAT-379 – check in missed psalm suppress
NoelLH Dec 27, 2024
15829b6
MAT-379 – fix weird DI loop similar to the CharityUpdated one we alre…
NoelLH Dec 28, 2024
a8fc155
MAT-379: Run matchbot consumer automatically in dev environment
bdsl Jan 2, 2025
b9671ab
DON-998 - create test campaign and attach to donation
Jan 2, 2025
78aa7be
DON-998 - fix EM query by assigning to value rather than dayOfMonth
Jan 2, 2025
093da3b
DON-998 - fix psalm and new integration test passing
Jan 2, 2025
805e582
DON-998 - fix psalm:
Jan 2, 2025
913327a
DON-998 - fix psalm:
Jan 2, 2025
06be75c
DON-998 - make batch limit a constant
Jan 3, 2025
f8f1ad7
DON-998 - rename limit
Jan 3, 2025
ea92c8a
DON-998 - additional query constraints
Jan 3, 2025
b15db8b
DON-998 - rename test file
Jan 3, 2025
e0d9009
DON-998 - remove unused code
Jan 3, 2025
6fd57be
DON-998 - use fixed date time in test
Jan 3, 2025
90df036
DON-998 - fix date format and remove unused variable
Jan 3, 2025
f986891
DON-998 - fix null error in test query
Jan 3, 2025
c02ea38
DON-998 - fix test: allow finding donations with preAuth date exactly…
Jan 3, 2025
7cfaa89
Merge branch 'develop' into DON-998-2nd-3rd-donation-PI-rework
dorota-joanna Jan 3, 2025
da7ac4f
DON-998 - split test - wip
Jan 3, 2025
76c0afe
DON-998 - conditionally activate mandate in test setup
Jan 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion integrationTests/CreateRegularGivingMandateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ public function testItCreatesRegularGivingMandate(): void

// act
$response = $this->createRegularGivingMandate($pencePerMonth);

// assert
$this->assertSame(201, $response->getStatusCode());
$mandateDatabaseRows = $this->db()->executeQuery(
Expand Down
1 change: 0 additions & 1 deletion integrationTests/PullCharityUpdatedBasedOnSfHookTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ public function testItPullsCharityUpdateAfterSalesforceSendsHook(): void
$campaign = TestCase::someCampaign();
$charity = $campaign->getCharity();
$sfId = $charity->getSalesforceId();
\assert(is_string($sfId));

$em->persist($campaign);
$em->persist($charity);
Expand Down
120 changes: 120 additions & 0 deletions integrationTests/RegularGivingDonationRepositoryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<?php

namespace MatchBot\IntegrationTests;

use Doctrine\ORM\EntityManagerInterface;
use MatchBot\Domain\DayOfMonth;
use MatchBot\Domain\Donation;
use MatchBot\Domain\DonationRepository;
use MatchBot\Domain\DonationSequenceNumber;
use MatchBot\Domain\DonationStatus;
use MatchBot\Domain\DonorAccount;
use MatchBot\Domain\DonorName;
use MatchBot\Domain\EmailAddress;
use MatchBot\Domain\Money;
use MatchBot\Domain\PaymentMethodType;
use MatchBot\Domain\PersonId;
use MatchBot\Domain\Salesforce18Id;
use MatchBot\Domain\StripeCustomerId;
use MatchBot\Tests\TestCase;
use Ramsey\Uuid\Uuid;

class RegularGivingDonationRepositoryTest extends IntegrationTest
{
public function setUp(): void
{
parent::setUp();
}

/**
* @return array
* @throws \Assert\AssertionFailedException
* @throws \MatchBot\Domain\DomainException\RegularGivingCollectionEndPassed
*/
public function arrange(bool $activateMandate, \DateTimeImmutable $atDateTime): array
{
// arrange
$campaign = TestCase::someCampaign(
sfId: Salesforce18Id::ofCampaign('123456789012345678')
);

$em = $this->getService(EntityManagerInterface::class);

$mandate = new \MatchBot\Domain\RegularGivingMandate(
donorId: PersonId::of(Uuid::uuid4()->toString()),
donationAmount: Money::fromPoundsGBP(20),
campaignId: Salesforce18Id::ofCampaign($campaign->getSalesforceId()),
charityId: Salesforce18Id::ofCharity($campaign->getCharity()->getSalesforceId()),
giftAid: false,
dayOfMonth: DayOfMonth::of(2)
);
$donor = new DonorAccount(
uuid: $mandate->donorId,
emailAddress: EmailAddress::of('[email protected]'),
donorName: DonorName::of('donorFName-test', 'donorLName-test'),
stripeCustomerId: StripeCustomerId::of('cus_' . self::randomString())
);
$donor->setBillingCountryCode('GB');
$donor->setBillingPostcode('W1 5YU');

if ($activateMandate) {
$mandate->activate($atDateTime);
}

$donation = $mandate->createPreAuthorizedDonation(
DonationSequenceNumber::of(2),
$donor,
$campaign,
$activateMandate ? true : false
);

$em->persist($donor);
$em->persist($mandate);
$em->persist($campaign);
$em->persist($donation);
$em->flush();

return [&$mandate, &$donation];
}

public function testFindDonationsToSetPaymentIntent(): void
{
$atDateTime = new \DateTimeImmutable('2025-01-03T00:11:11');
list(&$mandate, $donation) = $this->arrange(true, $atDateTime);
$sut = $this->getService(DonationRepository::class);

$donation->preAuthorize($atDateTime);

$donations = $sut->findDonationsToSetPaymentIntent($atDateTime, 10);

$this->assertCount(1, $donations);
$this->assertEquals($donation->getUuid(), $donations[0]->getUuid());
}

public function testDoesntFindDonationsForPaymentIntentIfNotPreAuthorised(): void
{

$atDateTime = new \DateTimeImmutable('2025-01-03T00:11:11');
list($mandate, $donation) = $this->arrange(false, $atDateTime);
$sut = $this->getService(DonationRepository::class);


$donations = $sut->findDonationsToSetPaymentIntent($atDateTime, 10);

$this->assertCount(0, $donations);
$this->assertEquals($donation->getUuid(), $donations[0]->getUuid());
}

public function testDoesntFindDonationsForPaymentIntentIfStatusNotActive(): void
{

$atDateTime = new \DateTimeImmutable('2025-01-03T00:11:11');
list($mandate, $donation, $atDateTime) = $this->arrange(false, $atDateTime);
$sut = $this->getService(DonationRepository::class);


$donations = $sut->findDonationsToSetPaymentIntent($atDateTime, 10);
$this->assertCount(0, $donations);
$this->assertEquals($donation->getUuid(), $donations[0]->getUuid());
}
}
4 changes: 1 addition & 3 deletions src/Application/Commands/SetupTestMandate.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,7 @@ protected function doExecute(InputInterface $input, OutputInterface $output): in
$donorId,
Money::fromPoundsGBP($amount),
Salesforce18Id::ofCampaign($campaign->getSalesforceId()),
Salesforce18Id::ofCharity(
$charity->getSalesforceId() ?? throw new \Exception('Missing charity sf ID')
),
Salesforce18Id::ofCharity($charity->getSalesforceId()),
(bool)$input->getOption('gift-aid'),
$dayOfMonth
);
Expand Down
21 changes: 19 additions & 2 deletions src/Application/Commands/TakeRegularGivingDonations.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@
)]
class TakeRegularGivingDonations extends LockingCommand
{
private const int MAXBATCHSIZE = 20;
private ?RegularGivingService $mandateService = null;


/** @psalm-suppress PossiblyUnusedMethod - called by PHP-DI */
public function __construct(
private Container $container,
Expand Down Expand Up @@ -82,25 +84,40 @@ protected function doExecute(InputInterface $input, OutputInterface $output): in
$now = $this->container->get(\DateTimeImmutable::class);

$this->createNewDonationsAccordingToRegularGivingMandates($now, $io);
$this->setPaymentIntentWhenReachedPaymentDate($now, $io);
$this->confirmPreCreatedDonationsThatHaveReachedPaymentDate($now, $io);

return 0;
}

private function createNewDonationsAccordingToRegularGivingMandates(\DateTimeImmutable $now, SymfonyStyle $io): void
{
$mandates = $this->mandateRepository->findMandatesWithDonationsToCreateOn($now, limit: 20);
$mandates = $this->mandateRepository->findMandatesWithDonationsToCreateOn($now, self::MAXBATCHSIZE);

$io->block(count($mandates) . " mandates may have donations to create at this time");

foreach ($mandates as [$mandate]) {
// @todo-regular-giving: catch the exception when missing address on account
$donation = $this->makeDonationForMandate($mandate);
if ($donation) {
$io->writeln("created donation {$donation}");
}
}
}

private function setPaymentIntentWhenReachedPaymentDate(
\DateTimeImmutable $now,
SymfonyStyle $io
): void {
$donations = $this->donationRepository->findDonationsToSetPaymentIntent($now, self::MAXBATCHSIZE);
$io->block(count($donations) . " donations are due to have Payment Intent set at this time");

foreach ($donations as $donation) {
$this->donationService->createPaymentIntent($donation);
$io->writeln("setting payment intent on donation #{$donation->getId()}");
}
}

private function confirmPreCreatedDonationsThatHaveReachedPaymentDate(
\DateTimeImmutable $now,
SymfonyStyle $io
Expand All @@ -111,7 +128,7 @@ private function confirmPreCreatedDonationsThatHaveReachedPaymentDate(
- Ensure we don't send emails that are meant for confirmation of on-session donations
- Probably other things.
*/
$donations = $this->donationRepository->findPreAuthorizedDonationsReadyToConfirm($now, limit:20);
$donations = $this->donationRepository->findPreAuthorizedDonationsReadyToConfirm($now, self::MAXBATCHSIZE);

$io->block(count($donations) . " donations are due to be confirmed at this time");

Expand Down
10 changes: 10 additions & 0 deletions src/Domain/Charity.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use DateTime;
use Doctrine\ORM\Mapping as ORM;
use MatchBot\Application\Assertion;

#[ORM\Table]
#[ORM\Entity(repositoryClass: CharityRepository::class)]
Expand Down Expand Up @@ -113,6 +114,15 @@ public function __toString(): string
return "Charity sfID ({$this->getSalesforceId()})";
}

#[\Override]
public function getSalesforceId(): string
{
$salesforceId = parent::getSalesforceId();
Assertion::string($salesforceId);

return $salesforceId;
}

/**
* @param string $name
*/
Expand Down
30 changes: 28 additions & 2 deletions src/Domain/DoctrineDonationRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,31 @@ public function findAllCompleteForCustomer(StripeCustomerId $stripeCustomerId):
return $result;
}

/**
* We only set Payment Intent on the day of the payment due to stripe limitations
*
*/
public function findDonationsToSetPaymentIntent(\DateTimeImmutable $atDateTime, int $maxBatchSize): array
{
$preAuthorized = DonationStatus::PreAuthorized->value;
$active = MandateStatus::Active->value;
// @todo-regular-giving: add constraint on late donation collections
$query = $this->getEntityManager()->createQuery(<<<DQL
SELECT donation from Matchbot\Domain\Donation donation JOIN donation.mandate mandate
WHERE donation.donationStatus = '$preAuthorized'
AND donation.transactionId is null
AND donation.preAuthorizationDate <= :atDateTime
AND mandate.status = '$active'
DQL
dorota-joanna marked this conversation as resolved.
Show resolved Hide resolved
);
$query->setParameter('atDateTime', $atDateTime);
$query->setMaxResults($maxBatchSize);

/** @var list<Donation> $result */
$result = $query->getResult();
return $result;
}

public function findPreAuthorizedDonationsReadyToConfirm(\DateTimeImmutable $atDateTime, int $limit): array
{
$preAuthorized = DonationStatus::PreAuthorized->value;
Expand All @@ -750,11 +775,12 @@ public function findPreAuthorizedDonationsReadyToConfirm(\DateTimeImmutable $atD
SELECT donation from Matchbot\Domain\Donation donation JOIN donation.mandate mandate
WHERE donation.donationStatus = '$preAuthorized'
AND mandate.status = '$active'
AND donation.preAuthorizationDate <= :now
AND donation.transactionId is not null
AND donation.preAuthorizationDate <= :atDateTime
DQL
);

$query->setParameter('now', $atDateTime);
$query->setParameter('atDateTime', $atDateTime);
$query->setMaxResults($limit);

/** @var list<Donation> $result */
Expand Down
5 changes: 5 additions & 0 deletions src/Domain/DonationRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@ public function pushSalesforcePending(\DateTimeImmutable $now, MessageBusInterfa
*/
public function findAllCompleteForCustomer(StripeCustomerId $stripeCustomerId): array;

/**
* @return list<Donation>
*/
public function findDonationsToSetPaymentIntent(\DateTimeImmutable $atDateTime, int $maxBatchSize): array;

/**
* @return list<Donation>
*/
Expand Down
9 changes: 7 additions & 2 deletions src/Domain/DonationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
use Random\Randomizer;
use Stripe\Exception\ApiErrorException;
use Stripe\Exception\InvalidRequestException;
use Stripe\PaymentIntent;
use Stripe\StripeObject;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Messenger\Envelope;
Expand Down Expand Up @@ -257,10 +258,14 @@ public function confirmPreAuthorized(Donation $donation): void
);
}

$this->stripe->confirmPaymentIntent(
$paymentIntent = $this->stripe->confirmPaymentIntent(
$donation->getTransactionId(),
['payment_method' => $paymentMethod->stripePaymentMethodId]
);
if ($paymentIntent->status !== 'succeeded') {
// TODO: create a new db field on Donation - e.g. payment_attempt_count and update here
// decide on a limit and log an error (or warning) if exceeded
}
}

/**
Expand Down Expand Up @@ -348,7 +353,7 @@ function () use ($donation) {
if ($stripeAccountId === null || $stripeAccountId === '') {
$this->logger->error(sprintf(
'Stripe Payment Intent create error: Stripe Account ID not set for Account %s',
$campaign->getCharity()->getSalesforceId() ?? 'missing charity sf ID',
$campaign->getCharity()->getSalesforceId(),
));
throw new StripeAccountIdNotSetForAccount();
}
Expand Down
5 changes: 1 addition & 4 deletions src/Domain/RegularGivingMandateRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,7 @@ private function getMandatesWithCharities(\Doctrine\ORM\Query $query)
$charities = [];
foreach ($x as $entity) {
if ($entity instanceof Charity) {
$salesforceId = $entity->getSalesforceId();
\assert($salesforceId !== null);

$charities[$salesforceId] = $entity;
$charities[$entity->getSalesforceId()] = $entity;
}
}

Expand Down
6 changes: 1 addition & 5 deletions src/Domain/RegularGivingService.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,7 @@ public function setupNewMandate(
);
}

$charityId = Salesforce18Id::ofCharity(
$campaign->getCharity()->getSalesforceId() ?? throw new \Exception('missing charity SF ID')
);
$charityId = Salesforce18Id::ofCharity($campaign->getCharity()->getSalesforceId());

/**
* For now we assume this exists - @todo-regular-giving ensure that for all accounts (or all accounts that
Expand Down Expand Up @@ -157,7 +155,6 @@ public function makeNextDonationForMandate(RegularGivingMandate $mandate): ?Dona
// would only be null if donor was deleted after mandate created.
Assertion::notNull($donor, "donor not found for id {$mandate->donorId->id}");

/** @todo-regular-giving Throw a more specific exception if this fails and handle instead of crashing */
$donor->assertHasRequiredInfoForRegularGiving();

$campaign = $this->campaignRepository->findOneBySalesforceId($mandate->getCampaignId());
Expand Down Expand Up @@ -191,7 +188,6 @@ public function makeNextDonationForMandate(RegularGivingMandate $mandate): ?Dona
return null;
}

$this->donationService->createPaymentIntent($donation);
$mandate->setDonationsCreatedUpTo($preAuthorizationDate);

return $donation;
Expand Down
5 changes: 5 additions & 0 deletions tests/Domain/InMemoryDonationRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,11 @@ public function totalMatchFundsReleased(): string
throw new \Exception("Method not implemented in test double");
}

#[\Override] public function findDonationsToSetPaymentIntent(\DateTimeImmutable $atDateTime, int $maxBatchSize): array
{
throw new \Exception("Method not implemented in test double");
}

#[\Override] public function maxSequenceNumberForMandate(int $mandateId): ?DonationSequenceNumber
{
throw new \Exception("Method not implemented in test double");
Expand Down
4 changes: 1 addition & 3 deletions tests/Domain/RegularGivingMandateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,7 @@ public function testItRendersApiModel(): void
donationAmount: Money::fromPoundsGBP(500),
dayOfMonth: DayOfMonth::of(12),
campaignId: Salesforce18Id::ofCampaign('campaign9012345678'),
charityId: Salesforce18Id::ofCharity(
$charity->getSalesforceId() ?? throw new \Exception("sf id can't be null")
),
charityId: Salesforce18Id::ofCharity($charity->getSalesforceId()),
giftAid: true,
);

Expand Down
Loading