Passed
Push — main ( 3df85b...6e75fa )
by Iain
05:19
created

SubscriptionManager::createSubscription()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 0

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 0
c 0
b 0
f 0
nc 1
nop 3
dl 0
loc 2
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * Copyright Humbly Arrogant Software Limited 2020-2023.
7
 *
8
 * Use of this software is governed by the Business Source License included in the LICENSE file and at https://getparthenon.com/docs/next/license.
9
 *
10
 * Change Date: 26.06.2026 ( 3 years after 2.2.0 release )
11
 *
12
 * On the date above, in accordance with the Business Source License, use of this software will be governed by the open source license specified in the LICENSE file.
13
 */
14
15
namespace Parthenon\Billing\Subscription;
16
17
use Obol\Model\CancelSubscription;
18
use Obol\Model\Enum\ProrataType;
19
use Obol\Provider\ProviderInterface;
20
use Parthenon\Billing\Dto\StartSubscriptionDto;
21
use Parthenon\Billing\Entity\CustomerInterface;
22
use Parthenon\Billing\Entity\PaymentCard;
23
use Parthenon\Billing\Entity\Price;
24
use Parthenon\Billing\Entity\Subscription;
25
use Parthenon\Billing\Entity\SubscriptionPlan;
26
use Parthenon\Billing\Enum\BillingChangeTiming;
27
use Parthenon\Billing\Enum\SubscriptionStatus;
28
use Parthenon\Billing\Event\PaymentCreated;
29
use Parthenon\Billing\Event\SubscriptionCancelled;
30
use Parthenon\Billing\Event\SubscriptionCreated;
31
use Parthenon\Billing\Exception\SubscriptionCreationException;
32
use Parthenon\Billing\Factory\EntityFactoryInterface;
33
use Parthenon\Billing\Obol\BillingDetailsFactoryInterface;
34
use Parthenon\Billing\Obol\PaymentFactoryInterface;
35
use Parthenon\Billing\Obol\SubscriptionFactoryInterface;
36
use Parthenon\Billing\Plan\Plan;
37
use Parthenon\Billing\Plan\PlanManagerInterface;
38
use Parthenon\Billing\Plan\PlanPrice;
39
use Parthenon\Billing\Repository\PaymentCardRepositoryInterface;
40
use Parthenon\Billing\Repository\PaymentRepositoryInterface;
41
use Parthenon\Billing\Repository\PriceRepositoryInterface;
42
use Parthenon\Billing\Repository\SubscriptionPlanRepositoryInterface;
43
use Parthenon\Billing\Repository\SubscriptionRepositoryInterface;
44
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
45
46
final class SubscriptionManager implements SubscriptionManagerInterface
47
{
48
    public function __construct(
49
        private PaymentCardRepositoryInterface $paymentDetailsRepository,
50
        private ProviderInterface $provider,
51
        private BillingDetailsFactoryInterface $billingDetailsFactory,
52
        private PaymentFactoryInterface $paymentFactory,
53
        private SubscriptionFactoryInterface $subscriptionFactory,
54
        private PaymentRepositoryInterface $paymentRepository,
55
        private PlanManagerInterface $planManager,
56
        private SubscriptionPlanRepositoryInterface $subscriptionPlanRepository,
57
        private PriceRepositoryInterface $priceRepository,
58
        private SubscriptionRepositoryInterface $subscriptionRepository,
59
        private EntityFactoryInterface $entityFactory,
60
        private EventDispatcherInterface $dispatcher,
61
    ) {
62
    }
63
64
    public function createSubscription(CustomerInterface $customer, SubscriptionPlan|Plan $plan, Price|PlanPrice $planPrice): Subscription
65
    {
66
    }
0 ignored issues
show
Bug Best Practice introduced by
In this branch, the function will implicitly return null which is incompatible with the type-hinted return Parthenon\Billing\Entity\Subscription. Consider adding a return statement or allowing null as return value.

For hinted functions/methods where all return statements with the correct type are only reachable via conditions, ?null? gets implicitly returned which may be incompatible with the hinted type. Let?s take a look at an example:

interface ReturnsInt {
    public function returnsIntHinted(): int;
}

class MyClass implements ReturnsInt {
    public function returnsIntHinted(): int
    {
        if (foo()) {
            return 123;
        }
        // here: null is implicitly returned
    }
}
Loading history...
67
68
    public function startSubscription(CustomerInterface $customer, SubscriptionPlan|Plan $plan, Price|PlanPrice $planPrice, ?PaymentCard $paymentDetails = null, int $seatNumbers = 1, ?bool $hasTrial = null, ?int $trialLengthDays = 0): Subscription
69
    {
70
        $billingDetails = $this->billingDetailsFactory->createFromCustomerAndPaymentDetails($customer, $paymentDetails);
0 ignored issues
show
Bug introduced by
It seems like $paymentDetails can also be of type null; however, parameter $paymentDetails of Parthenon\Billing\Obol\B...omerAndPaymentDetails() does only seem to accept Parthenon\Billing\Entity\PaymentCard, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

70
        $billingDetails = $this->billingDetailsFactory->createFromCustomerAndPaymentDetails($customer, /** @scrutinizer ignore-type */ $paymentDetails);
Loading history...
71
        $obolSubscription = $this->subscriptionFactory->createSubscription($billingDetails, $planPrice, $seatNumbers, $hasTrial ?? $plan->getHasTrial(), $trialLengthDays ?? $plan->getTrialLengthDays());
72
        $obolSubscription->setStoredPaymentReference($paymentDetails->getStoredPaymentReference());
0 ignored issues
show
Bug introduced by
The method getStoredPaymentReference() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

72
        $obolSubscription->setStoredPaymentReference($paymentDetails->/** @scrutinizer ignore-call */ getStoredPaymentReference());

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
73
74
        if ($this->subscriptionRepository->hasActiveSubscription($customer)) {
75
            $subscription = $this->subscriptionRepository->getOneActiveSubscriptionForCustomer($customer);
76
77
            if ($subscription->getCurrency() != $planPrice->getCurrency()) {
78
                throw new SubscriptionCreationException("Can't add a child subscription for a different currency");
79
            }
80
81
            $obolSubscription->setParentReference($subscription->getMainExternalReference());
82
        }
83
84
        $subscriptionCreationResponse = $this->provider->payments()->startSubscription($obolSubscription);
85
        if ($subscriptionCreationResponse->hasCustomerCreation()) {
86
            $customer->setPaymentProviderDetailsUrl($subscriptionCreationResponse->getCustomerCreation()->getDetailsUrl());
87
            $customer->setExternalCustomerReference($subscriptionCreationResponse->getCustomerCreation()->getReference());
88
        }
89
90
        $subscription = $this->entityFactory->getSubscriptionEntity();
91
        $subscription->setPlanName($plan->getName());
92
        if ($planPrice->isRecurring()) {
0 ignored issues
show
Bug introduced by
The method isRecurring() does not exist on Parthenon\Billing\Plan\PlanPrice. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

92
        if ($planPrice->/** @scrutinizer ignore-call */ isRecurring()) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
93
            $subscription->setPaymentSchedule($planPrice->getSchedule());
94
        }
95
        $subscription->setActive(true);
96
        $subscription->setMoneyAmount($subscriptionCreationResponse->getPaymentDetails()?->getAmount());
97
        $subscription->setStatus(SubscriptionStatus::ACTIVE);
98
        $subscription->setMainExternalReference($subscriptionCreationResponse->getSubscriptionId());
99
        $subscription->setChildExternalReference($subscriptionCreationResponse->getLineId());
100
        $subscription->setSeats($seatNumbers);
101
        $subscription->setCreatedAt(new \DateTime());
102
        $subscription->setUpdatedAt(new \DateTime());
103
        $subscription->setStartOfCurrentPeriod(new \DateTime());
104
        $subscription->setValidUntil($subscriptionCreationResponse->getBilledUntil());
105
        $subscription->setCustomer($customer);
106
        $subscription->setMainExternalReferenceDetailsUrl($subscriptionCreationResponse->getDetailsUrl());
107
        $subscription->setPaymentDetails($paymentDetails);
108
        $subscription->setTrialLengthDays($obolSubscription->getTrialLengthDays());
109
        $subscription->setHasTrial($obolSubscription->hasTrial());
110
111
        if ($plan instanceof SubscriptionPlan) {
112
            $subscription->setSubscriptionPlan($plan);
113
        } elseif ($plan->hasEntityId()) {
114
            $subscriptionPlan = $this->subscriptionPlanRepository->findById($plan->getEntityId());
115
            $subscription->setSubscriptionPlan($subscriptionPlan);
116
        }
117
118
        if ($planPrice instanceof Price) {
119
            $subscription->setPrice($planPrice);
120
        } elseif ($planPrice->hasEntityId()) {
121
            $price = $this->priceRepository->findById($planPrice->getEntityId());
122
            $subscription->setPrice($price);
123
        }
124
        $this->subscriptionRepository->save($subscription);
125
        $this->subscriptionRepository->updateValidUntilForAllActiveSubscriptions($customer, $subscription->getMainExternalReference(), $subscriptionCreationResponse->getBilledUntil());
126
127
        $this->dispatcher->dispatch(new SubscriptionCreated($subscription), SubscriptionCreated::NAME);
128
129
        $obolPaymentDetails = $subscriptionCreationResponse->getPaymentDetails();
130
        if ($obolPaymentDetails) {
131
            $payment = $this->paymentFactory->fromSubscriptionCreation($obolPaymentDetails, $customer);
132
            $payment->addSubscription($subscription);
133
            $this->paymentRepository->save($payment);
134
135
            $this->dispatcher->dispatch(new PaymentCreated($payment, true), PaymentCreated::NAME);
136
        }
137
138
        return $subscription;
139
    }
140
141
    public function startSubscriptionWithDto(CustomerInterface $customer, StartSubscriptionDto $startSubscriptionDto): Subscription
142
    {
143
        if (!$startSubscriptionDto->getPaymentDetailsId()) {
144
            $paymentDetails = $this->paymentDetailsRepository->getDefaultPaymentCardForCustomer($customer);
145
        } else {
146
            $paymentDetails = $this->paymentDetailsRepository->findById($startSubscriptionDto->getPaymentDetailsId());
147
        }
148
149
        $plan = $this->planManager->getPlanByName($startSubscriptionDto->getPlanName());
150
        $planPrice = $plan->getPriceForPaymentSchedule($startSubscriptionDto->getSchedule(), $startSubscriptionDto->getCurrency());
151
152
        return $this->startSubscription($customer, $plan, $planPrice, $paymentDetails, $startSubscriptionDto->getSeatNumbers());
153
    }
154
155
    public function cancelSubscriptionAtEndOfCurrentPeriod(Subscription $subscription): Subscription
156
    {
157
        $obolSubscription = $this->subscriptionFactory->createSubscriptionFromEntity($subscription, false);
0 ignored issues
show
Unused Code introduced by
The call to Parthenon\Billing\Obol\S...ubscriptionFromEntity() has too many arguments starting with false. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

157
        /** @scrutinizer ignore-call */ 
158
        $obolSubscription = $this->subscriptionFactory->createSubscriptionFromEntity($subscription, false);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
158
159
        $cancelRequest = new CancelSubscription();
160
        $cancelRequest->setSubscription($obolSubscription);
161
        $cancelRequest->setInstantCancel(false);
162
163
        $cancellation = $this->provider->payments()->stopSubscription($cancelRequest);
0 ignored issues
show
Unused Code introduced by
The assignment to $cancellation is dead and can be removed.
Loading history...
164
165
        $subscription->setStatus(SubscriptionStatus::PENDING_CANCEL);
166
        $subscription->endAtEndOfPeriod();
167
168
        $this->subscriptionRepository->save($subscription);
169
        $this->dispatcher->dispatch(new SubscriptionCancelled($subscription), SubscriptionCancelled::NAME);
170
171
        return $subscription;
172
    }
173
174
    public function cancelSubscriptionInstantly(Subscription $subscription): Subscription
175
    {
176
        $obolSubscription = $this->subscriptionFactory->createSubscriptionFromEntity($subscription);
177
178
        $cancelRequest = new CancelSubscription();
179
        $cancelRequest->setSubscription($obolSubscription);
180
        $cancelRequest->setInstantCancel(true);
181
182
        $cancellation = $this->provider->payments()->stopSubscription($cancelRequest);
0 ignored issues
show
Unused Code introduced by
The assignment to $cancellation is dead and can be removed.
Loading history...
183
184
        $subscription->setStatus(SubscriptionStatus::CANCELLED);
185
        $subscription->setActive(false);
186
        $subscription->endNow();
187
188
        $this->subscriptionRepository->save($subscription);
189
        $this->dispatcher->dispatch(new SubscriptionCancelled($subscription), SubscriptionCancelled::NAME);
190
191
        return $subscription;
192
    }
193
194
    public function cancelSubscriptionOnDate(Subscription $subscription, \DateTimeInterface $dateTime): Subscription
195
    {
196
        $obolSubscription = $this->subscriptionFactory->createSubscriptionFromEntity($subscription);
197
198
        $cancelRequest = new CancelSubscription();
199
        $cancelRequest->setSubscription($obolSubscription);
200
        $cancelRequest->setInstantCancel(false);
201
202
        $cancellation = $this->provider->payments()->stopSubscription($cancelRequest);
0 ignored issues
show
Unused Code introduced by
The assignment to $cancellation is dead and can be removed.
Loading history...
203
204
        $subscription->setStatus(SubscriptionStatus::PENDING_CANCEL);
205
        $subscription->setEndedAt($dateTime);
206
        $subscription->setValidUntil($dateTime);
207
208
        $this->subscriptionRepository->save($subscription);
209
        $this->dispatcher->dispatch(new SubscriptionCancelled($subscription), SubscriptionCancelled::NAME);
210
211
        return $subscription;
212
    }
213
214
    public function changeSubscriptionPrice(Subscription $subscription, Price $price, BillingChangeTiming $billingChangeTiming): void
215
    {
216
        $subscription->setPrice($price);
217
        $subscription->setMoneyAmount($price->getAsMoney());
218
        $obolSubscription = $this->subscriptionFactory->createSubscriptionFromEntity($subscription);
219
        $obolSubscription->setPriceId($price->getExternalReference());
0 ignored issues
show
Bug introduced by
It seems like $price->getExternalReference() can also be of type null; however, parameter $priceId of Obol\Model\Subscription::setPriceId() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

219
        $obolSubscription->setPriceId(/** @scrutinizer ignore-type */ $price->getExternalReference());
Loading history...
220
        $prorataTye = match ($billingChangeTiming) {
221
            BillingChangeTiming::INSTANTLY => ProrataType::NOW,
222
            default => ProrataType::NONE,
223
        };
224
225
        $this->provider->subscriptions()->updatePrice($obolSubscription, $prorataTye);
226
    }
227
228
    public function changeSubscriptionPlan(Subscription $subscription, SubscriptionPlan $plan, Price $price, BillingChangeTiming $billingChangeTiming): void
229
    {
230
        $subscription->setSubscriptionPlan($plan);
231
        $subscription->setPlanName($plan->getName());
232
        $subscription->setPrice($price);
233
        $subscription->setMoneyAmount($price->getAsMoney());
234
235
        $obolSubscription = $this->subscriptionFactory->createSubscriptionFromEntity($subscription);
236
        $obolSubscription->setPriceId($price->getExternalReference());
0 ignored issues
show
Bug introduced by
It seems like $price->getExternalReference() can also be of type null; however, parameter $priceId of Obol\Model\Subscription::setPriceId() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

236
        $obolSubscription->setPriceId(/** @scrutinizer ignore-type */ $price->getExternalReference());
Loading history...
237
        $prorataTye = match ($billingChangeTiming) {
238
            BillingChangeTiming::INSTANTLY => ProrataType::NOW,
239
            default => ProrataType::NONE,
240
        };
241
242
        $this->provider->subscriptions()->updatePrice($obolSubscription, $prorataTye);
243
    }
244
}
245