-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathDonation.php
More file actions
2178 lines (1874 loc) · 79.3 KB
/
Donation.php
File metadata and controls
2178 lines (1874 loc) · 79.3 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
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?php
declare(strict_types=1);
namespace MatchBot\Domain;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Mapping as ORM;
use JetBrains\PhpStorm\Pure;
use MatchBot\Application\Assert;
use MatchBot\Application\Assertion;
use MatchBot\Application\AssertionFailedException;
use MatchBot\Application\Environment;
use MatchBot\Application\Fees\Calculator;
use MatchBot\Application\HttpModels\DonationCreate;
use MatchBot\Application\LazyAssertionException;
use MatchBot\Domain\DomainException\CannotRemoveGiftAid;
use MatchBot\Domain\DomainException\RegularGivingDonationTooOldToCollect;
use Messages;
use PrinsFrank\Standards\Country\CountryAlpha2;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use Stripe\Payout;
use function bccomp;
use function sprintf;
#[ORM\UniqueConstraint(fields: ['mandateSequenceNumber', 'mandate'])]
#[ORM\Index(name: 'campaign_and_status', columns: ['campaign_id', 'donationStatus'])]
#[ORM\Index(name: 'date_and_status', columns: ['createdAt', 'donationStatus'])]
#[ORM\Index(name: 'updated_date_and_status', columns: ['updatedAt', 'donationStatus'])]
#[ORM\Index(name: 'salesforcePushStatus', columns: ['salesforcePushStatus'])]
#[ORM\Index(name: 'pspCustomerId', columns: ['pspCustomerId'])]
#[ORM\Entity(repositoryClass: DoctrineDonationRepository::class)]
#[ORM\HasLifecycleCallbacks]
class Donation extends SalesforceWriteProxy
{
use TimestampsTrait;
/**
* @see self::$currencyCode
* Theoretically also a global limit for any other methods in the Payment Element, but currently the Pay By Bank
* limit is £10k and that method won't be offered by Stripe.js above that level.
*/
public const int MAXIMUM_CARD_DONATION = 25_000;
public const int MAXIMUM_CUSTOMER_BALANCE_DONATION = 200_000;
public const int MINUMUM_AMOUNT = 1;
public const string GIFT_AID_PERCENTAGE = '25';
/**
* Placeholder used in home postcode field for a donor with a home outside the UK. See also
* OVERSEAS constant in donate-frontend.
*/
public const string OVERSEAS = 'OVERSEAS';
/**
* The donation ID for PSPs and public APIs. Not the same as the internal auto-increment $id used
* by Doctrine internally for fast joins.
*
*/
#[ORM\Column(type: 'uuid', unique: true)]
protected UuidInterface $uuid;
/**
* @var Campaign
*/
#[ORM\ManyToOne(targetEntity: Campaign::class)]
#[ORM\JoinColumn(nullable: false)]
protected Campaign $campaign;
/**
* @var string Which Payment Service Provider (PSP) is expected to (or did) process the donation.
*/
#[ORM\Column(length: 20)]
protected string $psp;
/**
* @var ?DateTimeImmutable When the donation first moved to status Collected, i.e. the donor finished paying.
*/
#[ORM\Column(nullable: true)]
protected ?DateTimeImmutable $collectedAt = null;
/**
* @var string|null PSP's transaction ID assigned on their processing.
*
* In the case of stripe (which is the only thing we support at present, this is the payment intent ID).
* May change after initial assignment until donation's Collected, in some edge cases.
*/
#[ORM\Column(unique: true, nullable: true)]
protected ?string $transactionId = null;
/**
* @var string|null PSP's charge ID assigned on their processing.
*/
#[ORM\Column(unique: true, nullable: true)]
protected ?string $chargeId = null;
/**
* @var string|null PSP's transfer ID assigned on a successful charge. For Stripe this
* ID relates to the Platform Account (i.e. the Big Give's) rather than
* the Connected Account for the charity receiving the transferred
* donation balance.
*/
#[ORM\Column(unique: true, nullable: true)]
protected ?string $transferId = null;
/**
* @var string ISO 4217 code for the currency in which all monetary values are denominated, e.g. 'GBP'.
*/
#[ORM\Column(length: 3)]
protected readonly string $currencyCode;
/**
* Core donation amount in major currency units (i.e. Pounds) excluding any tip.
*
* @psalm-var numeric-string Always use bcmath methods as in repository helpers to avoid doing float maths
* with decimals!
* @see Donation::$currencyCode
*/
#[ORM\Column(type: 'decimal', precision: 18, scale: 2)]
protected readonly string $amount;
/**
* @var Money Amount of match funds the donor expects to be allocated to this donation. Can vary between zero
* and the donation amount. (And perhaps more in future if any funders want to match on a more-generous-than-121
* basis.) If the matchFundsReserved is less than this then the donation should not be confirmed.
*
* Ignore values on donations from before August 2025 deployment.
*
* @psalm-suppress UnusedProperty - will be used soon.
*/
#[ORM\Embedded()]
private Money $expectedMatchAmount;
/**
* Total amount paid by donor - recorded from the Stripe charge, and reduced to reflect the new total
* if we issue a tip refund (but not if we issue a full refund).
*
* Null for donation collected before August 2024, as we didn't record it at the time.
*
* @psalm-var numeric-string Always use bcmath methods as in repository helpers to avoid doing float maths
* with decimals!
* @see Donation::$currencyCode
*/
#[ORM\Column(type: 'decimal', precision: 18, scale: 2, nullable: true)]
private ?string $totalPaidByDonor = null;
/**
* Fee the charity takes on, in £. Excludes any tax if applicable.
*
* For Stripe (EU / UK): 1.5% of $amount + 0.20p
* For Stripe (Non EU / Amex): 3.2% of $amount + 0.20p
*
* @var numeric-string Always use bcmath methods as in repository helpers to avoid doing float maths with decimals!
* @see Donation::$currencyCode
*
* initialised via call to deriveFees
*/
#[ORM\Column(type: 'decimal', precision: 18, scale: 2)]
protected string $charityFee; // @phpstan-ignore property.uninitialized
/**
* Value Added Tax amount on `$charityFee`, in £. In addition to base amount
* in $charityFee.
*
* @var numeric-string Always use bcmath methods as in repository helpers to avoid doing float maths with decimals!
* @see Donation::$currencyCode
*
* initialised via call to deriveFees
*/
#[ORM\Column(type: 'decimal', precision: 18, scale: 2)]
protected string $charityFeeVat; // @phpstan-ignore property.uninitialized
/**
* Fee charged to TBG by the PSP, in £. Added just for Stripe transactions from March '21.
* Original fee varies by card brand and country and is based on the full amount paid by the
* donor: `$amount + $tipAmount`.
*
* @var numeric-string Always use bcmath methods as in repository helpers to avoid doing float maths with decimals!
* @see Donation::$currencyCode
*/
#[ORM\Column(type: 'decimal', precision: 18, scale: 2)]
protected string $originalPspFee = '0.00';
#[ORM\Column]
protected DonationStatus $donationStatus = DonationStatus::Pending;
/**
* @var bool Whether the donor opted to receive email from the charity running the campaign
*/
#[ORM\Column(type: 'boolean', nullable: true)]
protected ?bool $charityComms = null;
/**
* Whether the Donor has asked for Gift Aid to be claimed about this donation.
*/
#[ORM\Column()]
protected bool $giftAid = false;
/**
* Date at which we amended the donation to cancel claiming gift aid.
*/
#[ORM\Column(nullable: true, type: 'datetime_immutable')]
private ?DateTimeImmutable $giftAidRemovedAt = null;
/**
* @var bool Whether the donor opted to receive email from the Big Give
*/
#[ORM\Column(type: 'boolean', nullable: true)]
protected ?bool $tbgComms = null;
/**
* @var bool Whether the donor opted to receive email from the champion funding the campaign
*/
#[ORM\Column(type: 'boolean', nullable: true)]
protected ?bool $championComms = null;
/**
* @var string|null *Billing* country code.
*/
#[ORM\Column(length: 2, nullable: true)]
protected ?string $donorCountryCode = null;
/**
* Ideally we would type this as ?EmailAddress instead of ?string but that will require changing
* the column name to match the property inside the VO. Might be easy and worth doing.
*/
#[ORM\Column(nullable: true)]
protected ?string $donorEmailAddress = null;
#[ORM\Column(nullable: true)]
protected ?string $donorFirstName = null;
#[ORM\Column(nullable: true)]
protected ?string $donorLastName = null;
/**
* The UUID of the donor as recorded in Identity service. Note that in most cases this may refer to an
* account that no-longer exists, as we auto delete old accounts if no password is set.
*
* Non-null for all donations from April 2025 and later, will be null for old donations.
*
* @psalm-suppress PossiblyUnusedProperty - to be used soon for sending set password links
*/
#[ORM\Column(type: 'uuid', nullable: true)]
protected ?UuidInterface $donorUUID;
/**
* Position in sequence of donations taken in relation to a regular giving mandate, e.g. 1st
* (taken at mandate creation time), 2nd, 3rd etc.
*
* Null only iff this is a one-off, non regular-giving donation.
*/
#[ORM\Column(nullable: true)]
protected ?int $mandateSequenceNumber = null;
#[ORM\ManyToOne(targetEntity: RegularGivingMandate::class)]
private ?RegularGivingMandate $mandate = null;
/**
* Previously known as donor postal address,
* and may still be called that in other systems,
* but now used for billing postcode only. Some old
* donations from 2022 or earlier have full addresses here.
*
* May be a post code or equivilent from anywhere in the world,
* so we allow up to 15 chars which has been enough for all donors in the last 12 months.
*
* @var string|null
*/
#[ORM\Column(nullable: true, name: 'donorPostalAddress')]
protected ?string $donorBillingPostcode = null;
/**
* @var string|null From residential address, if donor is claiming Gift Aid.
*/
#[ORM\Column(nullable: true)]
protected ?string $donorHomeAddressLine1 = null;
/**
* @var string|null From residential address, if donor is claiming Gift Aid.
*/
#[ORM\Column(nullable: true)]
protected ?string $donorHomePostcode = null;
/**
* @var numeric-string Amount donor chose to tip. Precision numeric string.
* Set during donation setup and can also be modified later if the donor changes only this.
* @see Donation::$currencyCode
*/
#[ORM\Column(type: 'decimal', precision: 18, scale: 2)]
protected string $tipAmount = '0.00';
/**
* @var numeric-string Amount refunded to donor in case of accidental tip.
*
* Only set on donations from Feb 2025 and later.
*
* @see Donation::$currencyCode
*/
#[ORM\Column(type: 'decimal', precision: 18, scale: 2, nullable: true)]
protected ?string $tipRefundAmount = null;
/**
* @var bool Whether Gift Aid was claimed on the 'tip' donation to the Big Give.
*/
#[ORM\Column(nullable: true)]
protected ?bool $tipGiftAid = null;
/**
* @var bool Whether any Gift Aid claim should be made by the Big Give as an agent/nominee
* *if* `$giftAid is true too. This field is set independently to allow for claim
* status amendments so we must not assume a donation can actualy be claimed just
* because it's true.
* @see Donation::$giftAid
*/
#[ORM\Column(nullable: true)]
protected ?bool $tbgShouldProcessGiftAid = null;
/**
* @var ?DateTimeImmutable When a queued message that should lead to a Gift Aid claim was sent.
*/
#[ORM\Column(nullable: true)]
protected ?DateTimeImmutable $tbgGiftAidRequestQueuedAt = null;
/**
* @var ?DateTime When a claim submission attempt was detected to have an error returned.
*/
#[ORM\Column(nullable: true)]
protected ?DateTime $tbgGiftAidRequestFailedAt = null;
/**
* @var ?DateTime When a claim was detected accepted via an async poll.
*/
#[ORM\Column(nullable: true)]
protected ?DateTime $tbgGiftAidRequestConfirmedCompleteAt = null;
/**
* @var ?string Provided by HMRC upon initial claim submission acknowledgement.
* Doesn't imply success.
*/
#[ORM\Column(nullable: true)]
protected ?string $tbgGiftAidRequestCorrelationId = null;
/**
* @var ?string Verbatim final errors or messages from HMRC received immediately or
* (most likely based on real world observation) via an async poll.
*/
#[ORM\Column(type: 'text', length: 65535, nullable: true)]
protected ?string $tbgGiftAidResponseDetail = null;
#[ORM\Column(nullable: true)]
protected ?string $pspCustomerId = null;
/**
* Until a Donation is confirmed, we mostly try to avoid restricting switches between 'card' and
* 'pay_by_bank' as they both behave similarly except that `pay_by_bank` cannot be set up for
* future off-session usage. Update will try to change the method to the latest received from the
* frontend.
*/
#[ORM\Column(nullable: true)]
protected ?PaymentMethodType $paymentMethodType = PaymentMethodType::Card;
/**
* @var Collection<int,FundingWithdrawal>
*/
#[ORM\OneToMany(targetEntity: FundingWithdrawal::class, mappedBy: 'donation', fetch: 'EAGER')]
protected $fundingWithdrawals;
/**
* Date at which we refunded this to the donor. Ideally will be null. Should be not null only iff status is
* DonationStatus::Refunded
*/
#[ORM\Column(nullable: true, type: 'datetime_immutable')]
private ?\DateTimeImmutable $refundedAt = null;
/**
* We only have permission to collect a preAuthorized donation on or after the given date. Intended to be used
* with regular giving.
*
* @see DonationStatus::PreAuthorized
*/
#[ORM\Column(nullable: true, type: 'datetime_immutable')]
private ?DateTimeImmutable $preAuthorizationDate = null;
/**
* Stripe payout used to pay this donation out to recipient charity. Null on
* all donations from before May 2025.
*/
#[ORM\Column(nullable: true)]
private ?string $stripePayoutId = null;
/**
* Records when (and if) this donation was paid out from Big Give to the recipient charity. Null on
* all donations from before May 2025.
*
* Based on {@see Payout::$arrival_date}
*/
#[ORM\Column(nullable: true, type: 'datetime_immutable')]
private ?DateTimeImmutable $paidOutAt = null;
#[ORM\Column(nullable: true)]
private ?bool $payoutSuccessful = false;
/**
* Payment card brand used or intended to be used for this donation. Null for historic donations (July 2025 & earlier
* ) for which we didn't save this info, new pending donations where a card hasn't yet been set,
* and for donations paid from a donor funds account.
*/
#[ORM\Column(nullable: true, name: 'paymentCard_brand')]
private ?CardBrand $paymentCardBrand;
/**
* Payment card country used or intended to be used for this donation. Null for historic donations (July 2025 & earlier
* ) for which we didn't save this info, new pending donations where a card hasn't yet been set,
* and for donations paid from a donor funds account.
*/
#[ORM\Column(nullable: true, name: 'paymentCard_country')]
private ?CountryAlpha2 $paymentCardCountry;
/** @psalm-suppress UnusedProperty - for now just recorded for internal reference */
#[ORM\Column(nullable: true)]
private ?string $ryftPaymentSessionId = null;
/**
* @param string|null $billingPostcode
* @psalm-param numeric-string $amount
* @psalm-param ?numeric-string $tipAmount
*/
public function __construct(
string $amount,
string $currencyCode,
PaymentMethodType $paymentMethodType,
Campaign $campaign,
?bool $charityComms,
?bool $championComms,
?string $pspCustomerId,
PaymentServiceProvider $psp,
?bool $optInTbgEmail,
?DonorName $donorName,
?EmailAddress $emailAddress,
?string $countryCode,
?string $tipAmount,
?RegularGivingMandate $mandate,
?DonationSequenceNumber $mandateSequenceNumber,
PersonId $donorId,
bool $giftAid = false,
?bool $tipGiftAid = null,
?string $homeAddress = null,
?string $homePostcode = null,
?string $billingPostcode = null,
) {
$this->uuid = Uuid::uuid4();
$this->fundingWithdrawals = new ArrayCollection();
$this->currencyCode = $currencyCode;
$maximumAmount = self::maximumAmount($paymentMethodType);
if (
bccomp($amount, (string)self::MINUMUM_AMOUNT, 2) === -1 ||
bccomp($amount, (string)$maximumAmount, 2) === 1
) {
throw new \UnexpectedValueException(sprintf(
'Amount %s is out of allowed range %d-%d %s',
$amount,
self::MINUMUM_AMOUNT,
$maximumAmount,
$this->currencyCode,
));
}
$this->amount = $amount;
/** The donor shouldn't definitively expect any match funds unless we actually reserve funds for them which will be done after
* the donation is constructed but before its first returned to the browser */
$this->expectedMatchAmount = Money::zero($this->currency());
$this->paymentMethodType = $paymentMethodType;
$this->createdNow(); // Mimic ORM persistence hook attribute, calling its fn explicitly instead.
$this->setPsp($campaign->getCharity()->psp->value);
$this->campaign = $campaign; // Charity & match expectation determined implicitly from this
$this->setCharityComms($charityComms);
$this->setChampionComms($championComms);
$this->setPspCustomerId($pspCustomerId);
$this->setTbgComms($optInTbgEmail);
$this->setDonorName($donorName);
$this->setDonorEmailAddress($emailAddress);
$this->giftAid = $giftAid;
$this->tipGiftAid = $tipGiftAid;
$this->donorHomeAddressLine1 = $homeAddress;
$this->donorHomePostcode = $homePostcode;
// We probably don't need to test for all these, just replicationg behaviour of `empty` that was used before.
if ($countryCode !== '' && $countryCode !== null && $countryCode !== '0') {
$this->setDonorCountryCode(strtoupper($countryCode));
}
if (isset($tipAmount)) {
$this->setTipAmount($tipAmount);
}
$this->mandate = $mandate;
$this->mandateSequenceNumber = $mandateSequenceNumber?->number;
$this->donorBillingPostcode = $billingPostcode;
$this->donorUUID = $donorId->toUUID();
// Using null card details as we don't yet know what payment method will be used
// and we need something. We expect these will always give the cheapest fee, but will
// be replaced by actual card details before donation is confirmed.
$this->paymentCardBrand = null;
$this->paymentCardCountry = null;
$this->setTbgShouldProcessGiftAid($campaign->getCharity()->isTbgClaimingGiftAid());
$this->deriveFees();
$this->psp = $psp->value;
}
/**
* @param PersonId $donorId
* @throws \Assert\AssertionFailedException
* @throws \UnexpectedValueException
*/
public static function fromApiModel(DonationCreate $donationData, Campaign $campaign, PersonId $donorId): Donation
{
Assertion::inArray($donationData->psp, PaymentServiceProvider::VALUES);
return new self(
amount: $donationData->donationAmount,
currencyCode: $donationData->currencyCode,
paymentMethodType: $donationData->pspMethodType,
campaign: $campaign,
charityComms: $donationData->optInCharityEmail,
championComms: $donationData->optInChampionEmail,
pspCustomerId: $donationData->pspCustomerId,
psp: PaymentServiceProvider::from($donationData->psp),
optInTbgEmail: $donationData->optInTbgEmail,
donorName: $donationData->donorName,
emailAddress: $donationData->emailAddress,
countryCode: $donationData->countryCode,
tipAmount: $donationData->tipAmount,
mandate: null,
// Main form starts off with this null on init in the API model, so effectively it's ignored here
// then as `false` is also the constructor's default. Donation Funds tips should send a bool value
// from the start.
mandateSequenceNumber: null,
// Not meaningfully used yet (typical donations set it on Update instead; Donation Funds
// tips don't have a "tip" because the donation is to BG), but map just in case.
donorId: $donorId,
giftAid: $donationData->giftAid ?? false,
tipGiftAid: $donationData->tipGiftAid,
homeAddress: $donationData->homeAddress, // no support for billing post code on donation creation in API - only on update.
homePostcode: $donationData->homePostcode,
billingPostcode: null
);
}
private static function maximumAmount(PaymentMethodType $paymentMethodType): int
{
return match ($paymentMethodType) {
PaymentMethodType::CustomerBalance => self::MAXIMUM_CUSTOMER_BALANCE_DONATION,
PaymentMethodType::Card => self::MAXIMUM_CARD_DONATION,
PaymentMethodType::PayByBank => self::MAXIMUM_CARD_DONATION,
};
}
/**
* Multiples by 25%
*
* @param numeric-string $amount
* @return numeric-string
*/
public static function donationAmountToGiftAidValue(string $amount): string
{
$giftAidFactor = bcdiv(self::GIFT_AID_PERCENTAGE, '100', 2);
return bcmul($amount, $giftAidFactor, 2);
}
public function __toString(): string
{
// if we're in __toString then probably something has already gone wrong, and we don't want to allow
// any more crashes during the logging process.
try {
$charityName = $this->getCampaign()->getCharity()->getName();
} catch (\Throwable $t) {
// perhaps the charity was never pulled from Salesforce into our database, in which case we might
// have a TypeError trying to get a string name from it.
$charityName = "[pending charity threw " . get_class($t) . "]";
}
$id = is_null($this->id) ? 'non-persisted' : "#{$this->id}";
return "Donation $id {$this->getUuid()} to $charityName";
}
/*
* In contrast to __toString, this is used when creating a payment intent. If we can't find the charity name
* then we should let the process of making the intent and registering the donation crash.
*/
public function getDescription(): string
{
$charityName = $this->getCampaign()->getCharity()->getName();
return "Donation {$this->getUuid()} to $charityName";
}
/**
* @param PreUpdateEventArgs $args
* @throws \LogicException if amount is changed
*/
#[ORM\PreUpdate]
public function preUpdate(PreUpdateEventArgs $args): void
{
if (!$args->hasChangedField('amount')) {
return;
}
if ($args->getOldValue('amount') !== $args->getNewValue('amount')) {
throw new \LogicException('Amount may not be changed after a donation is created');
}
}
/**
* @return array<string|mixed>|null A representation of the donation suitable for sending to Salesforce,
* or null if this donation cannot be represented in SF in its current state.
*/
public function toSFApiModel(): null|array
{
$data = [
...$this->toFrontEndApiModel(),
'originalPspFee' => (float) $this->getOriginalPspFee(),
'tipRefundAmount' => $this->getTipRefundAmount()?->toMajorUnitFloat(),
'stripePayoutId' => $this->stripePayoutId,
'paidOutAt' => $this->paidOutAt?->format(DateTimeInterface::ATOM),
'payoutSuccessful' => $this->payoutSuccessful,
'isOffSession' => $this->isOffSession(),
];
// As of mid 2024 only the actual donate frontend gets this value, to avoid
// confusion around values that are too temporary to be useful in a CRM anyway.
unset($data['matchReservedAmount']);
if ($this->mandate) {
$mandateSalesforceId = $this->mandate->getSalesforceId();
if ($mandateSalesforceId === null) {
return null; // we can't send the Donation to SF yet, as it can't be linked to its mandate.
}
$data['mandate'] = [
'salesforceId' => $mandateSalesforceId,
];
}
return $data;
}
/**
* @return array<string, mixed>
*/
public function toFrontEndApiModel(bool $enableNoReservationsMode = false): array
{
$totalPaidByDonor = $this->getTotalPaidByDonor();
$fundingWithdrawalsByType = $this->getWithdrawalTotalByFundType();
$data = [
'amountMatchedByChampionFunds' => (float) $fundingWithdrawalsByType['amountMatchedByChampionFunds'],
'amountMatchedByPledges' => (float) $fundingWithdrawalsByType['amountMatchedByPledges'],
'amountPreauthorizedFromChampionFunds' => (float) $fundingWithdrawalsByType['amountPreauthorizedFromChampionFunds'],
'billingPostalAddress' => $this->donorBillingPostcode,
'charityFee' => (float) $this->getCharityFee(),
'charityFeeVat' => (float) $this->getCharityFeeVat(),
'charityId' => $this->getCampaign()->getCharity()->getSalesforceId(),
'charityName' => $this->getCampaign()->getCharity()->getName(),
'countryCode' => $this->getDonorCountryCode(),
'collectedTime' => $this->getCollectedAt()?->format(DateTimeInterface::ATOM),
'createdTime' => $this->getCreatedDate()->format(DateTimeInterface::ATOM),
'currencyCode' => $this->currency()->isoCode(),
'donationAmount' => (float) $this->getAmount(),
'totalPaid' => is_null($totalPaidByDonor) ? null : (float)$totalPaidByDonor,
'donationId' => $this->getUuid(),
'donationMatched' => $this->getCampaign()->isMatched() && ! $enableNoReservationsMode,
'emailAddress' => $this->getDonorEmailAddress()?->email,
'firstName' => $this->getDonorFirstName(true),
'giftAid' => $this->hasGiftAid(),
'homeAddress' => $this->getDonorHomeAddressLine1(),
'homePostcode' => $this->getDonorHomePostcode(),
'isOrganisationDonor' => empty($this->donorFirstName) && $this->getDonorLastName(true),
'lastName' => $this->getDonorLastName(true),
'matchedAmount' => $this->matchedAmount()->toMajorUnitFloat(),
'matchReservedAmount' => 0,
'optInCharityEmail' => $this->getCharityComms(),
'optInChampionEmail' => $this->getChampionComms(),
'optInTbgEmail' => $this->getTbgComms(),
'projectId' => $this->getCampaign()->getSalesforceId(),
'psp' => $this->getPsp(),
'pspCustomerId' => $this->getPspCustomerId()?->stripeCustomerId,
'pspMethodType' => $this->getPaymentMethodType()?->value,
'refundedTime' => $this->refundedAt?->format(DateTimeInterface::ATOM),
'status' => $this->getDonationStatus(),
'tbgGiftAidRequestConfirmedCompleteAt' =>
$this->tbgGiftAidRequestConfirmedCompleteAt?->format(DateTimeInterface::ATOM),
'tipAmount' => (float) $this->getTipAmount(),
'tipGiftAid' => $this->hasTipGiftAid(),
'transactionId' => $this->getTransactionId(),
'referenceCode' => $this->getReferenceCode(),
'updatedTime' => $this->getUpdatedDate()->format(DateTimeInterface::ATOM),
];
if (in_array($this->getDonationStatus(), [DonationStatus::Pending, DonationStatus::PreAuthorized], true)) {
$data['matchReservedAmount'] = (float) $this->getFundingWithdrawalTotal();
}
if ($this->mandate) {
// Not including the entire mandate details as that would be wasteful, just parts FE needs to display with
// the donation.
$data['mandate']['uuid'] = $this->mandate->getUuid()->toString();
$data['mandate']['activeFrom'] = $this->mandate->getActiveFrom()?->format(DateTimeInterface::ATOM);
} else {
$data['mandate'] = null;
}
return $data;
}
public function getDonationStatus(): DonationStatus
{
return $this->donationStatus;
}
public function setDonationStatusForTest(DonationStatus $donationStatus): void
{
Assertion::eq(Environment::current(), Environment::Test);
$this->donationStatus = match ($donationStatus) {
DonationStatus::Refunded =>
throw new \Exception('Donation::recordRefundAt must be used to set refunded status'),
DonationStatus::Cancelled =>
throw new \Exception('Donation::cancelled must be used to cancel'),
DonationStatus::Collected =>
throw new \Exception('Donation::collectFromStripe must be used to collect'),
DonationStatus::Paid =>
throw new \Exception('Donation::recordPayout must be used for paid status'),
DonationStatus::Chargedback =>
throw new \Exception('DonationStatus::Chargedback is deprecated'),
DonationStatus::Failed,
DonationStatus::Pending,
DonationStatus::PreAuthorized
=> $donationStatus,
};
}
public function getCollectedAt(): ?DateTimeImmutable
{
return $this->collectedAt;
}
/**
* @return Campaign
*/
public function getCampaign(): Campaign
{
return $this->campaign;
}
/**
* @param Campaign $campaign
*/
public function setCampaign(Campaign $campaign): void
{
$this->campaign = $campaign;
}
public function getDonorEmailAddress(): ?EmailAddress
{
return ((bool) $this->donorEmailAddress) ? EmailAddress::of($this->donorEmailAddress) : null;
}
public function setDonorEmailAddress(?EmailAddress $donorEmailAddress): void
{
$this->donorEmailAddress = $donorEmailAddress?->email;
}
public function getCharityComms(): ?bool
{
return $this->charityComms;
}
public function setCharityComms(?bool $charityComms): void
{
$this->charityComms = $charityComms;
}
public function getChampionComms(): ?bool
{
return $this->championComms;
}
public function setChampionComms(?bool $championComms): void
{
$this->championComms = $championComms;
}
public function getDonorFirstName(bool $salesforceSafe = false): ?string
{
$firstName = $this->donorFirstName;
if ($salesforceSafe) {
$firstName = $this->makeSalesforceSafe($firstName, false);
}
return $firstName;
}
public function setDonorName(?DonorName $donorName): void
{
$this->donorFirstName = $donorName?->first;
$this->donorLastName = $donorName?->last;
}
public function getDonorLastName(bool $salesforceSafe = false): ?string
{
$lastName = $this->donorLastName;
if ($salesforceSafe) {
$lastName = $this->makeSalesforceSafe($lastName, true);
}
return $lastName;
}
public function hasGiftAid(): bool
{
return $this->giftAid;
}
/**
* Ensure 'deriveFees' is always called after this.
*/
private function setGiftAid(bool $giftAid): void
{
$this->giftAid = $giftAid;
// Default tip Gift Aid to main Gift Aid value. If it is set explicitly
// first this will be skipped. If set explicitly after, the later call
// will persist.
if ($this->tipGiftAid === null) {
$this->tipGiftAid = $giftAid;
}
}
public function getTbgComms(): ?bool
{
return $this->tbgComms;
}
public function setTbgComms(?bool $tbgComms): void
{
$this->tbgComms = $tbgComms;
}
/**
* Get core donation amount excluding any tip or fee cover.
*
* @return numeric-string In full pounds GBP.
*/
public function getAmount(): string
{
return $this->amount;
}
/**
* @return numeric-string In full pounds GBP. Net fee if VAT is added.
*/
public function getCharityFee(): string
{
return $this->charityFee;
}
private function hasCharityFee(): bool
{
return bccomp($this->charityFee, '0.00', 2) === 1;
}
/**
* @return numeric-string
*/
#[Pure] public function getCharityFeeGross(): string
{
return bcadd($this->getCharityFee(), $this->getCharityFeeVat(), 2);
}
public function setTransactionId(string $transactionId): void
{
$this->transactionId = $transactionId;
}
/**
* @return string|null
*/
public function getTransferId(): ?string
{
return $this->transferId;
}
public function getDonorCountryCode(): ?string
{
return $this->donorCountryCode;
}
/**
* @psalm-return numeric-string Total amount in withdrawals - not necessarily finalised.
*/
public function getFundingWithdrawalTotal(): string
{
$withdrawalTotal = '0.00';
foreach ($this->fundingWithdrawals as $fundingWithdrawal) {
if ($fundingWithdrawal->isReleased()) {
continue;
}
$withdrawalTotal = bcadd($withdrawalTotal, $fundingWithdrawal->getAmount(), 2);
}
return $withdrawalTotal;
}
public function getFundingWithdrawalTotalAsObject(): Money
{
return Money::fromNumericString(
$this->getFundingWithdrawalTotal(),
Currency::fromIsoCode($this->currencyCode)
);
}
/**
* @return array{
* amountMatchedByChampionFunds: numeric-string,
* amountMatchedByPledges: numeric-string,
* amountPreauthorizedFromChampionFunds: numeric-string,
* amountMatchedOther: numeric-string,
* }
*/
public function getWithdrawalTotalByFundType(): array
{
$withdrawalTotals = [
'amountMatchedByChampionFunds' => '0.00',
'amountMatchedByPledges' => '0.00',
'amountPreauthorizedFromChampionFunds' => '0.00',
'amountMatchedOther' => '0.00', // This key is not sent to SF, covers match fund usage that we don't need to
// report, i.e. for donations that are neither sucessful nor preauthed.
];
foreach ($this->fundingWithdrawals as $fundingWithdrawal) {
if ($fundingWithdrawal->isReleased()) {
continue;
}
$fundTypeOfThisWithdrawal = $fundingWithdrawal->getCampaignFunding()->getFund()->getFundType();
// I don't think the match.unhandled error from phpstan here can be right - we handle 12 cases: 3 cases of enum * 2 values of bool * 2 values of bool
// @phpstan-ignore match.unhandled
$key = match ([$fundTypeOfThisWithdrawal, $this->donationStatus->isSuccessful(), $this->donationStatus === DonationStatus::PreAuthorized ]) {
[FundType::ChampionFund, true, true] => throw new \LogicException("impossible status"),
[FundType::ChampionFund, true, false] => 'amountMatchedByChampionFunds',
[FundType::ChampionFund, false, true] => 'amountPreauthorizedFromChampionFunds',
[FundType::ChampionFund, false, false] => 'amountMatchedOther',
[FundType::Pledge, true, true] => throw new \LogicException("impossible status"),
[FundType::Pledge, true, false] => 'amountMatchedByPledges',
[FundType::Pledge, false, true] => throw new \RuntimeException("unexpected pre-authed donation using pledge fund"),
[FundType::Pledge, false, false] => 'amountMatchedOther',
[FundType::TopupPledge, true, true] => throw new \LogicException("impossible status"),
[FundType::TopupPledge, true, false] => 'amountMatchedByPledges',
[FundType::TopupPledge, false, true] => throw new \RuntimeException("unexpected pre-authed donation using top-up pledge fund"),
[FundType::TopupPledge, false, false] => 'amountMatchedOther',
};
$withdrawalTotals[$key] = bcadd($withdrawalTotals[$key], $fundingWithdrawal->getAmount(), 2);
}
return $withdrawalTotals;
}
/**
* @return string|null For a stripe based donation this is the payment intent ID. Usually set immediately for
* each new donation, but for delayed regular giving donations will not be set until we're ready to collect
* the payment.
*/
public function getTransactionId(): ?string
{
return $this->transactionId;
}