From ba9c80a63b8be5b4f64d6c1396b7f9297fc0f382 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Wed, 4 Mar 2026 18:31:57 +0800 Subject: [PATCH 1/4] Membership Management (PR #93 rebased onto 2.x) --- src/Plugin.php | 26 +- src/console/controllers/SyncController.php | 40 +- src/controllers/CheckoutController.php | 29 +- src/db/Table.php | 2 + src/elements/Price.php | 196 +++++++- src/elements/Product.php | 4 +- src/elements/Subscription.php | 114 ++++- src/elements/db/PriceQuery.php | 1 - src/elements/db/SubscriptionQuery.php | 2 + src/events/GrantGroupAssignmentEvent.php | 44 ++ src/events/RevokeGroupAssignmentEvent.php | 45 ++ .../StripeSubscriptionStatusChangeEvent.php | 35 ++ src/events/StripeSubscriptionSyncEvent.php | 6 + src/fieldlayoutelements/AssignmentLogs.php | 51 +++ .../UserGroupAssignmentsField.php | 130 ++++++ src/helpers/Customer.php | 2 +- src/helpers/Subscription.php | 146 +++--- src/migrations/Install.php | 25 + ...806_201102_add_price_user_groups_table.php | 42 ++ ...07_add_user_id_column_to_subscriptions.php | 33 ++ .../m250821_232955_add_subscription_logs.php | 40 ++ src/models/Customer.php | 17 + src/models/Message.php | 34 ++ src/services/Customers.php | 6 +- src/services/Subscriptions.php | 426 +++++++++++++++++- src/services/Webhooks.php | 4 +- .../fieldlayoutelements/assignmentlogs.twig | 29 ++ .../usergroupassignmentsfield.twig | 8 + src/translations/en/stripe.php | 12 +- 29 files changed, 1386 insertions(+), 163 deletions(-) create mode 100644 src/events/GrantGroupAssignmentEvent.php create mode 100644 src/events/RevokeGroupAssignmentEvent.php create mode 100644 src/events/StripeSubscriptionStatusChangeEvent.php create mode 100644 src/fieldlayoutelements/AssignmentLogs.php create mode 100644 src/fieldlayoutelements/UserGroupAssignmentsField.php create mode 100644 src/migrations/m250806_201102_add_price_user_groups_table.php create mode 100644 src/migrations/m250820_185807_add_user_id_column_to_subscriptions.php create mode 100644 src/migrations/m250821_232955_add_subscription_logs.php create mode 100644 src/models/Message.php create mode 100644 src/templates/fieldlayoutelements/assignmentlogs.twig create mode 100644 src/templates/fieldlayoutelements/usergroupassignmentsfield.twig diff --git a/src/Plugin.php b/src/Plugin.php index aa97da2..4f54595 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -20,6 +20,7 @@ use craft\events\DefineBehaviorsEvent; use craft\events\DefineConsoleActionsEvent; use craft\events\DefineEditUserScreensEvent; +use craft\events\DefineFieldLayoutElementsEvent; use craft\events\DefineFieldLayoutFieldsEvent; use craft\events\DefineMenuItemsEvent; use craft\events\DefineMetadataEvent; @@ -44,7 +45,9 @@ use craft\stripe\elements\Subscription; use craft\stripe\feedme\fields\Products as FeedMeProducts; use craft\stripe\feedme\fields\Subscriptions as FeedMeSubscriptions; +use craft\stripe\fieldlayoutelements\AssignmentLogs; use craft\stripe\fieldlayoutelements\PricesField; +use craft\stripe\fieldlayoutelements\UserGroupAssignmentsField; use craft\stripe\fields\Products as ProductsField; use craft\stripe\fields\Subscriptions as SubscriptionsField; use craft\stripe\jobs\SyncSingleCustomerData; @@ -91,7 +94,7 @@ class Plugin extends BasePlugin /** * @var string */ - public string $schemaVersion = '1.2.0'; + public string $schemaVersion = '1.3.0'; /** * @inheritdoc @@ -473,8 +476,22 @@ private function registerFieldLayoutElements(): void case Product::class: $event->fields[] = PricesField::class; break; + case Price::class: + $event->fields[] = UserGroupAssignmentsField::class; + break; } }); + + Event::on(FieldLayout::class, FieldLayout::EVENT_DEFINE_UI_ELEMENTS, function(DefineFieldLayoutElementsEvent $event) { + /** @var FieldLayout $fieldLayout */ + $fieldLayout = $event->sender; + + if ($fieldLayout->type !== Subscription::class) { + return; + } + + $event->elements[] = AssignmentLogs::class; + }); } /** @@ -516,7 +533,7 @@ public function registerResaveCommands(): void return $controller->resaveElements(Product::class); }, 'options' => ['withFields'], - 'helpSummary' => 'Re-saves Stripe products.', + 'helpSummary' => 'Re-saves Stripe Products.', ]; $e->actions['stripe-prices'] = [ @@ -535,7 +552,7 @@ public function registerResaveCommands(): void return $controller->resaveElements(Price::class); }, 'options' => ['withFields'], - 'helpSummary' => 'Re-saves Stripe prices.', + 'helpSummary' => 'Re-saves Stripe Prices.', ]; $e->actions['stripe-subscriptions'] = [ @@ -554,7 +571,7 @@ public function registerResaveCommands(): void return $controller->resaveElements(Subscription::class); }, 'options' => ['withFields'], - 'helpSummary' => 'Re-saves Stripe subscriptions.', + 'helpSummary' => 'Re-saves Stripe Subscriptions.', ]; }); } @@ -573,6 +590,7 @@ private function registerCpRoutes(): void $event->rules['stripe/products'] = 'stripe/products/index'; $event->rules['stripe/products/'] = 'elements/edit'; + $event->rules['stripe/products//prices/'] = 'elements/edit'; $event->rules['stripe/subscriptions'] = 'stripe/subscriptions/index'; $event->rules['stripe/subscriptions/'] = 'elements/edit'; diff --git a/src/console/controllers/SyncController.php b/src/console/controllers/SyncController.php index b20816a..daaec22 100644 --- a/src/console/controllers/SyncController.php +++ b/src/console/controllers/SyncController.php @@ -131,13 +131,13 @@ public function actionInvoices(): int */ private function syncProducts(): void { - $this->stdout('Syncing Stripe products and prices…' . PHP_EOL . PHP_EOL, Console::FG_GREEN); + $this->stdout('Syncing Stripe Products and Prices…' . PHP_EOL . PHP_EOL, Console::FG_GREEN); $start = microtime(true); Plugin::getInstance()->getProducts()->syncAllProducts(); $time = microtime(true) - $start; - $this->stdout('Finished syncing ' . Product::find()->count() . ' product(s) in ' . round($time, 2) . 's' . PHP_EOL . PHP_EOL, Console::FG_GREEN); + $this->stdout('Finished syncing ' . Product::find()->count() . ' Product(s) in ' . round($time, 2) . 's' . PHP_EOL . PHP_EOL, Console::FG_GREEN); } /** @@ -149,13 +149,13 @@ private function syncProducts(): void */ private function syncPrices(): void { - $this->stdout('Syncing Stripe prices…' . PHP_EOL . PHP_EOL, Console::FG_GREEN); + $this->stdout('Syncing Stripe Prices…' . PHP_EOL . PHP_EOL, Console::FG_GREEN); $start = microtime(true); Plugin::getInstance()->getPrices()->syncAllPrices(); $time = microtime(true) - $start; - $this->stdout('Finished syncing ' . Price::find()->count() . ' price(s) in ' . round($time, 2) . 's' . PHP_EOL . PHP_EOL, Console::FG_GREEN); + $this->stdout('Finished syncing ' . Price::find()->count() . ' Price(s) in ' . round($time, 2) . 's' . PHP_EOL . PHP_EOL, Console::FG_GREEN); } /** @@ -167,13 +167,13 @@ private function syncPrices(): void */ private function syncSubscriptions(): void { - $this->stdout('Syncing Stripe subscriptions…' . PHP_EOL . PHP_EOL, Console::FG_GREEN); + $this->stdout('Syncing Stripe Subscriptions…' . PHP_EOL . PHP_EOL, Console::FG_GREEN); $start = microtime(true); Plugin::getInstance()->getSubscriptions()->syncAllSubscriptions(); $time = microtime(true) - $start; - $this->stdout('Finished syncing ' . Subscription::find()->count() . ' subscription(s) in ' . round($time, 2) . 's' . PHP_EOL . PHP_EOL, Console::FG_GREEN); + $this->stdout('Finished syncing ' . Subscription::find()->count() . ' Subscription(s) in ' . round($time, 2) . 's' . PHP_EOL . PHP_EOL, Console::FG_GREEN); } /** @@ -185,13 +185,13 @@ private function syncSubscriptions(): void */ private function syncCustomers(): void { - $this->stdout('Syncing Stripe customers…' . PHP_EOL . PHP_EOL, Console::FG_GREEN); + $this->stdout('Syncing Stripe Customers…' . PHP_EOL . PHP_EOL, Console::FG_GREEN); $start = microtime(true); $count = Plugin::getInstance()->getCustomers()->syncAllCustomers(); $time = microtime(true) - $start; - $this->stdout('Finished syncing ' . $count . ' customer(s) in ' . round($time, 2) . 's' . PHP_EOL . PHP_EOL, Console::FG_GREEN); + $this->stdout('Finished syncing ' . $count . ' Customer(s) in ' . round($time, 2) . 's' . PHP_EOL . PHP_EOL, Console::FG_GREEN); } /** @@ -203,13 +203,13 @@ private function syncCustomers(): void */ private function syncPaymentMethods(): void { - $this->stdout('Syncing Stripe payment methods…' . PHP_EOL . PHP_EOL, Console::FG_GREEN); + $this->stdout('Syncing Stripe Payment Methods…' . PHP_EOL . PHP_EOL, Console::FG_GREEN); $start = microtime(true); $count = Plugin::getInstance()->getPaymentMethods()->syncAllPaymentMethods(); $time = microtime(true) - $start; - $this->stdout('Finished syncing ' . $count . ' payment method(s) in ' . round($time, 2) . 's' . PHP_EOL . PHP_EOL, Console::FG_GREEN); + $this->stdout('Finished syncing ' . $count . ' Payment Method(s) in ' . round($time, 2) . 's' . PHP_EOL . PHP_EOL, Console::FG_GREEN); } /** @@ -221,13 +221,13 @@ private function syncPaymentMethods(): void */ private function syncInvoices(): void { - $this->stdout('Syncing Stripe invoices…' . PHP_EOL . PHP_EOL, Console::FG_GREEN); + $this->stdout('Syncing Stripe Invoices…' . PHP_EOL . PHP_EOL, Console::FG_GREEN); $start = microtime(true); $count = Plugin::getInstance()->getInvoices()->syncAllInvoices(); $time = microtime(true) - $start; - $this->stdout('Finished syncing ' . $count . ' invoice(s) in ' . round($time, 2) . 's' . PHP_EOL . PHP_EOL, Console::FG_GREEN); + $this->stdout('Finished syncing ' . $count . ' Invoice(s) in ' . round($time, 2) . 's' . PHP_EOL . PHP_EOL, Console::FG_GREEN); } /** @@ -243,32 +243,32 @@ private function syncSingleCustomer(string $customerId): void $this->stdout("Syncing Stripe customer {$customerId} and related data…" . PHP_EOL . PHP_EOL, Console::FG_GREEN); $start = microtime(true); - + $plugin = Plugin::getInstance(); $api = $plugin->getApi(); - + try { // Fetch the customer from Stripe $stripeCustomer = $api->fetchCustomerById($customerId); - + // Sync the customer data $this->stdout('Syncing customer data…' . PHP_EOL, Console::FG_YELLOW); $plugin->getCustomers()->createOrUpdateCustomer($stripeCustomer); - + // Sync customer's subscriptions $this->stdout('Syncing customer subscriptions…' . PHP_EOL, Console::FG_YELLOW); $subscriptionCount = $plugin->getSubscriptions()->syncCustomerSubscriptions($stripeCustomer); - + // Sync customer's invoices $this->stdout('Syncing customer invoices…' . PHP_EOL, Console::FG_YELLOW); $invoiceCount = $plugin->getInvoices()->syncCustomerInvoices($stripeCustomer); - + // Sync customer's payment methods $this->stdout('Syncing customer payment methods…' . PHP_EOL, Console::FG_YELLOW); $paymentMethodCount = $plugin->getPaymentMethods()->syncCustomerPaymentMethods($stripeCustomer); - + $time = microtime(true) - $start; - + $this->stdout(PHP_EOL . "Finished syncing customer {$customerId}:" . PHP_EOL, Console::FG_GREEN); $this->stdout(" - Customer data synced" . PHP_EOL, Console::FG_GREEN); $this->stdout(" - {$subscriptionCount} subscription(s) synced" . PHP_EOL, Console::FG_GREEN); diff --git a/src/controllers/CheckoutController.php b/src/controllers/CheckoutController.php index de9d140..1346b28 100644 --- a/src/controllers/CheckoutController.php +++ b/src/controllers/CheckoutController.php @@ -68,21 +68,20 @@ public function actionCheckout(): Response } $params = []; - $fields = $request->getBodyParam('fields'); - if (!empty($fields)) { - // check the checkout mode - if it's subscription, proceed with creating a draft - $mode = $checkoutService->getCheckoutMode($lineItems); - if ($mode === StripeCheckoutSession::MODE_SUBSCRIPTION) { - // create an unpublished & unsaved draft subscription in Craft; - $subscription = Craft::createObject([ - 'class' => Subscription::class, - 'attributes' => ['title' => DateTimeHelper::now()->format('Y-m-d H:i:s')], - ]); - $subscription->setFieldValuesFromRequest('fields'); - if (Craft::$app->getDrafts()->saveElementAsDraft($subscription, markAsSaved: false)) { - // send the uid of it to Stripe to be stored as metadata on the Session(!) - $params['metadata']['craftSubscriptionUid'] = $subscription->uid; - } + + // Customers can save custom field values to subscriptions during checkout: + $mode = $checkoutService->getCheckoutMode($lineItems); + if ($mode === StripeCheckoutSession::MODE_SUBSCRIPTION) { + // Create a provisional draft element in Craft: + $subscription = new Subscription(); + // Temporary title (because we don’t have a subscription ID yet): + $subscription->title = DateTimeHelper::now()->format('Y-m-d H:i:s'); + + $subscription->setFieldValuesFromRequest('fields'); + + if (Craft::$app->getDrafts()->saveElementAsDraft($subscription, markAsSaved: false)) { + // Attach the element’s UUID to the Stripe request as metadata on the Checkout Session: + $params['metadata']['craftSubscriptionUid'] = $subscription->uid; } } diff --git a/src/db/Table.php b/src/db/Table.php index be3a364..c1f8c98 100644 --- a/src/db/Table.php +++ b/src/db/Table.php @@ -25,4 +25,6 @@ abstract class Table public const CUSTOMERDATA = '{{%stripe_customerdata}}'; public const INVOICEDATA = '{{%stripe_invoicedata}}'; public const WEBHOOKS = '{{%stripe_webhooks}}'; + public const PRICES_USERGROUPS = '{{%stripe_prices_usergroups}}'; + public const SUBSCRIPTIONLOGS = '{{%stripe_subscriptionlogs}}'; } diff --git a/src/elements/Price.php b/src/elements/Price.php index 14146e9..d53598a 100644 --- a/src/elements/Price.php +++ b/src/elements/Price.php @@ -15,6 +15,7 @@ use craft\db\Table as CraftTable; use craft\elements\User; use craft\enums\Color; +use craft\helpers\ArrayHelper; use craft\helpers\Db; use craft\helpers\ElementHelper; use craft\helpers\Html; @@ -22,6 +23,7 @@ use craft\helpers\MoneyHelper; use craft\helpers\StringHelper; use craft\models\FieldLayout; +use craft\models\UserGroup; use craft\stripe\db\Table; use craft\stripe\elements\conditions\prices\PriceCondition; use craft\stripe\elements\db\PriceQuery; @@ -32,6 +34,7 @@ use Money\Currency; use Money\Money; use Stripe\Price as StripePrice; +use Throwable; use yii\base\InvalidConfigException; /** @@ -110,6 +113,16 @@ class Price extends Element implements NestedElementInterface */ private ?Product $_product = null; + /** + * @var UserGroup[]|null + */ + private ?array $_userGroupAssignments = null; + + /** + * @var int + */ + private ?array $_userGroupAssignmentIds = null; + /** * @var array|string[] Array of params that should be expanded when fetching Price from the Stripe API */ @@ -135,7 +148,7 @@ public static function displayName(): string */ public static function lowerDisplayName(): string { - return Craft::t('stripe', 'stripe price'); + return Craft::t('stripe', 'Stripe Price'); } /** @@ -151,7 +164,7 @@ public static function pluralDisplayName(): string */ public static function pluralLowerDisplayName(): string { - return Craft::t('stripe', 'stripe prices'); + return Craft::t('stripe', 'Stripe Prices'); } /** @@ -266,6 +279,14 @@ public function getSidebarHtml(bool $static): string return parent::getSidebarHtml($static) . $priceCard; } + /** + * Returns whether the price is configured as recurring. + */ + public function isRecurring(): bool + { + return $this->type === StripePrice::TYPE_RECURRING; + } + /** * Returns price amount as number & currency. * @@ -465,6 +486,29 @@ protected static function defineDefaultCardAttributes(): array ]; } + /** + * @inheritdoc + */ + public function defineRules(): array + { + $rules = parent::defineRules(); + + $rules[] = [['userGroupAssignmentIds'], 'safe']; + $rules[] = [ + ['userGroupAssignmentIds'], + function ($attribute, $params, $validator, $current) { + $allGroupIds = ArrayHelper::getColumn(Craft::$app->getUserGroups()->getAllGroups(), 'id'); + $selectedGroupIds = ArrayHelper::getColumn($this->getUserGroupAssignments(), 'id'); + + if (count(array_diff($selectedGroupIds, $allGroupIds)) > 0) { + $this->addError($attribute, Craft::t('stripe', 'One or more of the selected user groups are invalid.')); + } + }, + ]; + + return $rules; + } + /** * @inheritdoc */ @@ -507,7 +551,18 @@ public function canCreateDrafts(User $user): bool */ protected function cpEditUrl(): ?string { - return null; + return sprintf('stripe/products/%s/prices/%s', $this->getOwnerId(), $this->getCanonicalId()); + } + + /** + * @inheritdoc + */ + public function extraFields(): array + { + $names = parent::extraFields(); + $names[] = 'userGroupAssignments'; + $names[] = 'userGroupAssignmentIds'; + return $names; } /** @@ -588,9 +643,79 @@ public function afterSave(bool $isNew): void $this->setDirtyAttributes($dirtyAttributes); + // Update join records with new groups: + $this->saveUserGroupAssignments(); + parent::afterSave($isNew); } + /** + * Updates user group assignment records after a save. + * + * This method lifts most of its logic from {@see craft\services\Users::assignUserToGroups()}. + */ + protected function saveUserGroupAssignments(): void + { + $db = Craft::$app->getDb(); + + $oldGroupIds = (new Query()) + ->select(['groupId']) + ->from([Table::PRICES_USERGROUPS]) + ->where(['priceId' => $this->id]) + ->column($db); + + // Build an array of new user groups, so we can unset them easily: + $newGroupIds = array_flip(array_unique(ArrayHelper::getColumn($this->getUserGroupAssignments(), 'id'))); + + $removedGroupIds = []; + + foreach ($oldGroupIds as $oldGroupId) { + // Is this group still present in the new data? + if (isset($newGroupIds[$oldGroupId])) { + // Ok, let’s avoid deleting + re-creating the record unnecessarily: + unset($newGroupIds[$oldGroupId]); + } else { + $removedGroupIds[] = $oldGroupId; + } + } + + if (empty($removedGroupIds) && empty($newGroupIds)) { + // There was no change to the selected groups; we don’t have to touch the database! + return; + } + + // Whichever keys are left here (after culling with `unset()`) are our “real” new values: + $newGroupIds = array_keys($newGroupIds); + + // Wrap all our group updates in a transaction: + $transaction = $db->beginTransaction(); + + try { + if (!empty($newGroupIds)) { + $values = []; + foreach ($newGroupIds as $groupId) { + $values[] = [$this->id, $groupId]; + } + Db::batchInsert(Table::PRICES_USERGROUPS, ['priceId', 'groupId'], $values, $db); + } + + if (!empty($removedGroupIds)) { + Db::delete(Table::PRICES_USERGROUPS, [ + 'priceId' => $this->id, + 'groupId' => $removedGroupIds, + ], [], $db); + } + + $transaction->commit(); + } catch (Throwable $e) { + $transaction->rollBack(); + throw $e; + } + + // The Users service invalidates element index caches, at this point. + // We have the luxury of being in a private method, which will always be invoked within a typical element save! + } + /** * @inheritdoc */ @@ -701,4 +826,69 @@ public function getCheckoutUrl( $params ); } + + /** + * Sets a list of user group IDs. + * + * @param array $ids + */ + public function setUserGroupAssignmentIds(array $ids): void + { + $this->_userGroupAssignmentIds = $ids; + } + + /** + * Gets the list of user group IDs. + */ + public function getUserGroupAssignmentIds(): array + { + return $this->_userGroupAssignmentIds ?? []; + } + + /** + * Returns a list of currently-configured user groups subscribers should be added to. + * + * @return UserGroup[] + */ + public function getUserGroupAssignments(): array + { + if (!isset($this->_userGroupAssignments)) { + // Do we have a list of IDs already? + if (isset($this->_userGroupAssignmentIds)) { + $groupIds = $this->_userGroupAssignmentIds; + } else { + if ($this->id) { + // Fetch them, if we have a source ID: + $groupIds = (new Query) + ->select(['groupId']) + ->from([Table::PRICES_USERGROUPS]) + ->where(['priceId' => $this->id]) + ->column(); + } else { + $groupIds = []; + } + } + + // Turn those IDs into models, discarding any that didn’t resolve: + $groups = array_filter(array_map(function ($id) { + return Craft::$app->getUserGroups()->getGroupById((int)$id); + }, $groupIds)); + + // Memoize the results: + $this->setUserGroupAssignments($groups); + } + + return $this->_userGroupAssignments; + } + + /** + * Sets an array of user groups. + * + * @param UserGroup[] $groups + */ + public function setUserGroupAssignments(array $groups): void + { + $this->_userGroupAssignments = $groups; + $this->_userGroupAssignmentIds = array_map(fn(UserGroup $userGroup) => $userGroup->id, $groups); + } } diff --git a/src/elements/Product.php b/src/elements/Product.php index 358e7bc..a07e9dc 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -115,7 +115,7 @@ public static function displayName(): string */ public static function lowerDisplayName(): string { - return Craft::t('stripe', 'Stripe product'); + return Craft::t('stripe', 'Stripe Product'); } /** @@ -131,7 +131,7 @@ public static function pluralDisplayName(): string */ public static function pluralLowerDisplayName(): string { - return Craft::t('stripe', 'stripe products'); + return Craft::t('stripe', 'Stripe Products'); } /** diff --git a/src/elements/Subscription.php b/src/elements/Subscription.php index 7f075e0..23fd7f4 100644 --- a/src/elements/Subscription.php +++ b/src/elements/Subscription.php @@ -9,6 +9,7 @@ use Craft; use craft\base\Element; +use craft\elements\db\EagerLoadPlan; use craft\elements\User; use craft\enums\Color; use craft\errors\SiteNotFoundException; @@ -88,6 +89,11 @@ class Subscription extends Element */ public ?string $customerId = null; + /** + * @var int|null ID of the subscriber’s {@see User} element (if one exists) + */ + public ?int $userId = null; + /** * @var string|null */ @@ -125,6 +131,13 @@ class Subscription extends Element */ private ?Customer $_customer = null; + /** + * @var User|null The subscriber’s User element. + * @see getUser() + * @see setUser() + */ + private ?User $_user = null; + /** * @var array|string[] Array of params that should be expanded when fetching Subscription from the Stripe API */ @@ -146,7 +159,7 @@ public static function displayName(): string */ public static function lowerDisplayName(): string { - return Craft::t('stripe', 'stripe subscription'); + return Craft::t('stripe', 'Stripe subscription'); } /** @@ -154,7 +167,7 @@ public static function lowerDisplayName(): string */ public static function pluralDisplayName(): string { - return Craft::t('stripe', 'Stripe Subscriptions'); + return Craft::t('stripe', 'Stripe subscriptions'); } /** @@ -162,7 +175,7 @@ public static function pluralDisplayName(): string */ public static function pluralLowerDisplayName(): string { - return Craft::t('stripe', 'stripe subscriptions'); + return Craft::t('stripe', 'Stripe subscriptions'); } /** @@ -292,6 +305,28 @@ protected static function defineSources(string $context): array ]; } + /** + * @inheritdoc + */ + public static function eagerLoadingMap(array $sourceElements, string $handle): array|null|false + { + if ($handle === 'user') { + $map = array_map(function (Subscription $el) { + return [ + 'source' => $el->id, + 'target' => $el->userId, + ]; + }, $sourceElements); + + return [ + 'elementType' => User::class, + 'map' => $map, + ]; + } + + return parent::eagerLoadingMap($sourceElements, $handle); + } + /** * @inheritdoc */ @@ -589,6 +624,19 @@ protected function attributeHtml(string $attribute): string }; } + /** + * @inheritdoc + */ + public function setEagerLoadedElements(string $handle, array $elements, EagerLoadPlan $plan): void + { + switch ($plan->handle) { + case 'user': + $this->setUser($elements[0]); + break; + default: + parent::setEagerLoadedElements($handle, $elements, $plan); + } + } /** * Return URL to edit the subscription in Stripe Dashboard @@ -649,11 +697,26 @@ public function getProducts(): array|null return $this->_products; } + /** + * Gets the Prices that are part of the subscription. + * + * @return Price[] + */ + public function getPrices(): array + { + if (!$this->prices) { + return []; + } + + return Price::find() + ->stripeId($this->prices) + ->all(); + } + /** * Returns customer this subscription is related to. * * @return Customer|null - * @throws \yii\base\InvalidConfigException */ public function getCustomer(): Customer|null { @@ -671,6 +734,49 @@ public function setCustomer(Customer $customer): void $this->_customer = $customer; } + /** + * Gets the User element corresponding to the subscriber. + * + * @return User|null + * @since 1.7.x + */ + public function getUser(): ?User + { + if ($this->_user) { + return $this->_user; + } + + if ($this->userId) { + $user = Craft::$app->getUsers()->getUserById($this->_userId); + + $this->setUser($user); + + return $this->_user; + } + + // Ok, we don’t have any concrete relationship to a user... let’s go via the customer: + + $customer = $this->getCustomer(); + + if (!$customer) { + return null; + } + + return $customer->getUser(); + } + + /** + * Sets the subscriber’s User element. + * + * @param User $user + * @since 1.7.x + */ + public function setUser(User $user): void + { + $this->userId = $user->id; + $this->_user = $user; + } + /** * Returns the URL to update the subscription in the billing portal. * diff --git a/src/elements/db/PriceQuery.php b/src/elements/db/PriceQuery.php index 366b3bd..80fdae6 100644 --- a/src/elements/db/PriceQuery.php +++ b/src/elements/db/PriceQuery.php @@ -540,7 +540,6 @@ protected function beforePrepare(): bool 'stripe_pricedata.type', 'stripe_pricedata.productId as stripeProductId', 'stripe_pricedata.primaryCurrency', - ]); if (!empty($this->ownerId) || !empty($this->primaryOwnerId)) { diff --git a/src/elements/db/SubscriptionQuery.php b/src/elements/db/SubscriptionQuery.php index 9cbeb13..79e04fa 100644 --- a/src/elements/db/SubscriptionQuery.php +++ b/src/elements/db/SubscriptionQuery.php @@ -824,6 +824,7 @@ protected function beforePrepare(): bool $this->query->select([ 'stripe_subscriptions.stripeId', + 'stripe_subscriptions.userId', 'stripe_subscriptiondata.stripeStatus', 'stripe_subscriptiondata.data', 'stripe_subscriptiondata.prices', @@ -836,6 +837,7 @@ protected function beforePrepare(): bool 'stripe_subscriptiondata.trialEnd', 'stripe_customerdata.stripeId AS customerStripeId', 'stripe_customerdata.email AS customerEmail', + // This array is unpacked and removed from the “row” when populating the results: 'stripe_customerdata.data AS customerData', ]); diff --git a/src/events/GrantGroupAssignmentEvent.php b/src/events/GrantGroupAssignmentEvent.php new file mode 100644 index 0000000..ddb84a9 --- /dev/null +++ b/src/events/GrantGroupAssignmentEvent.php @@ -0,0 +1,44 @@ + + */ +class GrantGroupAssignmentEvent extends CancelableEvent +{ + /** + * @var Subscription The subscription that caused the event to be triggered. + */ + public Subscription $subscription; + + /** + * @var Price The relevant Stripe price that contains assignment configuration. + */ + public Price $price; + + /** + * @var User The user whose permissions may be changing. + */ + public User $user; + + /** + * @var UserGroup The user will be added to this group. + */ + public UserGroup $group; +} \ No newline at end of file diff --git a/src/events/RevokeGroupAssignmentEvent.php b/src/events/RevokeGroupAssignmentEvent.php new file mode 100644 index 0000000..91ec7e3 --- /dev/null +++ b/src/events/RevokeGroupAssignmentEvent.php @@ -0,0 +1,45 @@ + + * @since 1.7.x + */ +class RevokeGroupAssignmentEvent extends CancelableEvent +{ + /** + * @var Subscription The subscription that caused the event to be triggered. + */ + public Subscription $subscription; + + /** + * @var Price The relevant Stripe price that contains assignment configuration. + */ + public Price $price; + + /** + * @var User The user whose permissions may be changing. + */ + public User $user; + + /** + * @var UserGroup Group the subscriber will be added to. + */ + public UserGroup $group; +} \ No newline at end of file diff --git a/src/events/StripeSubscriptionStatusChangeEvent.php b/src/events/StripeSubscriptionStatusChangeEvent.php new file mode 100644 index 0000000..a2c661e --- /dev/null +++ b/src/events/StripeSubscriptionStatusChangeEvent.php @@ -0,0 +1,35 @@ + + * @since 1.7.x + */ +class StripeSubscriptionStatusChangeEvent extends CancelableEvent +{ + /** + * @var Subscription Craft subscription element being synchronized. + */ + public Subscription $subscription; + + /** + * @var string Status the subscription moved into. + */ + public string $newStatus; + + /** + * @var string|null Status the subscription moved away from. + */ + public ?string $oldStatus = null; +} diff --git a/src/events/StripeSubscriptionSyncEvent.php b/src/events/StripeSubscriptionSyncEvent.php index 27a9880..8f63dc2 100644 --- a/src/events/StripeSubscriptionSyncEvent.php +++ b/src/events/StripeSubscriptionSyncEvent.php @@ -27,4 +27,10 @@ class StripeSubscriptionSyncEvent extends CancelableEvent * @var StripeSubscription Stripe API Subscription object. */ public StripeSubscription $source; + + /** + * @var bool Is this subscription just being created? + * @since 1.7.x + */ + public bool $isNew = false; } diff --git a/src/fieldlayoutelements/AssignmentLogs.php b/src/fieldlayoutelements/AssignmentLogs.php new file mode 100644 index 0000000..eae0b8d --- /dev/null +++ b/src/fieldlayoutelements/AssignmentLogs.php @@ -0,0 +1,51 @@ + + * @since 1.7.x + */ +class AssignmentLogs extends BaseUiElement +{ + /** + * @inheritdoc + */ + protected function selectorLabel(): string + { + return Craft::t('stripe', 'Subscription Logs'); + } + + /** + * @inheritdoc + */ + protected function selectorIcon(): ?string + { + return '@appicons/list-timeline.svg'; + } + + /** + * @inheritdoc + */ + public function formHtml(?ElementInterface $element = null, bool $static = false): ?string + { + return Craft::$app->getView()->renderTemplate('stripe/fieldlayoutelements/assignmentlogs', [ + 'logs' => Plugin::getInstance()->getSubscriptions()->getLogs($element), + 'canManagePrices' => Craft::$app->getUser()->getIdentity()->can('accessPlugin-stripe'), + ]); + } +} \ No newline at end of file diff --git a/src/fieldlayoutelements/UserGroupAssignmentsField.php b/src/fieldlayoutelements/UserGroupAssignmentsField.php new file mode 100644 index 0000000..d11b2ab --- /dev/null +++ b/src/fieldlayoutelements/UserGroupAssignmentsField.php @@ -0,0 +1,130 @@ + + * @since 1.7.x + */ +class UserGroupAssignmentsField extends BaseNativeField +{ + /** + * @inheritdoc + */ + public bool $mandatory = true; + + /** + * @inheritdoc + */ + public string $attribute = 'userGroupAssignments'; + + /** + * @inheritdoc + */ + public bool $required = false; + + /** + * @inheritdoc + */ + public function __construct($config = []) + { + unset( + $config['mandatory'], + $config['translatable'], + $config['maxlength'], + $config['required'], + $config['autofocus'] + ); + + parent::__construct($config); + } + + /** + * @inheritdoc + */ + public function fields(): array + { + $fields = parent::fields(); + unset( + $fields['mandatory'], + $fields['translatable'], + $fields['maxlength'], + $fields['required'], + $fields['autofocus'] + ); + return $fields; + } + + /** + * @inheritdoc + */ + public function defaultLabel(?ElementInterface $element = null, bool $static = false): ?string + { + return Craft::t('stripe', 'User Group Assignments'); + } + + /** + * @inheritdoc + */ + protected function defaultInstructions(?ElementInterface $element = null, bool $static = false): ?string + { + return Craft::t('stripe', 'Select the groups you want subscribers of this plan to be added to.'); + } + + /** + * @inheritdoc + */ + protected function selectorIcon(): ?string + { + return '@appicons/people-group.svg'; + } + + /** + * @inheritdoc + */ + public function formHtml(?ElementInterface $element = null, bool $static = false): ?string + { + /** @var Price $element */ + // We don’t want to display this for one-time items: + if (!$element->isRecurring()) { + return null; + } + + // User groups are only configurable in Craft Pro, which is not a requirement of the plugin: + if (Craft::$app->edition->value !== CmsEdition::Pro->value) { + return null; + } + + return parent::formHtml($element, $static); + } + + /** + * @inheritdoc + */ + protected function inputHtml(?ElementInterface $element = null, bool $static = false): ?string + { + if (!$element instanceof Price) { + throw new InvalidArgumentException(sprintf('%s can only be used in price field layouts.', __CLASS__)); + } + + Craft::$app->getView()->registerDeltaName($this->attribute()); + + return Craft::$app->getView()->renderTemplate('stripe/fieldlayoutelements/usergroupassignmentsfield', [ + 'plan' => $element, + ]); + } +} diff --git a/src/helpers/Customer.php b/src/helpers/Customer.php index b23135a..bbb021b 100644 --- a/src/helpers/Customer.php +++ b/src/helpers/Customer.php @@ -49,7 +49,7 @@ public static function getCustomerLink(string $id): string } // if we found a matching user - return element chip - return Cp::elementChipHtml($user, ['size' => Cp::CHIP_SIZE_SMALL]); + return Cp::elementChipHtml($user, ['hyperlink' => Craft::$app->getUser()->checkPermission('viewUsers')]); } /** diff --git a/src/helpers/Subscription.php b/src/helpers/Subscription.php index f1f786c..c0c0112 100644 --- a/src/helpers/Subscription.php +++ b/src/helpers/Subscription.php @@ -53,6 +53,7 @@ public static function renderCardHtml(SubscriptionElement $subscription): string 'icon' => 'external', ], ]); + $cardHeader = Html::a($title . $externalLink, $subscription->getStripeEditUrl(), [ 'style' => '', 'class' => 'pec-header', @@ -75,90 +76,79 @@ public static function renderCardHtml(SubscriptionElement $subscription): string ]); if (count($stripeSubscription) > 0) { - foreach ($properties as $property) { - switch ($property) { - case 'customer': - $meta[Craft::t('stripe', 'Customer')] = Customer::getCustomerLink($stripeSubscription['customer']); - break; - case 'currentPeriod': - $meta[Craft::t('stripe', 'Current period')] = - $formatter->asDatetime($stripeSubscription['current_period_start'], 'php:d M') . - ' to ' . - $formatter->asDatetime($stripeSubscription['current_period_end'], 'php:d M'); - break; - case 'cancelAtPeriodEnd': - $meta[Craft::t('stripe', 'Cancel at period end')] = - $stripeSubscription['cancel_at_period_end'] ? - Craft::t('stripe', 'Yes') : - Craft::t('stripe', 'No'); - break; - case 'discount': - $meta[Craft::t('stripe', 'Discounts')] = collect($stripeSubscription['discounts']) - ->filter() - ->join(', '); - break; - case 'cancelAt': - if ($stripeSubscription['cancel_at'] !== null) { - $meta[Craft::t('stripe', 'Cancel at')] = - $formatter->asDatetime($stripeSubscription['cancel_at'], Formatter::FORMAT_WIDTH_SHORT); - } - break; - case 'canceledAt': - if ($stripeSubscription['canceled_at'] !== null) { - $meta[Craft::t('stripe', 'Canceled at')] = - $formatter->asDatetime($stripeSubscription['canceled_at'], Formatter::FORMAT_WIDTH_SHORT); - } - break; - case 'endedAt': - if ($stripeSubscription['ended_at'] !== null) { - $meta[Craft::t('stripe', 'Ended at')] = - $formatter->asDatetime($stripeSubscription['ended_at'], Formatter::FORMAT_WIDTH_SHORT); - } - break; - case 'products': - $products = array_filter($subscription->getProducts()); - $html = '
    '; - foreach ($products as $product) { - $html .= '
  • ' . Cp::elementChipHtml($product, ['size' => Cp::CHIP_SIZE_SMALL]) . '
  • '; - } - $html .= '
'; - $meta[Craft::t('stripe', 'Products')] = $html; - } + // Customer/Subscriber + $meta[Craft::t('stripe', 'Customer')] = Customer::getCustomerLink($stripeSubscription['customer']); + + // Billing period + $meta[Craft::t('stripe', 'Current period')] = + $formatter->asDate($stripeSubscription['current_period_start']) . + ' to ' . + $formatter->asDate($stripeSubscription['current_period_end']); + + // Cancellation + Expiry + if ($stripeSubscription['cancel_at'] !== null) { + $meta[Craft::t('stripe', 'Cancel at')] = + $formatter->asDatetime($stripeSubscription['cancel_at'], Formatter::FORMAT_WIDTH_SHORT); } - } - $footer = ''; - if (count($stripeSubscription) > 0) { - $meta[Craft::t('stripe', 'Metadata')] = - Html::beginTag('div', ['class' => 'pec-metadata']) . - collect($stripeSubscription['metadata']) - ->map(function($value, $key) { - return Html::beginTag('div', ['class' => 'fullwidth']) . - Html::tag('em', Html::encode($key) . ': ') . - Html::encode($value) . - Html::endTag('div'); - }) - ->join(' ') . - Html::endTag('div'); - - $meta[Craft::t('stripe', 'Created at')] = $formatter->asDatetime($stripeSubscription['created'], Formatter::FORMAT_WIDTH_SHORT); - - $spinner = Html::tag('div', '', [ - 'class' => 'spinner', - 'hx' => [ - 'indicator', - ], - ]); + if ($stripeSubscription['canceled_at'] !== null) { + $meta[Craft::t('stripe', 'Canceled at')] = + $formatter->asDatetime($stripeSubscription['canceled_at'], Formatter::FORMAT_WIDTH_SHORT); + + $meta[Craft::t('stripe', 'Cancel at period end')] = + $stripeSubscription['cancel_at_period_end'] ? + Craft::t('stripe', 'Yes') : + Craft::t('stripe', 'No'); + } + + if ($stripeSubscription['ended_at'] !== null) { + $meta[Craft::t('stripe', 'Ended at')] = + $formatter->asDatetime($stripeSubscription['ended_at'], Formatter::FORMAT_WIDTH_SHORT); + } - $dateCreated = DateTimeHelper::toDateTime($stripeSubscription['created']); - $now = new \DateTime(); - $diff = $now->diff($dateCreated); - $duration = DateTimeHelper::humanDuration($diff, false); - $footer = Html::tag('div', 'Created ' . $duration . ' ago.' . $spinner, [ - 'class' => 'pec-footer', + // Applied discount(s) + $meta[Craft::t('stripe', 'Discounts')] = collect($stripeSubscription['discounts']) + ->filter() + ->join(', '); + + // Products + $meta[Craft::t('stripe', 'Products')] = Html::ul(array_filter($subscription->getProducts()), [ + 'item' => function($product, $index) { + return Html::tag('li', Cp::elementChipHtml($product, ['hyperlink' => true])); + }, ]); } + // Metadata + $meta[Craft::t('stripe', 'Metadata')] = + Html::beginTag('div', ['class' => 'pec-metadata']) . + collect($stripeSubscription['metadata']) + ->map(function($value, $key) { + return Html::beginTag('div', ['class' => 'fullwidth']) . + Html::tag('em', $key . ': ') . + $value . + Html::endTag('div'); + }) + ->join(' ') . + Html::endTag('div'); + + $meta[Craft::t('app', 'Created at')] = $formatter->asDatetime($stripeSubscription['created'], Formatter::FORMAT_WIDTH_SHORT); + + $spinner = Html::tag('div', '', [ + 'class' => 'spinner', + 'hx' => [ + 'indicator', + ], + ]); + + $dateCreated = DateTimeHelper::toDateTime($stripeSubscription['created']); + $now = new \DateTime(); + $diff = $now->diff($dateCreated); + $duration = DateTimeHelper::humanDuration($diff, false); + $footer = Html::tag('div', 'Created ' . $duration . ' ago.' . $spinner, [ + 'class' => 'pec-footer', + ]); + $metadataHtml = Cp::metadataHtml($meta); return Html::tag('div', $cardHeader . $hr . $metadataHtml . $footer, [ diff --git a/src/migrations/Install.php b/src/migrations/Install.php index 2b46b18..0551d0f 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -109,6 +109,26 @@ public function createTables(): void 'prices' => $this->text()->defaultValue(null), ]); + $this->archiveTableIfExists(Table::SUBSCRIPTIONLOGS); + $this->createTable(Table::SUBSCRIPTIONLOGS, [ + 'id' => $this->primaryKey(), + 'subscriptionId' => $this->integer()->notNull(), + 'message' => $this->text()->notNull(), + 'dateCreated' => $this->dateTime()->notNull(), + 'dateUpdated' => $this->dateTime()->notNull(), + 'uid' => $this->string(), + ]); + + $this->archiveTableIfExists(Table::PRICES_USERGROUPS); + $this->createTable(Table::PRICES_USERGROUPS, [ + 'id' => $this->primaryKey(), + 'priceId' => $this->integer()->notNull(), + 'groupId' => $this->integer()->notNull(), + 'dateCreated' => $this->dateTime()->notNull(), + 'dateUpdated' => $this->dateTime()->notNull(), + 'uid' => $this->string(), + ]); + $this->archiveTableIfExists(Table::PAYMENTMETHODDATA); $this->createTable(Table::PAYMENTMETHODDATA, [ 'id' => $this->primaryKey(), @@ -270,6 +290,11 @@ public function addForeignKeys(): void $this->addForeignKey(null, Table::SUBSCRIPTIONDATA, ['subscriptionId'],Table::SUBSCRIPTIONS, ['id'], 'CASCADE', 'CASCADE'); $this->addForeignKey(null, Table::SUBSCRIPTIONS, ['id'], CraftTable::ELEMENTS, ['id'], 'CASCADE', 'CASCADE'); + + $this->addForeignKey(null, Table::PRICES_USERGROUPS, ['groupId'], CraftTable::USERGROUPS, ['id'], 'CASCADE', 'CASCADE'); + $this->addForeignKey(null, Table::PRICES_USERGROUPS, ['priceId'], Table::PRICES, ['id'], 'CASCADE', 'CASCADE'); + + $this->addForeignKey(null, Table::SUBSCRIPTIONLOGS, ['subscriptionId'], Table::SUBSCRIPTIONS, ['id'], 'CASCADE'); } /** diff --git a/src/migrations/m250806_201102_add_price_user_groups_table.php b/src/migrations/m250806_201102_add_price_user_groups_table.php new file mode 100644 index 0000000..4aacf89 --- /dev/null +++ b/src/migrations/m250806_201102_add_price_user_groups_table.php @@ -0,0 +1,42 @@ +createTable(StripeTable::PRICES_USERGROUPS, [ + 'id' => $this->primaryKey(), + 'priceId' => $this->integer()->notNull(), + 'groupId' => $this->integer()->notNull(), + 'dateCreated' => $this->dateTime()->notNull(), + 'dateUpdated' => $this->dateTime()->notNull(), + 'uid' => $this->string(), + ]); + + $this->addForeignKey(null, StripeTable::PRICES_USERGROUPS, ['groupId'], CraftTable::USERGROUPS, ['id'], 'CASCADE', 'CASCADE'); + $this->addForeignKey(null, StripeTable::PRICES_USERGROUPS, ['priceId'], StripeTable::PRICES, ['id'], 'CASCADE', 'CASCADE'); + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m250806_201102_AddPriceUserGroupAssignmentsTable cannot be reverted.\n"; + return false; + } +} diff --git a/src/migrations/m250820_185807_add_user_id_column_to_subscriptions.php b/src/migrations/m250820_185807_add_user_id_column_to_subscriptions.php new file mode 100644 index 0000000..e90bf8c --- /dev/null +++ b/src/migrations/m250820_185807_add_user_id_column_to_subscriptions.php @@ -0,0 +1,33 @@ +addColumn(Table::SUBSCRIPTIONS, 'userId', $this->integer()); + $this->addForeignKey(null, Table::SUBSCRIPTIONS, ['userId'], CraftTable::USERS, ['id'], 'SET NULL'); + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m250820_185807_add_user_id_column_to_subscriptions cannot be reverted.\n"; + return false; + } +} diff --git a/src/migrations/m250821_232955_add_subscription_logs.php b/src/migrations/m250821_232955_add_subscription_logs.php new file mode 100644 index 0000000..2af2a2b --- /dev/null +++ b/src/migrations/m250821_232955_add_subscription_logs.php @@ -0,0 +1,40 @@ +createTable(Table::SUBSCRIPTIONLOGS, [ + 'id' => $this->primaryKey(), + 'subscriptionId' => $this->integer()->notNull(), + 'message' => $this->text()->notNull(), + 'dateCreated' => $this->dateTime()->notNull(), + 'dateUpdated' => $this->dateTime()->notNull(), + 'uid' => $this->string(), + ]); + + $this->addForeignKey(null, Table::SUBSCRIPTIONLOGS, ['subscriptionId'], Table::SUBSCRIPTIONS, ['id'], 'CASCADE'); + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m250821_232955_add_subscription_logs cannot be reverted.\n"; + return false; + } +} diff --git a/src/models/Customer.php b/src/models/Customer.php index dd49c72..ec13114 100644 --- a/src/models/Customer.php +++ b/src/models/Customer.php @@ -7,6 +7,7 @@ namespace craft\stripe\models; +use craft\elements\User; use craft\stripe\base\Model; use craft\stripe\Plugin; use DateTime; @@ -42,4 +43,20 @@ public function getStripeEditUrl(): string { return Plugin::getInstance()->stripeBaseUrl . "/customers/{$this->stripeId}"; } + + /** + * Returns the Craft {@see User} element with the matching email. + * + * @return User|null + */ + public function getUser(): ?User + { + if (!$this->email) { + return null; + } + + return User::find() + ->email($this->email) + ->one(); + } } diff --git a/src/models/Message.php b/src/models/Message.php new file mode 100644 index 0000000..9afc3a2 --- /dev/null +++ b/src/models/Message.php @@ -0,0 +1,34 @@ +message); + } +} \ No newline at end of file diff --git a/src/services/Customers.php b/src/services/Customers.php index 96f41cf..f247a1e 100644 --- a/src/services/Customers.php +++ b/src/services/Customers.php @@ -80,10 +80,10 @@ public function createOrUpdateCustomer(StripeCustomer $customer): bool $lockKey = "stripe-customer:$customer->id"; $mutex = Craft::$app->getMutex(); if (!$mutex->acquire($lockKey, 15)) { - throw new MutexException($lockKey, 'Could not acquire a lock to create or update price.'); + throw new MutexException($lockKey, 'Could not acquire a lock to create or update a customer.'); } - // Build our attribute set from the Stripe payment method data: + // Build our attribute set from the Stripe customer data: $attributes = [ 'stripeId' => $customer->id, 'email' => $customer->email, @@ -91,7 +91,7 @@ public function createOrUpdateCustomer(StripeCustomer $customer): bool 'data' => Json::decode($customer->toJSON()), ]; - // Find the payment method data or create one + // Find the local Customer record or create one /** @var CustomerDataRecord $customerDataRecord */ $customerDataRecord = CustomerDataRecord::find()->where(['stripeId' => $customer->id])->one() ?: new CustomerDataRecord(); $customerDataRecord->setAttributes($attributes, false); diff --git a/src/services/Subscriptions.php b/src/services/Subscriptions.php index 2279797..c14c78f 100644 --- a/src/services/Subscriptions.php +++ b/src/services/Subscriptions.php @@ -8,21 +8,29 @@ namespace craft\stripe\services; use Craft; +use craft\db\Query; use craft\elements\User; use craft\enums\CmsEdition; use craft\errors\MutexException; use craft\events\ConfigEvent; +use craft\helpers\ArrayHelper; +use craft\helpers\Db; use craft\helpers\Json; use craft\helpers\ProjectConfig; +use craft\i18n\Translation; use craft\models\FieldLayout; +use craft\stripe\db\Table; +use craft\stripe\elements\Price; use craft\stripe\elements\Subscription; -use craft\stripe\elements\Subscription as SubscriptionElement; +use craft\stripe\events\GrantGroupAssignmentEvent; +use craft\stripe\events\RevokeGroupAssignmentEvent; +use craft\stripe\events\StripeSubscriptionStatusChangeEvent; use craft\stripe\events\StripeSubscriptionSyncEvent; use craft\stripe\models\Customer; +use craft\stripe\models\Message; use craft\stripe\Plugin; use craft\stripe\records\SubscriptionData as SubscriptionDataRecord; use Stripe\Customer as StripeCustomer; -use Stripe\Stripe; use Stripe\Subscription as StripeSubscription; use yii\base\Component; @@ -82,6 +90,53 @@ class Subscriptions extends Component */ public const EVENT_AFTER_SYNCHRONIZE_SUBSCRIPTION = 'afterSynchronizeSubscription'; + /** + * @event StripeSubscriptionStatusChangeEvent Event triggered when a subscription changes status. + * @since 1.7.0 + * + * --- + * + * ```php + * use craft\stripe\events\StripeSubscriptionStatusChangeEvent; + * use craft\stripe\services\Subscriptions; + * use yii\base\Event; + * + * Event::on( + * Subscriptions::class, + * Subscriptions::EVENT_SUBSCRIPTION_STATUS_CHANGE, + * function(StripeSubscriptionStatusChangeEvent $event) { + * if ($event->newStatus !== StripeSubscription::STATUS_CANCELED) { + * // We only want to act on cancellations! + * return; + * } + * + * $perpetualVipProductIds = Product::find() + * ->grantsPerpetualVipStatus(true) + * ->ids(); + * $subscriptionProductIds = ArrayHelper::getColumn($subscription->getProducts(), 'id'); + * + * // Suppress default cancellation logic for those products: + * $hasVipProducts = count(array_intersect($perpetualVipProductIds, $subscriptionProductIds)) > 0; + * + * if ($hasVipProducts) { + * $event->isValid = false; + * } + * } + * ); + * ``` + */ + public const EVENT_SUBSCRIPTION_STATUS_CHANGE = 'subscriptionStatusChange'; + + /** + * @event craft\stripe\events\GrantGroupAssignmentEvent + */ + public const EVENT_BEFORE_GRANT_GROUP = 'beforeGrantGroup'; + + /** + * @event craft\stripe\events\RevokeGroupAssignmentEvent + */ + public const EVENT_BEFORE_REVOKE_GROUP = 'beforeRevokeGroup'; + /** * @return void * @throws \Throwable @@ -107,7 +162,7 @@ public function syncAllSubscriptions(): void } // Remove any subscriptions that are no longer in Stripe just in case. - $deletableSubscriptionElements = SubscriptionElement::find()->stripeId(['not', $stripeIds])->all(); + $deletableSubscriptionElements = Subscription::find()->stripeId(['not', $stripeIds])->all(); foreach ($deletableSubscriptionElements as $element) { Craft::$app->elements->deleteElement($element); @@ -193,6 +248,11 @@ public function createOrUpdateSubscriptionElement(StripeSubscription $subscripti throw new MutexException($lockKey, 'Could not acquire a lock to create or update subscription.'); } + // Record states before the update: + $isNew = !isset($subscriptionElement->id) || $subscriptionElement->getIsDraft(); + // Subscriptions default to `active` in our system, which is somewhat misleading: + $originalStatus = $isNew ? null : $subscriptionElement->stripeStatus; + // Build our attribute set from the Stripe subscription data: $attributes = [ 'stripeId' => $subscription->id, @@ -208,6 +268,7 @@ public function createOrUpdateSubscriptionElement(StripeSubscription $subscripti $event = new StripeSubscriptionSyncEvent([ 'element' => $subscriptionElement, 'source' => $subscription, + 'isNew' => $isNew, ]); $this->trigger(self::EVENT_BEFORE_SYNCHRONIZE_SUBSCRIPTION, $event); @@ -220,6 +281,12 @@ public function createOrUpdateSubscriptionElement(StripeSubscription $subscripti return false; } + $settings = Plugin::getInstance()->getSettings(); + if ($settings->createUserIfMissing && Craft::$app->edition->value >= CmsEdition::Pro->value) { + $user = $this->ensureUser($subscription, $subscriptionElement); + $subscriptionElement->setUser($user); + } + if ($subscriptionElement->getIsUnpublishedDraft()) { try { $subscriptionElement = Craft::$app->getDrafts()->applyDraft($subscriptionElement); @@ -242,11 +309,6 @@ public function createOrUpdateSubscriptionElement(StripeSubscription $subscripti } } - $settings = Plugin::getInstance()->getSettings(); - if ($settings->createUserIfMissing && Craft::$app->edition->value >= CmsEdition::Pro->value) { - $this->ensureUser($subscription, $subscriptionElement); - } - $attributes['subscriptionId'] = $subscriptionElement->id; // Find the subscription data or create one @@ -260,10 +322,14 @@ public function createOrUpdateSubscriptionElement(StripeSubscription $subscripti $mutex->release($lockKey); } + // Handle potential status changes: + $this->handleStatusChange($subscriptionElement, $originalStatus); + if ($this->hasEventHandlers(self::EVENT_AFTER_SYNCHRONIZE_SUBSCRIPTION)) { $event = new StripeSubscriptionSyncEvent([ 'element' => $subscriptionElement, 'source' => $subscription, + 'isNew' => $isNew, ]); $this->trigger(self::EVENT_AFTER_SYNCHRONIZE_SUBSCRIPTION, $event); } @@ -285,20 +351,20 @@ public function handleChangedFieldLayout(ConfigEvent $event): void if (empty($data) || empty(reset($data))) { // Delete the field layout - $fieldsService->deleteLayoutsByType(SubscriptionElement::class); + $fieldsService->deleteLayoutsByType(Subscription::class); return; } // Save the field layout $layout = FieldLayout::createFromConfig(reset($data)); - $layout->id = $fieldsService->getLayoutByType(SubscriptionElement::class)->id; - $layout->type = SubscriptionElement::class; + $layout->id = $fieldsService->getLayoutByType(Subscription::class)->id; + $layout->type = Subscription::class; $layout->uid = key($data); $fieldsService->saveLayout($layout, false); // Invalidate subscription caches - Craft::$app->getElements()->invalidateCachesForElementType(SubscriptionElement::class); + Craft::$app->getElements()->invalidateCachesForElementType(Subscription::class); } /** @@ -306,7 +372,7 @@ public function handleChangedFieldLayout(ConfigEvent $event): void */ public function handleDeletedFieldLayout(): void { - Craft::$app->getFields()->deleteLayoutsByType(SubscriptionElement::class); + Craft::$app->getFields()->deleteLayoutsByType(Subscription::class); } /** @@ -320,7 +386,7 @@ public function handleDeletedFieldLayout(): void public function deleteSubscriptionByStripeId(string $stripeId): void { if ($stripeId) { - if ($subscription = SubscriptionElement::find()->stripeId($stripeId)->one()) { + if ($subscription = Subscription::find()->stripeId($stripeId)->one()) { Craft::$app->getElements()->deleteElement($subscription, false); } if ($subscriptionData = SubscriptionDataRecord::find()->where(['stripeId' => $stripeId])->one()) { @@ -389,17 +455,17 @@ public function resumeSubscriptionByStripeId(string $stripeId): bool * Return Subscription element draft by its uid stored in the Stripe's checkout session's metadata. * * @param StripeSubscription $subscription - * @return SubscriptionElement + * @return Subscription * @since 1.2 */ - public function getUnsavedDraftByUid(StripeSubscription $subscription): SubscriptionElement + public function getUnsavedDraftByUid(StripeSubscription $subscription): Subscription { // get checkout session by subscription id $stripe = Plugin::getInstance()->getApi()->getClient(); $sessionsList = $stripe->checkout->sessions->all(['subscription' => $subscription->id]); if ($sessionsList->isEmpty()) { - return new SubscriptionElement(); + return new Subscription(); } // if we found one, get the metadata from the session @@ -407,27 +473,252 @@ public function getUnsavedDraftByUid(StripeSubscription $subscription): Subscrip $uid = $checkoutSession->metadata['craftSubscriptionUid'] ?? null; if ($uid === null) { - return new SubscriptionElement(); + return new Subscription(); } // try to find an unsaved Subscription element by the uid from the session's metadata - return SubscriptionElement::find() + return Subscription::find() ->uid($uid) ->status(null) ->drafts() - ->one() ?? new SubscriptionElement(); + ->one() ?? new Subscription(); + } + + /** + * Grants permissions for the passed Subscription. + * + * The `$price` argument is used to override the default behavior, which uses the currently-associated Price(s) to determine which groups are granted. In situations where we need to swap out permissions (i.e. switching “plans”), the Subscription will only have access to the new {@see Price}. + * + * @param Subscription $subscription + * @param Price[]|null $prices + * @return bool + */ + public function grantGroupsForSubscription(Subscription $subscription, ?array $prices = null): bool + { + $prices = $prices ?? $subscription->getPrices(); + $user = $subscription->getUser(); + + if (!$user) { + $this->logActivity($subscription->id, Translation::prep('stripe', 'No user exists for this subscription.')); + + return false; + } + + foreach ($prices as $price) { + $groups = $price->getUserGroupAssignments(); + + foreach ($groups as $group) { + // They may already be in it: + if ($user->isInGroup($group->id)) { + $this->logActivity($subscription->id, Translation::prep('stripe', 'The user already belonged to group ID #{groupId} ({groupName}) when they signed up for {priceName}.', [ + 'groupId' => $group->id, + 'groupName' => $group->name, + 'priceName' => $price->title, + ])); + + continue; + } + + // Fire an event to give the system an opportunity to alter the behavior: + $event = new GrantGroupAssignmentEvent([ + 'subscription' => $subscription, + 'price' => $price, + 'user' => $user, + 'group' => $group, + ]); + + $this->trigger(self::EVENT_BEFORE_GRANT_GROUP, $event); + + // If it was prevented, log a message and continue: + if (!$event->isValid) { + $this->logActivity( + $subscription->id, + Translation::prep('stripe', 'A plugin prevented the user from being added to group ID #{groupId} ({groupName}).', [ + 'groupId' => $group->id, + 'groupName' => $group->name, + ]) + ); + + continue; + } + + // Get current groups, and append the granted one: + $subscriberGroups = $user->getGroups(); + $subscriberGroups[] = $group; + + // Assign by plucking their IDs: + Craft::$app->getUsers()->assignUserToGroups($user->id, ArrayHelper::getColumn($subscriberGroups, 'id')); + + // Set them back on the User, clearing the cached values: + $user->setGroups($subscriberGroups); + + $this->logActivity( + $subscription->id, + Translation::prep('stripe', 'Added the user to group ID #{groupId} ({groupName}) when they subscribed to {priceName}.', [ + 'groupId' => $group->id, + 'groupName' => $group->name, + 'priceName' => $price->title, + ]) + ); + } + } + + return true; + } + + /** + * Removes subscribers from user groups based on its Price’s configuration. + * + * As with the sister `grant` method, this one accepts an explicit list of {@see Price}s so that we can appropriately handle moving *away from* or *to* a given “plan.” + * + * @param Subscription $subscription + * @param Price[]|null $prices + * @return bool + */ + public function revokeGroupsForSubscription(Subscription $subscription, ?array $prices = null): bool + { + $prices = $prices ?? $subscription->getPrices(); + $user = $subscription->getUser(); + + // Get the User's *other*, *live* Subscriptions, if any: + $subscriptions = Subscription::find() + ->user($user) + ->id(['not', $subscription->id]) + ->status('live') + ->all(); + + // Fetch the Prices for those Subscriptions, and gather the granted user groups... + $protectedGroups = array_reduce($subscriptions, function($groups, $sub) { + $subGroups = []; + + foreach ($sub->getPrices() as $price) { + /** @var Price $price */ + array_merge($subGroups, $price->getUserGroupAssignments()); + } + + return array_merge($groups, $subGroups); + }, []); + + $protectedGroupIds = array_unique(ArrayHelper::getColumn($protectedGroups, 'id')); + + // Loop over the prices in the Subscription and pull the user out of the designated groups: + foreach ($prices as $price) { + $groups = $price->getUserGroupAssignments(); + + foreach ($groups as $group) { + // We also don't want to revoke a permission granted by a different (active) Subscription: + if (in_array($group->id, $protectedGroupIds)) { + $this->logActivity( + $subscription->id, + Translation::prep('stripe', 'Another active subscription prevented the user from being removed from group ID #{groupId} ({groupName}).', [ + 'groupId' => $group->id, + 'groupName' => $group->name, + ]) + ); + + continue; + } + + // They may just not be in it: + if (!$user->isInGroup($group->id)) { + $this->logActivity( + $subscription->id, + Translation::prep('stripe', 'The user wasn’t in group ID #{groupId} ({groupName}), so no action was taken.', [ + 'groupId' => $group->id, + 'groupName' => $group->name, + ]) + ); + + continue; + } + + // Fire an event to give the system an opportunity to alter the behavior: + $event = new RevokeGroupAssignmentEvent([ + 'subscription' => $subscription, + 'user' => $user, + 'group' => $group, + 'price' => $price, + ]); + + $this->trigger(self::EVENT_BEFORE_REVOKE_GROUP, $event); + + // If it was prevented, log a message and continue: + if (!$event->isValid) { + $this->logActivity( + $subscription->id, + Translation::prep('stripe', 'A plugin prevented the user from being removed from group ID #{groupId} ({groupName}).', [ + 'groupId' => $group->id, + 'groupName' => $group->name + ]) + ); + + continue; + } + + // Get current groups, and filter out this one: + $newGroups = array_filter($user->getGroups(), function ($g) use ($group) { + if ($g->id === $group->id) { + return false; + } + + return true; + }); + + // Assign the new groups: + Craft::$app->getUsers()->assignUserToGroups($user->id, ArrayHelper::getColumn($newGroups, 'id')); + + // Set them back on the User, clearing the cached values: + $user->setGroups($newGroups); + + $this->logActivity( + $subscription->id, + Translation::prep('stripe', 'The user was removed from group ID #{groupId} ({groupName}).', [ + 'groupId' => $group->id, + 'groupName' => $group->name + ]) + ); + } + } + + return true; + } + /** + * Gets user group assignment logs for the provided subscription. + * + * @param Subscription $subscription + * @return Message[] + */ + public function getLogs(Subscription $subscription): array + { + $rows = (new Query) + ->from([Table::SUBSCRIPTIONLOGS]) + ->select([ + 'id', + 'message', + 'subscriptionId', + 'dateCreated', + ]) + ->where([ + 'subscriptionId' => $subscription->id, + ]) + ->orderBy('dateCreated DESC') + ->all(); + + return array_map(function($row) { + return new Message($row); + }, $rows); } /** * Ensures that a user with given email address is created if one doesn't already exist. * * @param StripeSubscription $subscription - * @param SubscriptionElement $subscriptionElement - * @return void + * @param Subscription $subscriptionElement + * @return User|null * @throws \yii\base\Exception * @throws \yii\base\InvalidConfigException */ - private function ensureUser(StripeSubscription $subscription, SubscriptionElement $subscriptionElement): void + private function ensureUser(StripeSubscription $subscription, Subscription $subscriptionElement): ?User { $plugin = Plugin::getInstance(); $customer = $subscriptionElement->getCustomer(); @@ -441,11 +732,98 @@ private function ensureUser(StripeSubscription $subscription, SubscriptionElemen } /** @var Customer|StripeCustomer $customer */ if ($customer->email) { - Craft::$app->getUsers()->ensureUserByEmail($customer->email); + $user = Craft::$app->getUsers()->ensureUserByEmail($customer->email); if ($syncCustomerData) { $plugin->getCustomers()->createOrUpdateCustomer($customer); } + + return $user; + } + + return null; + } + + /** + * Handles a Subscription changing status. + * + * @param Subscription $subscription + * @param string|null $oldStatus + */ + private function handleStatusChange(Subscription $subscription, ?string $oldStatus = null): void + { + $newStatus = $subscription->stripeStatus; + + // Did the status actually change? + if ($newStatus === $oldStatus) { + return; + } + + // Emit an event to allow plugins to suppress default handlers: + if ($this->hasEventHandlers(self::EVENT_SUBSCRIPTION_STATUS_CHANGE)) { + $event = new StripeSubscriptionStatusChangeEvent([ + 'subscription' => $subscription, + 'newStatus' => $newStatus, + 'oldStatus' => $oldStatus, + ]); + $this->trigger(self::EVENT_SUBSCRIPTION_STATUS_CHANGE, $event); + + // Bail now if a plugin indicates it has handled the status change: + if ($event->isValid) { + Craft::warning('A plugin prevented the normal subscription status change handlers from running.', 'stripe'); + $this->logActivity($subscription->id, Translation::prep('stripe', 'The subscription changed statuses, but handlers were skipped.')); + + return; + } } + + // We only care about transitions into some statuses. + if ($newStatus === StripeSubscription::STATUS_ACTIVE) { + if (in_array($oldStatus, [null, StripeSubscription::STATUS_INCOMPLETE])) { + // It just began, possibly after some billing trouble. + $this->grantGroupsForSubscription($subscription); + } else if ($oldStatus === StripeSubscription::STATUS_TRIALING) { + // The trial period is over. Nothing should change! + } else if (in_array($oldStatus, [StripeSubscription::STATUS_PAST_DUE, StripeSubscription::STATUS_UNPAID])) { + // Billing issues were resolved. This should undo anything + } + } else if ($newStatus === StripeSubscription::STATUS_TRIALING) { + if ($oldStatus === null) { + // The customer just started a trial. This is treated the same way as a new, active subscription: + $this->grantGroupsForSubscription($subscription); + } + } else if ($newStatus === StripeSubscription::STATUS_CANCELED) { + // Always revoke permissions: + $this->revokeGroupsForSubscription($subscription); + + if (in_array($oldStatus, [StripeSubscription::STATUS_ACTIVE, StripeSubscription::STATUS_TRIALING])) { + // The subscription ended while in good standing (but potentially before actually starting). + } else if (in_array($oldStatus, [StripeSubscription::STATUS_PAST_DUE, StripeSubscription::STATUS_UNPAID])) { + // The subscription ended with outstanding invoices. + } + } else if (in_array($newStatus, [StripeSubscription::STATUS_PAST_DUE, StripeSubscription::STATUS_UNPAID])) { + // We’re in a delinquent payment situation! + if ($oldStatus === StripeSubscription::STATUS_ACTIVE) { + // Should we revoke permissions while they sort out payment? + } + } + } + + /** + * Logs a message against the specified subscription. + * + * Messages should be prepared using {@see Translation::prep()} instead of {@see Craft::t()}, so that they can be displayed in the current user’s language. + * @param int $subscriptionId + * @param string $message + * @return bool + */ + private function logActivity(int $subscriptionId, string $message): bool + { + $rows = Db::insert(Table::SUBSCRIPTIONLOGS, [ + 'subscriptionId' => $subscriptionId, + 'message' => $message, + ]); + + return $rows > 0; } } diff --git a/src/services/Webhooks.php b/src/services/Webhooks.php index c086cbf..807fff8 100644 --- a/src/services/Webhooks.php +++ b/src/services/Webhooks.php @@ -80,9 +80,9 @@ public function processEvent($event): void case 'customer.subscription.created': // retrieve the subscription again as we need some expandable info too $subscription = $plugin->getApi()->fetchSubscriptionById($eventObject->id); - // get the unsaved draft for a subscription when subscription + // A draft may have been created at checkout, prior to redirection: $subscriptionElement = $plugin->getSubscriptions()->getUnsavedDraftByUid($subscription); - // proceed with creating the element + // Create the live/canonical element: $plugin->getSubscriptions()->createOrUpdateSubscriptionElement($subscription, $subscriptionElement); break; case 'customer.subscription.updated': diff --git a/src/templates/fieldlayoutelements/assignmentlogs.twig b/src/templates/fieldlayoutelements/assignmentlogs.twig new file mode 100644 index 0000000..2ef15b8 --- /dev/null +++ b/src/templates/fieldlayoutelements/assignmentlogs.twig @@ -0,0 +1,29 @@ +
+ {% if logs | length %} +
+

{{ 'Activity log'|t('stripe') }}

+ + + + + + + + + {% for log in logs %} + + + + + {% endfor %} + +
{{ 'Message'|t('app') }}{{ 'Date'|t('app') }}
{{ log.getLocalizedMessage() }}{{ log.dateCreated|date('long') }}
+
+ {% else %} +
+
+

{{ 'No logs exist for this subscription.' | t('stripe') }}

+
+
+ {% endif %} +
diff --git a/src/templates/fieldlayoutelements/usergroupassignmentsfield.twig b/src/templates/fieldlayoutelements/usergroupassignmentsfield.twig new file mode 100644 index 0000000..6505f33 --- /dev/null +++ b/src/templates/fieldlayoutelements/usergroupassignmentsfield.twig @@ -0,0 +1,8 @@ +{% include '_includes/forms/componentSelect.twig' with { + name: 'userGroupAssignmentIds[]', + options: craft.app.userGroups.getAllGroups(), + values: plan.getUserGroupAssignments(), + showHandles: true, + showDescription: true, + createAction: currentUser.admin and craft.app.config.general.allowAdminChanges ? 'user-settings/edit-group' : null, +} %} \ No newline at end of file diff --git a/src/translations/en/stripe.php b/src/translations/en/stripe.php index 2673a55..e0136d3 100644 --- a/src/translations/en/stripe.php +++ b/src/translations/en/stripe.php @@ -93,21 +93,21 @@ 'Stripe Edit' => 'Stripe Edit', 'Stripe ID' => 'Stripe ID', 'Stripe Price' => 'Stripe Price', - 'stripe price' => 'stripe price', + 'Stripe Price' => 'Stripe Price', + 'Stripe Prices' => 'Stripe Prices', 'Stripe Prices' => 'Stripe Prices', - 'stripe prices' => 'stripe prices', 'Stripe Product' => 'Stripe Product', - 'stripe product' => 'stripe product', + 'Stripe Product' => 'Stripe Product', 'Stripe Products, Prices, Subscriptions, Customers, Invoices and Payment Methods successfully synced' => 'Stripe Products, Prices, Subscriptions, Customers, Invoices and Payment Methods successfully synced', 'Stripe Products' => 'Stripe Products', - 'stripe products' => 'stripe products', + 'Stripe Products' => 'Stripe Products', 'Stripe Publishable Key' => 'Stripe Publishable Key', 'Stripe Secret Key' => 'Stripe Secret Key', 'Stripe Status' => 'Stripe Status', 'Stripe Subscription' => 'Stripe Subscription', - 'stripe subscription' => 'stripe subscription', + 'Stripe Subscription' => 'Stripe Subscription', + 'Stripe Subscriptions' => 'Stripe Subscriptions', 'Stripe Subscriptions' => 'Stripe Subscriptions', - 'stripe subscriptions' => 'stripe subscriptions', 'Stripe Sync All' => 'Stripe Sync All', 'Stripe Webhook Signing Secret' => 'Stripe Webhook Signing Secret', 'Stripe' => 'Stripe', From f99ada0dfb78296520e9ede1db6259b8afae7aa0 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Wed, 4 Mar 2026 18:37:23 +0800 Subject: [PATCH 2/4] Cleanup --- src/elements/Price.php | 6 +++--- src/elements/Subscription.php | 2 +- src/events/GrantGroupAssignmentEvent.php | 2 +- src/events/RevokeGroupAssignmentEvent.php | 2 +- src/fieldlayoutelements/AssignmentLogs.php | 2 +- ...806_201102_add_price_user_groups_table.php | 2 +- src/models/Message.php | 2 +- src/services/Subscriptions.php | 20 +++++++++---------- 8 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/elements/Price.php b/src/elements/Price.php index d53598a..6b95adf 100644 --- a/src/elements/Price.php +++ b/src/elements/Price.php @@ -496,7 +496,7 @@ public function defineRules(): array $rules[] = [['userGroupAssignmentIds'], 'safe']; $rules[] = [ ['userGroupAssignmentIds'], - function ($attribute, $params, $validator, $current) { + function($attribute, $params, $validator, $current) { $allGroupIds = ArrayHelper::getColumn(Craft::$app->getUserGroups()->getAllGroups(), 'id'); $selectedGroupIds = ArrayHelper::getColumn($this->getUserGroupAssignments(), 'id'); @@ -859,7 +859,7 @@ public function getUserGroupAssignments(): array } else { if ($this->id) { // Fetch them, if we have a source ID: - $groupIds = (new Query) + $groupIds = (new Query()) ->select(['groupId']) ->from([Table::PRICES_USERGROUPS]) ->where(['priceId' => $this->id]) @@ -870,7 +870,7 @@ public function getUserGroupAssignments(): array } // Turn those IDs into models, discarding any that didn’t resolve: - $groups = array_filter(array_map(function ($id) { + $groups = array_filter(array_map(function($id) { return Craft::$app->getUserGroups()->getGroupById((int)$id); }, $groupIds)); diff --git a/src/elements/Subscription.php b/src/elements/Subscription.php index 23fd7f4..db5f205 100644 --- a/src/elements/Subscription.php +++ b/src/elements/Subscription.php @@ -311,7 +311,7 @@ protected static function defineSources(string $context): array public static function eagerLoadingMap(array $sourceElements, string $handle): array|null|false { if ($handle === 'user') { - $map = array_map(function (Subscription $el) { + $map = array_map(function(Subscription $el) { return [ 'source' => $el->id, 'target' => $el->userId, diff --git a/src/events/GrantGroupAssignmentEvent.php b/src/events/GrantGroupAssignmentEvent.php index ddb84a9..0505baa 100644 --- a/src/events/GrantGroupAssignmentEvent.php +++ b/src/events/GrantGroupAssignmentEvent.php @@ -41,4 +41,4 @@ class GrantGroupAssignmentEvent extends CancelableEvent * @var UserGroup The user will be added to this group. */ public UserGroup $group; -} \ No newline at end of file +} diff --git a/src/events/RevokeGroupAssignmentEvent.php b/src/events/RevokeGroupAssignmentEvent.php index 91ec7e3..d63222b 100644 --- a/src/events/RevokeGroupAssignmentEvent.php +++ b/src/events/RevokeGroupAssignmentEvent.php @@ -42,4 +42,4 @@ class RevokeGroupAssignmentEvent extends CancelableEvent * @var UserGroup Group the subscriber will be added to. */ public UserGroup $group; -} \ No newline at end of file +} diff --git a/src/fieldlayoutelements/AssignmentLogs.php b/src/fieldlayoutelements/AssignmentLogs.php index eae0b8d..29be2c2 100644 --- a/src/fieldlayoutelements/AssignmentLogs.php +++ b/src/fieldlayoutelements/AssignmentLogs.php @@ -48,4 +48,4 @@ public function formHtml(?ElementInterface $element = null, bool $static = false 'canManagePrices' => Craft::$app->getUser()->getIdentity()->can('accessPlugin-stripe'), ]); } -} \ No newline at end of file +} diff --git a/src/migrations/m250806_201102_add_price_user_groups_table.php b/src/migrations/m250806_201102_add_price_user_groups_table.php index 4aacf89..fe130ba 100644 --- a/src/migrations/m250806_201102_add_price_user_groups_table.php +++ b/src/migrations/m250806_201102_add_price_user_groups_table.php @@ -3,8 +3,8 @@ namespace craft\stripe\migrations; use craft\db\Migration; -use craft\stripe\db\Table as StripeTable; use craft\db\Table as CraftTable; +use craft\stripe\db\Table as StripeTable; /** * m250806_201102_add_price_user_groups_table migration. diff --git a/src/models/Message.php b/src/models/Message.php index 9afc3a2..60da582 100644 --- a/src/models/Message.php +++ b/src/models/Message.php @@ -31,4 +31,4 @@ public function getLocalizedMessage(): string { return Translation::translate($this->message); } -} \ No newline at end of file +} diff --git a/src/services/Subscriptions.php b/src/services/Subscriptions.php index c14c78f..a426254 100644 --- a/src/services/Subscriptions.php +++ b/src/services/Subscriptions.php @@ -648,7 +648,7 @@ public function revokeGroupsForSubscription(Subscription $subscription, ?array $ $subscription->id, Translation::prep('stripe', 'A plugin prevented the user from being removed from group ID #{groupId} ({groupName}).', [ 'groupId' => $group->id, - 'groupName' => $group->name + 'groupName' => $group->name, ]) ); @@ -656,7 +656,7 @@ public function revokeGroupsForSubscription(Subscription $subscription, ?array $ } // Get current groups, and filter out this one: - $newGroups = array_filter($user->getGroups(), function ($g) use ($group) { + $newGroups = array_filter($user->getGroups(), function($g) use ($group) { if ($g->id === $group->id) { return false; } @@ -674,7 +674,7 @@ public function revokeGroupsForSubscription(Subscription $subscription, ?array $ $subscription->id, Translation::prep('stripe', 'The user was removed from group ID #{groupId} ({groupName}).', [ 'groupId' => $group->id, - 'groupName' => $group->name + 'groupName' => $group->name, ]) ); } @@ -690,7 +690,7 @@ public function revokeGroupsForSubscription(Subscription $subscription, ?array $ */ public function getLogs(Subscription $subscription): array { - $rows = (new Query) + $rows = (new Query()) ->from([Table::SUBSCRIPTIONLOGS]) ->select([ 'id', @@ -782,26 +782,26 @@ private function handleStatusChange(Subscription $subscription, ?string $oldStat if (in_array($oldStatus, [null, StripeSubscription::STATUS_INCOMPLETE])) { // It just began, possibly after some billing trouble. $this->grantGroupsForSubscription($subscription); - } else if ($oldStatus === StripeSubscription::STATUS_TRIALING) { + } elseif ($oldStatus === StripeSubscription::STATUS_TRIALING) { // The trial period is over. Nothing should change! - } else if (in_array($oldStatus, [StripeSubscription::STATUS_PAST_DUE, StripeSubscription::STATUS_UNPAID])) { + } elseif (in_array($oldStatus, [StripeSubscription::STATUS_PAST_DUE, StripeSubscription::STATUS_UNPAID])) { // Billing issues were resolved. This should undo anything } - } else if ($newStatus === StripeSubscription::STATUS_TRIALING) { + } elseif ($newStatus === StripeSubscription::STATUS_TRIALING) { if ($oldStatus === null) { // The customer just started a trial. This is treated the same way as a new, active subscription: $this->grantGroupsForSubscription($subscription); } - } else if ($newStatus === StripeSubscription::STATUS_CANCELED) { + } elseif ($newStatus === StripeSubscription::STATUS_CANCELED) { // Always revoke permissions: $this->revokeGroupsForSubscription($subscription); if (in_array($oldStatus, [StripeSubscription::STATUS_ACTIVE, StripeSubscription::STATUS_TRIALING])) { // The subscription ended while in good standing (but potentially before actually starting). - } else if (in_array($oldStatus, [StripeSubscription::STATUS_PAST_DUE, StripeSubscription::STATUS_UNPAID])) { + } elseif (in_array($oldStatus, [StripeSubscription::STATUS_PAST_DUE, StripeSubscription::STATUS_UNPAID])) { // The subscription ended with outstanding invoices. } - } else if (in_array($newStatus, [StripeSubscription::STATUS_PAST_DUE, StripeSubscription::STATUS_UNPAID])) { + } elseif (in_array($newStatus, [StripeSubscription::STATUS_PAST_DUE, StripeSubscription::STATUS_UNPAID])) { // We’re in a delinquent payment situation! if ($oldStatus === StripeSubscription::STATUS_ACTIVE) { // Should we revoke permissions while they sort out payment? From 53c13735bf542ecf3a08b7e71da3c69384deac9f Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Wed, 4 Mar 2026 18:41:15 +0800 Subject: [PATCH 3/4] Cleanup --- src/elements/Price.php | 2 +- src/elements/Subscription.php | 7 +++++-- src/fieldlayoutelements/AssignmentLogs.php | 2 ++ src/services/Subscriptions.php | 16 ++++++++-------- src/translations/en/stripe.php | 6 ------ 5 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/elements/Price.php b/src/elements/Price.php index 6b95adf..8a29ce8 100644 --- a/src/elements/Price.php +++ b/src/elements/Price.php @@ -119,7 +119,7 @@ class Price extends Element implements NestedElementInterface private ?array $_userGroupAssignments = null; /** - * @var int + * @var int[]|null */ private ?array $_userGroupAssignmentIds = null; diff --git a/src/elements/Subscription.php b/src/elements/Subscription.php index db5f205..7ffc466 100644 --- a/src/elements/Subscription.php +++ b/src/elements/Subscription.php @@ -9,6 +9,7 @@ use Craft; use craft\base\Element; +use craft\base\ElementInterface; use craft\elements\db\EagerLoadPlan; use craft\elements\User; use craft\enums\Color; @@ -311,7 +312,8 @@ protected static function defineSources(string $context): array public static function eagerLoadingMap(array $sourceElements, string $handle): array|null|false { if ($handle === 'user') { - $map = array_map(function(Subscription $el) { + $map = array_map(function(ElementInterface $el) { + /** @var Subscription $el */ return [ 'source' => $el->id, 'target' => $el->userId, @@ -631,6 +633,7 @@ public function setEagerLoadedElements(string $handle, array $elements, EagerLoa { switch ($plan->handle) { case 'user': + /** @var User[] $elements */ $this->setUser($elements[0]); break; default: @@ -747,7 +750,7 @@ public function getUser(): ?User } if ($this->userId) { - $user = Craft::$app->getUsers()->getUserById($this->_userId); + $user = Craft::$app->getUsers()->getUserById($this->userId); $this->setUser($user); diff --git a/src/fieldlayoutelements/AssignmentLogs.php b/src/fieldlayoutelements/AssignmentLogs.php index 29be2c2..8084056 100644 --- a/src/fieldlayoutelements/AssignmentLogs.php +++ b/src/fieldlayoutelements/AssignmentLogs.php @@ -10,6 +10,7 @@ use Craft; use craft\base\ElementInterface; use craft\fieldlayoutelements\BaseUiElement; +use craft\stripe\elements\Subscription; use craft\stripe\Plugin; /** @@ -43,6 +44,7 @@ protected function selectorIcon(): ?string */ public function formHtml(?ElementInterface $element = null, bool $static = false): ?string { + /** @var Subscription $element */ return Craft::$app->getView()->renderTemplate('stripe/fieldlayoutelements/assignmentlogs', [ 'logs' => Plugin::getInstance()->getSubscriptions()->getLogs($element), 'canManagePrices' => Craft::$app->getUser()->getIdentity()->can('accessPlugin-stripe'), diff --git a/src/services/Subscriptions.php b/src/services/Subscriptions.php index a426254..2476b31 100644 --- a/src/services/Subscriptions.php +++ b/src/services/Subscriptions.php @@ -162,9 +162,9 @@ public function syncAllSubscriptions(): void } // Remove any subscriptions that are no longer in Stripe just in case. - $deletableSubscriptionElements = Subscription::find()->stripeId(['not', $stripeIds])->all(); + $deletableSubscriptions = Subscription::find()->stripeId(['not', $stripeIds])->all(); - foreach ($deletableSubscriptionElements as $element) { + foreach ($deletableSubscriptions as $element) { Craft::$app->elements->deleteElement($element); } } @@ -218,11 +218,11 @@ public function createOrUpdateSubscription(StripeSubscription $subscription): bo try { // Find the subscription element or create one (now safely inside the lock) - /** @var SubscriptionElement|null $subscriptionElement */ - $subscriptionElement = SubscriptionElement::find() + /** @var Subscription|null $subscriptionElement */ + $subscriptionElement = Subscription::find() ->stripeId($subscription->id) ->status(null) - ->one() ?? new SubscriptionElement(); + ->one() ?? new Subscription(); return $this->createOrUpdateSubscriptionElement($subscription, $subscriptionElement, false); } finally { @@ -234,12 +234,12 @@ public function createOrUpdateSubscription(StripeSubscription $subscription): bo * Takes the Stripe subscription data from the API a Subscription element and updates the element with the data. * * @param StripeSubscription $subscription - * @param SubscriptionElement $subscriptionElement + * @param Subscription $subscriptionElement * @param bool $acquireLock Whether to acquire a mutex lock (set to false if caller already holds the lock) * @return bool Whether the synchronization succeeded. * @since 1.2 */ - public function createOrUpdateSubscriptionElement(StripeSubscription $subscription, SubscriptionElement $subscriptionElement, bool $acquireLock = true): bool + public function createOrUpdateSubscriptionElement(StripeSubscription $subscription, Subscription $subscriptionElement, bool $acquireLock = true): bool { // Duplicates seem to be possible: https://github.com/craftcms/stripe/issues/44 $lockKey = "stripe-subscription:$subscription->id"; @@ -593,7 +593,7 @@ public function revokeGroupsForSubscription(Subscription $subscription, ?array $ foreach ($sub->getPrices() as $price) { /** @var Price $price */ - array_merge($subGroups, $price->getUserGroupAssignments()); + $subGroups = array_merge($subGroups, $price->getUserGroupAssignments()); } return array_merge($groups, $subGroups); diff --git a/src/translations/en/stripe.php b/src/translations/en/stripe.php index e0136d3..12e40b0 100644 --- a/src/translations/en/stripe.php +++ b/src/translations/en/stripe.php @@ -93,20 +93,14 @@ 'Stripe Edit' => 'Stripe Edit', 'Stripe ID' => 'Stripe ID', 'Stripe Price' => 'Stripe Price', - 'Stripe Price' => 'Stripe Price', - 'Stripe Prices' => 'Stripe Prices', 'Stripe Prices' => 'Stripe Prices', 'Stripe Product' => 'Stripe Product', - 'Stripe Product' => 'Stripe Product', 'Stripe Products, Prices, Subscriptions, Customers, Invoices and Payment Methods successfully synced' => 'Stripe Products, Prices, Subscriptions, Customers, Invoices and Payment Methods successfully synced', 'Stripe Products' => 'Stripe Products', - 'Stripe Products' => 'Stripe Products', 'Stripe Publishable Key' => 'Stripe Publishable Key', 'Stripe Secret Key' => 'Stripe Secret Key', 'Stripe Status' => 'Stripe Status', 'Stripe Subscription' => 'Stripe Subscription', - 'Stripe Subscription' => 'Stripe Subscription', - 'Stripe Subscriptions' => 'Stripe Subscriptions', 'Stripe Subscriptions' => 'Stripe Subscriptions', 'Stripe Sync All' => 'Stripe Sync All', 'Stripe Webhook Signing Secret' => 'Stripe Webhook Signing Secret', From 52c5e0967f58f09681207d22d0f85804d9c7509b Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Wed, 4 Mar 2026 18:54:40 +0800 Subject: [PATCH 4/4] cleanup --- CHANGELOG-WIP.md | 2 +- CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index c8c9840..8916cff 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -3,5 +3,5 @@ ## 2.0.0 ### Changed -- Updated Stripe API version from `2024-04-10` to `2026-01-28.clover`. +- Updated Stripe API version from `2024-04-10` to `2026-01-28.clover`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 20ce3c4..8871db1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 1.7.1 - 2026-01-19 -- Fixed typo in the Readme file. +- Fixed typo in the Readme file. - Fixed XSS vulnerabilities. ## 1.7.0 - 2025-12-11