From 2f97c6de896781acff480281c0e0236c01bf058e Mon Sep 17 00:00:00 2001 From: Matus Kalafut Date: Thu, 19 Oct 2023 12:52:47 +0000 Subject: [PATCH] Fix grace period handling - Renewed subscriptions did not create because of subscription meta assigned to grace period subscription. - Grace period subscription has to have subscription type of actual next subscription if user renews payment. remp/crm#2963 --- src/GooglePlayBillingModule.php | 1 + .../DeveloperNotificationReceivedHandler.php | 56 ++++++++++----- ...ficationReceivedHandlerGracePeriodTest.php | 71 ++++++++++++------- ...grace_period_meta_key_to_subscriptions.php | 39 ++++++++++ 4 files changed, 126 insertions(+), 41 deletions(-) create mode 100644 src/migrations/20231017134348_add_grace_period_meta_key_to_subscriptions.php diff --git a/src/GooglePlayBillingModule.php b/src/GooglePlayBillingModule.php index ed5d0c6..8184c87 100644 --- a/src/GooglePlayBillingModule.php +++ b/src/GooglePlayBillingModule.php @@ -21,6 +21,7 @@ class GooglePlayBillingModule extends CrmModule const META_KEY_PURCHASE_TOKEN = 'google_play_billing_purchase_token'; const META_KEY_ORDER_ID = 'google_play_billing_order_id'; const META_KEY_DEVELOPER_NOTIFICATION_ID = 'google_play_billing_developer_notification_id'; + const META_KEY_GRACE_PERIOD_SUBSCRIPTION = 'google_play_billing_grace_period_subscription'; public const USER_SOURCE_APP = 'android-app'; diff --git a/src/Hermes/DeveloperNotificationReceivedHandler.php b/src/Hermes/DeveloperNotificationReceivedHandler.php index 6702dfe..0d73d1b 100644 --- a/src/Hermes/DeveloperNotificationReceivedHandler.php +++ b/src/Hermes/DeveloperNotificationReceivedHandler.php @@ -197,12 +197,24 @@ private function createPayment(SubscriptionResponse $subscriptionResponse, Activ $subscriptionResponse->getRawResponse()->getOrderId() ); - if ($subscription) { + if ($subscription?->type === SubscriptionsRepository::TYPE_FREE) { // free trial exists for given SubscriptionResponse, // do not create payment return null; } + if ($subscription) { + $isGraceSubscription = $this->subscriptionMetaRepository->findBySubscriptionAndKey( + $subscription, + GooglePlayBillingModule::META_KEY_GRACE_PERIOD_SUBSCRIPTION + ); + + if (!$isGraceSubscription) { + // should not get here + throw new \Exception("Subscription already exists for order ID [{$subscriptionResponse->getRawResponse()->getOrderId()}]."); + } + } + $user = $this->subscriptionResponseProcessor->getUser($subscriptionResponse, $developerNotification); if (!in_array($subscriptionResponse->getPaymentState(), [ @@ -212,7 +224,7 @@ private function createPayment(SubscriptionResponse $subscriptionResponse, Activ throw new DoNotRetryException("Unable to handle PaymentState [{$subscriptionResponse->getPaymentState()}], no payment created."); } - $subscriptionType = $this->getSubscriptionType($developerNotification); + $subscriptionType = $this->getSubscriptionType($subscriptionResponse, $developerNotification); $paymentGatewayCode = GooglePlayBilling::GATEWAY_CODE; $paymentGateway = $this->paymentGatewaysRepository->findByCode($paymentGatewayCode); @@ -275,19 +287,11 @@ private function createPayment(SubscriptionResponse $subscriptionResponse, Activ } $recurrentCharge = true; + } - // Handle case when introductory subscription type is different from renewals. - if ($subscriptionType->next_subscription_type_id) { - $googleSubscriptionType = $this->googlePlaySubscriptionTypesRepository->findByGooglePlaySubscriptionId($developerNotification->subscription_id); - if ($googleSubscriptionType->offer_periods) { - $usedOfferPeriods = $this->getUsedOfferPeriods($subscriptionResponse->getRawResponse()->getOrderId()); - if ($usedOfferPeriods >= $googleSubscriptionType->offer_periods) { - $subscriptionType = $subscriptionType->next_subscription_type; - } - } else { - $subscriptionType = $subscriptionType->next_subscription_type; - } - } + // this is grace period subscription, set end time to start time of purchased (renewed) subscription + if ($subscription) { + $this->stopGracePeriodSubscription($subscription, $subscriptionStartAt); } $paymentItemContainer = (new PaymentItemContainer()) @@ -434,7 +438,7 @@ public function createGoogleGracePeriodSubscription( // prepare subscription and subscription meta $user = $this->subscriptionResponseProcessor->getUser($subscriptionResponse, $developerNotification); - $subscriptionType = $this->getSubscriptionType($developerNotification); + $subscriptionType = $this->getSubscriptionType($subscriptionResponse, $developerNotification); $subscriptionStartAt = $this->subscriptionResponseProcessor->getSubscriptionStartAt($subscriptionResponse); $subscriptionEndAt = $this->subscriptionResponseProcessor->getSubscriptionEndAt($subscriptionResponse); @@ -442,6 +446,7 @@ public function createGoogleGracePeriodSubscription( GooglePlayBillingModule::META_KEY_PURCHASE_TOKEN => $developerNotification->purchase_token, GooglePlayBillingModule::META_KEY_ORDER_ID => $subscriptionResponse->getRawResponse()->getOrderId(), GooglePlayBillingModule::META_KEY_DEVELOPER_NOTIFICATION_ID => $developerNotification->id, + GooglePlayBillingModule::META_KEY_GRACE_PERIOD_SUBSCRIPTION => true, ]; // get last subscription (with or without payment) linked to grace period through purchase token @@ -505,12 +510,31 @@ public function createGoogleGracePeriodSubscription( return $subscription; } - public function getSubscriptionType(ActiveRow $developerNotification): ActiveRow + private function stopGracePeriodSubscription(ActiveRow $subscription, $endTime) + { + $this->subscriptionsRepository->update($subscription, [ + 'end_time' => $endTime + ]); + } + + public function getSubscriptionType(SubscriptionResponse $subscriptionResponse, ActiveRow $developerNotification): ActiveRow { $googlePlaySubscriptionType = $this->googlePlaySubscriptionTypesRepository->findByGooglePlaySubscriptionId($developerNotification->subscription_id); if (!$googlePlaySubscriptionType || !isset($googlePlaySubscriptionType->subscription_type)) { throw new \Exception("Unable to find SubscriptionType with code [{$developerNotification->subscription_id}] provided by DeveloperNotification."); } + + // Handle case when introductory subscription type is different from renewals. + if ($googlePlaySubscriptionType->subscription_type->next_subscription_type_id) { + $usedOfferPeriods = $this->getUsedOfferPeriods($subscriptionResponse->getRawResponse()->getOrderId()); + // used all offer periods + // if offer periods not set -> there is 1 offer period and we have used it already + if (($googlePlaySubscriptionType->offer_periods && $usedOfferPeriods >= $googlePlaySubscriptionType->offer_periods) + || (is_null($googlePlaySubscriptionType->offer_periods) && $usedOfferPeriods > 0)) { + return $googlePlaySubscriptionType->subscription_type->next_subscription_type; + } + } + return $googlePlaySubscriptionType->subscription_type; } diff --git a/src/Tests/Hermes/DeveloperNotificationReceivedHandlerGracePeriodTest.php b/src/Tests/Hermes/DeveloperNotificationReceivedHandlerGracePeriodTest.php index f1438ae..e7b6123 100644 --- a/src/Tests/Hermes/DeveloperNotificationReceivedHandlerGracePeriodTest.php +++ b/src/Tests/Hermes/DeveloperNotificationReceivedHandlerGracePeriodTest.php @@ -272,7 +272,7 @@ public function testSuccess() // orderID is same, only suffix is different $orderIdGracePeriod = $orderIdFirstPurchase . '..0'; // start of subscription within grace period notification is same as start of previous subscription - $startTimeMillisGracePeriod = $startTimeMillisFirstPurchase; + $startTimeMillisGracePeriod = $expiryTimeMillisFirstPurchase; // expiry of grace period subscription will be cca 2 days after expiration of previous purchase $expiryTimeMillisGracePeriod = $expiryTimeMillisFirstPurchase->modifyClone('+2 days'); // price doesn't matter now @@ -343,7 +343,7 @@ public function testSuccess() $this->assertEquals(1, $this->paymentsRepository->totalCount()); $this->assertEquals(2, $this->subscriptionsRepository->totalCount()); $this->assertEquals(3, $this->paymentMetaRepository->totalCount()); - $this->assertEquals(3, $this->subscriptionMetaRepository->totalCount()); + $this->assertEquals(4, $this->subscriptionMetaRepository->totalCount()); $payments = $this->paymentsRepository->getTable()->order('created_at')->fetchAll(); $paymentFirstPurchaseReload = reset($payments); @@ -381,7 +381,7 @@ public function testSuccess() // check new subscription (grace period) & meta $this->assertEquals($subscriptionFirstPurchaseReload->subscription_type_id, $subscriptionGracePeriod->subscription_type_id); $this->assertEquals($this->getGooglePlaySubscriptionTypeWeb()->subscription_type_id, $subscriptionGracePeriod->subscription_type_id); - $this->assertEquals($expiryTimeMillisFirstPurchase, $subscriptionGracePeriod->start_time); // grace period should start after last valid Google subscription + $this->assertEquals($startTimeMillisGracePeriod, $subscriptionGracePeriod->start_time); // grace period should start after last valid Google subscription $this->assertEquals($expiryTimeMillisGracePeriod, $subscriptionGracePeriod->end_time); $this->assertEquals( $purchaseTokenGracePeriod->purchase_token, @@ -395,6 +395,9 @@ public function testSuccess() $orderIdGracePeriod, $this->subscriptionMetaRepository->findBySubscriptionAndKey($subscriptionGracePeriod, 'google_play_billing_order_id')->value ); + $this->assertTrue( + (bool) $this->subscriptionMetaRepository->findBySubscriptionAndKey($subscriptionGracePeriod, 'google_play_billing_grace_period_subscription')->value + ); } // same as testSuccess() until SECOND GRACE PERIOD section @@ -602,7 +605,7 @@ public function testSuccessSecondGracePeriod() $this->assertEquals(1, $this->paymentsRepository->totalCount()); // no change $this->assertEquals(2, $this->subscriptionsRepository->totalCount()); // new subscription $this->assertEquals(3, $this->paymentMetaRepository->totalCount()); // no change - $this->assertEquals(3, $this->subscriptionMetaRepository->totalCount()); // 3 new subscription meta + $this->assertEquals(4, $this->subscriptionMetaRepository->totalCount()); // 4 new subscription meta $payments = $this->paymentsRepository->getTable()->order('created_at')->fetchAll(); $paymentFirstPurchaseReload = reset($payments); @@ -654,6 +657,9 @@ public function testSuccessSecondGracePeriod() $orderIdGracePeriod, $this->subscriptionMetaRepository->findBySubscriptionAndKey($subscriptionGracePeriod, 'google_play_billing_order_id')->value ); + $this->assertTrue( + (bool) $this->subscriptionMetaRepository->findBySubscriptionAndKey($subscriptionGracePeriod, 'google_play_billing_grace_period_subscription')->value + ); /* ***************************************************************** * * SECOND GRACE PERIOD NOTIFICATION ******************************** * @@ -723,7 +729,7 @@ public function testSuccessSecondGracePeriod() $this->assertEquals(1, $this->paymentsRepository->totalCount()); $this->assertEquals(2, $this->subscriptionsRepository->totalCount()); $this->assertEquals(3, $this->paymentMetaRepository->totalCount()); - $this->assertEquals(3, $this->subscriptionMetaRepository->totalCount()); + $this->assertEquals(4, $this->subscriptionMetaRepository->totalCount()); // handler returns only bool value; check expected log for result (nothing logged) $mockLogger = $this->createMock(ILogger::class); @@ -746,7 +752,7 @@ public function testSuccessSecondGracePeriod() $this->assertEquals(1, $this->paymentsRepository->totalCount()); // no change $this->assertEquals(3, $this->subscriptionsRepository->totalCount()); // new subscription $this->assertEquals(3, $this->paymentMetaRepository->totalCount()); // no change - $this->assertEquals(6, $this->subscriptionMetaRepository->totalCount()); // 3 new subscription meta + $this->assertEquals(8, $this->subscriptionMetaRepository->totalCount()); // 4 new subscription meta $payments = $this->paymentsRepository->getTable()->order('created_at')->fetchAll(); $paymentFirstPurchaseReload = reset($payments); @@ -780,6 +786,9 @@ public function testSuccessSecondGracePeriod() $orderIdGracePeriodSecond, $this->subscriptionMetaRepository->findBySubscriptionAndKey($subscriptionGracePeriodSecond, 'google_play_billing_order_id')->value ); + $this->assertTrue( + (bool) $this->subscriptionMetaRepository->findBySubscriptionAndKey($subscriptionGracePeriod, 'google_play_billing_grace_period_subscription')->value + ); } // Test paid subscription > grace subscription > paid subscription > grace subscription @@ -889,7 +898,7 @@ public function testSuccess4SubscriptionsPaidGracePaidGrace() // orderID is same, only suffix is different $orderIdGracePeriod = $orderIdFirstPurchase . '..0'; // start of subscription within grace period notification is same as start of previous subscription - $startTimeMillisGracePeriod = $startTimeMillisFirstPurchase; + $startTimeMillisGracePeriod = $expiryTimeMillisFirstPurchase; // expiry of grace period subscription will be cca 2 days after expiration of previous purchase $expiryTimeMillisGracePeriod = $expiryTimeMillisFirstPurchase->modifyClone('+2 days'); // price doesn't matter now @@ -960,7 +969,7 @@ public function testSuccess4SubscriptionsPaidGracePaidGrace() $this->assertEquals(1, $this->paymentsRepository->totalCount()); // no change $this->assertEquals(2, $this->subscriptionsRepository->totalCount()); // new subscription $this->assertEquals(3, $this->paymentMetaRepository->totalCount()); // no change - $this->assertEquals(3, $this->subscriptionMetaRepository->totalCount()); // 3 new subscription meta + $this->assertEquals(4, $this->subscriptionMetaRepository->totalCount()); // 4 new subscription meta /* ***************************************************************** * * 3.) SECOND PURCHASE ********************************************* * @@ -978,10 +987,13 @@ public function testSuccess4SubscriptionsPaidGracePaidGrace() ], ); - // orderID is same, only suffix is different - $orderIdSecondPurchase = $orderIdFirstPurchase . '..1'; + /* ***************************************************************** * + * orderID is same as first grace period *************************** * + * ***************************************************************** */ + $orderIdSecondPurchase = $orderIdFirstPurchase . '..0'; // start of next paid subscription starts after end of grace period - $startTimeMillisSecondPurchase = $expiryTimeMillisGracePeriod; + $startTimeMillisSecondPurchase = $expiryTimeMillisGracePeriod->modifyClone('-1 day'); + ; $expiryTimeMillisSecondPurchase = $startTimeMillisSecondPurchase->modifyClone('+1 month'); $priceAmountMicrosSecondPurchase = $this->getGooglePlaySubscriptionTypeWeb()->subscription_type->price * 1000000; // acknowledgementState: 1 -> set to acknowledged, so we don't need to mock acknowledgement service @@ -1023,7 +1035,7 @@ public function testSuccess4SubscriptionsPaidGracePaidGrace() $this->assertEquals(1, $this->paymentsRepository->totalCount()); $this->assertEquals(2, $this->subscriptionsRepository->totalCount()); $this->assertEquals(3, $this->paymentMetaRepository->totalCount()); - $this->assertEquals(3, $this->subscriptionMetaRepository->totalCount()); + $this->assertEquals(4, $this->subscriptionMetaRepository->totalCount()); $result = $this->developerNotificationReceivedHandler->handle($hermesMessageSecondPurchase); $this->assertTrue($result); @@ -1039,7 +1051,7 @@ public function testSuccess4SubscriptionsPaidGracePaidGrace() $this->assertEquals(2, $this->paymentsRepository->totalCount()); // 1 new payment $this->assertEquals(3, $this->subscriptionsRepository->totalCount()); // 1 new subscription $this->assertEquals(6, $this->paymentMetaRepository->totalCount()); // 3 new meta - $this->assertEquals(3, $this->subscriptionMetaRepository->totalCount()); // no change + $this->assertEquals(4, $this->subscriptionMetaRepository->totalCount()); // no change /* ***************************************************************** * * 4.) SECOND GRACE PERIOD NOTIFICATION **************************** * @@ -1059,9 +1071,9 @@ public function testSuccess4SubscriptionsPaidGracePaidGrace() ); // orderID is same, only suffix is different - $orderIdGracePeriodSecond = $orderIdFirstPurchase . '..2'; + $orderIdGracePeriodSecond = $orderIdFirstPurchase . '..1'; // start of subscription within grace period notification is same as start of previous subscription - $startTimeMillisGracePeriodSecond = $startTimeMillisSecondPurchase; + $startTimeMillisGracePeriodSecond = $expiryTimeMillisSecondPurchase; // expiry of grace period subscription will be cca 2 days after expiration of previous purchase $expiryTimeMillisGracePeriodSecond = $expiryTimeMillisSecondPurchase->modifyClone('+2 days'); // price doesn't matter now @@ -1109,7 +1121,7 @@ public function testSuccess4SubscriptionsPaidGracePaidGrace() $this->assertEquals(2, $this->paymentsRepository->totalCount()); $this->assertEquals(3, $this->subscriptionsRepository->totalCount()); $this->assertEquals(6, $this->paymentMetaRepository->totalCount()); - $this->assertEquals(3, $this->subscriptionMetaRepository->totalCount()); + $this->assertEquals(4, $this->subscriptionMetaRepository->totalCount()); // handler returns only bool value; check expected log for result (nothing logged) $mockLogger = $this->createMock(ILogger::class); @@ -1132,7 +1144,7 @@ public function testSuccess4SubscriptionsPaidGracePaidGrace() $this->assertEquals(2, $this->paymentsRepository->totalCount()); // no change $this->assertEquals(4, $this->subscriptionsRepository->totalCount()); // new subscription $this->assertEquals(6, $this->paymentMetaRepository->totalCount()); // no change - $this->assertEquals(6, $this->subscriptionMetaRepository->totalCount()); // 3 new subscription meta + $this->assertEquals(8, $this->subscriptionMetaRepository->totalCount()); // 4 new subscription meta /* ***************************************************************** * * FINAL CHECK ***************************************************** * @@ -1198,6 +1210,15 @@ public function testSuccess4SubscriptionsPaidGracePaidGrace() )->value ); + // grace period subscription and renewed subscription have the same order ID is subscription meta + $this->assertEquals( + $orderIdSecondPurchase, + $this->subscriptionMetaRepository->findBySubscriptionAndKey( + $subscription2, + 'google_play_billing_order_id' + )->value + ); + // check subscription links to payments $payment1FromSubscription1 = $this->paymentsRepository->subscriptionPayment($subscription1); $noPaymentFromSubscription2 = $this->paymentsRepository->subscriptionPayment($subscription2); @@ -1299,9 +1320,9 @@ public function testFailedLateGracePeriodNotification() ], ); - $orderIdRenewed = $orderIdFirstPurchase . '..1'; // set to 1 to simulate that it was "generated" after grace period + $orderIdRenewed = $orderIdFirstPurchase . '..0'; $startTimeMillisRenewed = $expiryTimeMillisFirstPurchase; - $expiryTimeMillisRenewed = new DateTime('2030-06-27 19:20:57'); + $expiryTimeMillisRenewed = $startTimeMillisRenewed->modifyClone('+1 month'); $priceAmountMicrosRenewed = $this->getGooglePlaySubscriptionTypeWeb()->subscription_type->price * 1000000; // acknowledgementState: 1 -> set to acknowledged, so we don't need to mock acknowledgement service // autoRenewing: true -> first purchase set to create recurrent payment @@ -1366,12 +1387,12 @@ public function testFailedLateGracePeriodNotification() ], ); - // orderID is same, only suffix is different - $orderIdGracePeriod = $orderIdFirstPurchase . '..0'; // set to 0 to simulate that it was "generated" after grace period - // start of subscription within grace period notification is same as start of previous subscription - $startTimeMillisGracePeriod = $startTimeMillisFirstPurchase; - // expiry of grace period subscription will be cca 2 days after expiration of previous purchase - $expiryTimeMillisGracePeriod = $expiryTimeMillisFirstPurchase->modifyClone('+2 days'); + // orderID is same as renewed payment + $orderIdGracePeriod = $orderIdFirstPurchase . '..0'; + // start time is the same as renewed subscription + $startTimeMillisGracePeriod = $startTimeMillisRenewed; + // end time is the same as renewed subscription + $expiryTimeMillisGracePeriod = $expiryTimeMillisRenewed; // price doesn't matter now $priceAmountMicrosGracePeriod = $this->getGooglePlaySubscriptionTypeWeb()->subscription_type->price * 1000000; // other settings: diff --git a/src/migrations/20231017134348_add_grace_period_meta_key_to_subscriptions.php b/src/migrations/20231017134348_add_grace_period_meta_key_to_subscriptions.php new file mode 100644 index 0000000..1e6656c --- /dev/null +++ b/src/migrations/20231017134348_add_grace_period_meta_key_to_subscriptions.php @@ -0,0 +1,39 @@ +fetchAll($sql) as $row) { + $this->execute(<<output->writeln('Down migration is risky. See migration class for details. Nothing done.'); + return; + + $sql = <<execute($sql); + } +}