-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathTakeRegularGivingDonations.php
More file actions
251 lines (224 loc) · 10.5 KB
/
TakeRegularGivingDonations.php
File metadata and controls
251 lines (224 loc) · 10.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
<?php
declare(strict_types=1);
namespace MatchBot\Application\Commands;
use Assert\AssertionFailedException;
use DI\Container;
use Doctrine\ORM\EntityManagerInterface;
use MatchBot\Application\Actions\RegularGivingMandate\MandateCollectionRepeatedlyFailed;
use MatchBot\Application\Environment;
use MatchBot\Domain\DomainException\MandateNotActive;
use MatchBot\Domain\DomainException\RegularGivingCollectionEndPassed;
use MatchBot\Domain\DomainException\RegularGivingDonationTooOldToCollect;
use MatchBot\Domain\DomainException\WrongCampaignType;
use MatchBot\Domain\Donation;
use MatchBot\Domain\DonationRepository;
use MatchBot\Domain\DonationService;
use MatchBot\Domain\MandateCancellationType;
use MatchBot\Domain\RegularGivingService;
use MatchBot\Domain\RegularGivingMandate;
use MatchBot\Domain\RegularGivingMandateRepository;
use Psr\Clock\ClockInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'matchbot:collect-regular-giving',
description: "Takes money from donors that they have given us advance permission to take.",
)]
class TakeRegularGivingDonations extends LockingCommand
{
private const int MAXBATCHSIZE = 500;
public function __construct(
private Container $container,
private RegularGivingMandateRepository $mandateRepository,
private DonationRepository $donationRepository,
private DonationService $donationService,
private EntityManagerInterface $em,
private Environment $environment,
private LoggerInterface $logger,
private RegularGivingService $mandateService,
) {
parent::__construct();
$this->addOption(
'simulated-date',
shortcut: 'simulated-date',
mode: InputOption::VALUE_REQUIRED,
description: '(imperfectly) Simulated datetime - see comments in ' . basename(__file__) . ' for details',
);
}
/**
* Note that only some usages of the system clock are currently replaced with a simulated date here, so results
* may be inconsistent when using a simulated date. That's because matchbot-cli.php eagerly loads from the container
* every service needed by every possible command at startup, so by this point DonationService and perhaps others
* have already been created with a real system clock or timestamp.
*
* Consider using https://symfony.com/doc/current/console/lazy_commands.html or putting the simulated date in
* container early in the matchbot-cli.php to fix.
*/
public function setSimulatedNow(string $simulateDateInput, OutputInterface $output): void
{
$simulatedNow = new \DateTimeImmutable($simulateDateInput);
$this->container->set(ClockInterface::class, new MockClock($simulatedNow));
$output->writeln("Simulating running on {$simulatedNow->format('Y-m-d H:i:s')}");
}
/**
* When we run this for manual testing on developer machines we will need to simulate a future time
* instead of waiting for donations to become payable.
*/
public function applySimulatedDate(?string $simulateDateInput, OutputInterface $output): void
{
switch (true) {
case $this->environment !== Environment::Production && is_string($simulateDateInput):
$this->setSimulatedNow($simulateDateInput, $output);
break;
case $this->environment === Environment::Production && is_string($simulateDateInput):
throw new \Exception("Cannot simulate date in production");
default:
//no-op
}
}
#[\Override]
protected function doExecute(InputInterface $input, OutputInterface $output): int
{
$bufferedOutput = new BufferedOutput();
$io = new SymfonyStyle($input, $bufferedOutput);
/** @psalm-suppress MixedArgument */
$this->applySimulatedDate($input->getOption('simulated-date'), $bufferedOutput);
$now = $this->container->get(ClockInterface::class)->now();
$this->createNewDonationsAccordingToRegularGivingMandates($now, $io);
$this->createPaymentIntentWhenReachedPaymentDate($now, $io);
$this->confirmPreCreatedDonationsThatHaveReachedPaymentDate($now, $io);
$outputText = $bufferedOutput->fetch();
$output->writeln($outputText);
return 0;
}
private function createNewDonationsAccordingToRegularGivingMandates(\DateTimeImmutable $now, SymfonyStyle $io): void
{
$mandates = $this->mandateRepository->findMandatesWithDonationsToCreateOn($now, self::MAXBATCHSIZE);
$mandateUUIDs = array_map(
fn(array $mandate_charity) => $mandate_charity[0]->getUuid()->toString(),
$mandates
);
$io->block(sprintf(
"%s mandates may have donations to create at this time: %s",
count($mandates),
implode(', ', $mandateUUIDs)
));
foreach ($mandates as [$mandate]) {
try {
$donation = $this->makeDonationForMandate($mandate);
if ($donation) {
$io->writeln("created donation {$donation}");
}
} catch (AssertionFailedException | WrongCampaignType $e) {
$io->error($e->getMessage());
$this->logger->error($e->getMessage());
}
}
}
/**
* Make any needed Stripe Payment Intents and associate them with Donations.
*/
private function createPaymentIntentWhenReachedPaymentDate(
\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) {
try {
$this->donationService->createAndAssociatePaymentIntent($donation);
$io->writeln("setting payment intent on donation {$donation->getUuid()}");
} catch (RegularGivingDonationTooOldToCollect $e) {
if ($this->environment === Environment::Regression) {
$mandate = $donation->getMandate();
\assert($mandate instanceof RegularGivingMandate);
$mandate->cancel(
'Donation too old to collect, cancelling mandate - special regression environment behaviour',
$now,
MandateCancellationType::BigGiveCancelled,
);
$this->donationService->cancel($donation);
}
$this->logger->error($e->getMessage());
$io->error($e->getMessage());
}
}
$this->em->flush();
}
private function confirmPreCreatedDonationsThatHaveReachedPaymentDate(
\DateTimeImmutable $now,
SymfonyStyle $io
): void {
$donations = $this->donationRepository->findPreAuthorizedDonationsReadyToConfirm($now, self::MAXBATCHSIZE);
$io->block(count($donations) . " donations are due to be confirmed at this time");
foreach ($donations as $donation) {
$preAuthDate = $donation->getPreAuthorizationDate();
\assert($preAuthDate instanceof \DateTimeImmutable);
$io->writeln("Processing donation ID {$donation->getId()}");
$io->writeln(
"Donation {$donation->getUuid()} is pre-authorized to pay on" .
" <options=bold>{$preAuthDate->format('Y-m-d H:i:s')}</>
"
);
try {
try {
if ($this->donationService->confirmPreAuthorized($donation)) {
$io->writeln(
"Donation {$donation->getUuid()} is expected to become Collected when Stripe calls back"
);
} // else there are already detailed logs about the failure
} catch (MandateNotActive $exception) {
$io->info($exception->getMessage());
continue;
} catch (RegularGivingCollectionEndPassed $exception) {
$io->info($exception->getMessage());
continue;
} catch (RegularGivingDonationTooOldToCollect $exception) {
// Copied code from the other place we catch this, as it's only temporary.
if ($this->environment === Environment::Regression) {
$mandate = $donation->getMandate();
\assert($mandate instanceof RegularGivingMandate);
$mandate->cancel(
'Donation too old to collect, cancelling mandate - special regression environment behaviour',
$now,
MandateCancellationType::BigGiveCancelled,
);
$this->donationService->cancel($donation);
} else {
throw $exception;
}
} catch (MandateCollectionRepeatedlyFailed $exception) {
$mandate = $donation->getMandate();
\assert($mandate instanceof RegularGivingMandate);
$this->mandateService->cancelMandate(
$mandate,
'Payment failed more than one week after pre-auth date',
MandateCancellationType::CollectingAutomaticDonationRepeatFailed,
);
}
} catch (\Exception $exception) {
$this->logger->warning('Exception, skipping RG confirmation of donation: ' . $donation->getUuid()->toString() . ", " . \get_class($exception) . ": " . $exception->getMessage());
continue;
}
}
$this->em->flush();
}
/**
* @throws WrongCampaignType|AssertionFailedException
*/
private function makeDonationForMandate(RegularGivingMandate $mandate): ?Donation
{
$donation = $this->mandateService->makeNextDonationForMandate($mandate);
if ($donation) {
$this->em->persist($donation);
}
$this->em->flush();
return $donation;
}
}