Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Billing Portal support #2

Merged
merged 16 commits into from
Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 29 additions & 14 deletions src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
use craft\stripe\jobs\SyncData;
use craft\stripe\models\Settings;
use craft\stripe\services\Api;
use craft\stripe\services\BillingPortal;
use craft\stripe\services\Checkout;
use craft\stripe\services\Customers;
use craft\stripe\services\Invoices;
Expand Down Expand Up @@ -117,6 +118,7 @@
return [
'components' => [
'api' => ['class' => Api::class],
'billingPortal' => ['class' => BillingPortal::class],
'checkout' => ['class' => Checkout::class],
'customers' => ['class' => Customers::class],
'invoices' => ['class' => Invoices::class],
Expand Down Expand Up @@ -156,7 +158,7 @@
$this->registerSiteRoutes();
}
}
//

$projectConfigService = Craft::$app->getProjectConfig();
$productsService = $this->getProducts();
$pricesService = $this->getPrices();
Expand Down Expand Up @@ -218,6 +220,17 @@
return $this->get('api');
}

/**
* Returns the billing portal service
*
* @return BillingPortal The billing portal service
* @throws InvalidConfigException
*/
public function getBillingPortal(): BillingPortal
{
return $this->get('billingPortal');
}

/**
* Returns the Prices service
*
Expand Down Expand Up @@ -336,27 +349,29 @@

private function registerUserEditScreens(): void
{
Event::on(UsersController::class, UsersController::EVENT_DEFINE_EDIT_SCREENS, function(DefineEditUserScreensEvent $event) {

Check failure on line 352 in src/Plugin.php

View workflow job for this annotation

GitHub Actions / ci / Code Quality / PHPStan / PHPStan

Access to undefined constant craft\controllers\UsersController::EVENT_DEFINE_EDIT_SCREENS.

Check failure on line 352 in src/Plugin.php

View workflow job for this annotation

GitHub Actions / ci / Code Quality / PHPStan / PHPStan

Parameter $event of anonymous function has invalid type craft\events\DefineEditUserScreensEvent.
$event->screens['stripe'] = [

Check failure on line 353 in src/Plugin.php

View workflow job for this annotation

GitHub Actions / ci / Code Quality / PHPStan / PHPStan

Access to property $screens on an unknown class craft\events\DefineEditUserScreensEvent.

Check failure on line 353 in src/Plugin.php

View workflow job for this annotation

GitHub Actions / ci / Code Quality / PHPStan / PHPStan

Access to property $screens on an unknown class craft\events\DefineEditUserScreensEvent.
'label' => Craft::t('stripe', 'Stripe'),
];
});

Event::on(User::class, User::EVENT_DEFINE_METADATA, function(DefineMetadataEvent $event) {
$event->metadata[Craft::t('stripe', 'Stripe Customer(s)')] = function() use ($event) {
return $event->sender->getStripeCustomers()->reduce(function($carry, $item) {
$carry = is_string($carry) ?: '';
$carry .=
Html::beginTag('div') .
Html::tag(
'a',
$item->data['name'] . ' (' . $item->email . ')' . Html::tag('span', '', ['data-icon' => 'external']),
['href' => $item->getStripeEditUrl(), 'target' => '_blank']
) .
Html::endTag('div');

return $carry;
});
return Html::beginTag('div') .
$event->sender->getStripeCustomers()->reduce(function($carry, $item) {
$carry = is_string($carry) ? $carry : '';
$carry .=
Html::beginTag('div') .
Html::tag(
'a',
$item->data['name'] . ' (' . $item->stripeId . ')' . Html::tag('span', '', ['data-icon' => 'external']),
['href' => $item->getStripeEditUrl(), 'target' => '_blank']
) .
Html::endTag('div');

return $carry;
}) .
Html::endTag('div');
};
});
}
Expand Down Expand Up @@ -530,7 +545,7 @@
$oldEmail = $userRecord->getOldAttribute('email');
$newEmail = $userRecord->getAttribute('email');
if ($oldEmail != $newEmail) {
$customers = $user->getStripeCustomers();

Check failure on line 548 in src/Plugin.php

View workflow job for this annotation

GitHub Actions / ci / Code Quality / PHPStan / PHPStan

Call to an undefined method craft\elements\User::getStripeCustomers().
if (!empty($customers)) {
$client = $this->getApi()->getClient();
foreach ($customers as $customer) {
Expand All @@ -545,7 +560,7 @@
// kick off queue job to sync customer-related data
Event::on(User::class, User::EVENT_AFTER_SAVE, function(ModelEvent $event) {
$user = $event->sender;
if (!empty($user->email) && $user->getStripeCustomers()->isEmpty()) {

Check failure on line 563 in src/Plugin.php

View workflow job for this annotation

GitHub Actions / ci / Code Quality / PHPStan / PHPStan

Call to an undefined method object::getStripeCustomers().
// search for customer in Stripe by their email address
$stripe = $this->getApi()->getClient();
$stripeCustomers = $stripe->customers->search(['query' => "email:'{$user->email}'"]);
Expand Down
59 changes: 59 additions & 0 deletions src/behaviors/StripeCustomerBehavior.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
namespace craft\stripe\behaviors;

use craft\elements\User;
use craft\errors\SiteNotFoundException;
use craft\helpers\ArrayHelper;
use craft\helpers\UrlHelper;
use craft\stripe\elements\Subscription;
use craft\stripe\models\Customer;
use craft\stripe\models\PaymentMethod;
Expand Down Expand Up @@ -53,6 +56,15 @@ public function attach($owner)
parent::attach($owner);
}

/**
* @return Customer|null
* @throws InvalidConfigException
*/
public function getStripeCustomer(): ?Customer
{
return $this->getStripeCustomers()->first();
}

/**
* @return Collection<Customer>
* @throws InvalidConfigException
Expand Down Expand Up @@ -111,4 +123,51 @@ public function getStripePaymentMethods(): Collection

return $this->_paymentMethods ?? new Collection();
}

/**
* @param string|null $configurationId
* @param string|null $returnUrl
* @param array $params
* @return string|null
* @throws InvalidConfigException
* @throws SiteNotFoundException
*/
public function getStripeBillingPortalSessionUrl(?string $configurationId = null, ?string $returnUrl = null, array $params = []): ?string
{
$customer = $this->getStripeCustomer();

if ($customer === null) {
return null;
}

return Plugin::getInstance()->getBillingPortal()->getCustomerBillingPortalSessionUrl($customer, $configurationId, $returnUrl, $params);
}

/**
* @param string|null $configurationId
* @param string|null $returnUrl
* @param array $params
* @return string|null
* @throws InvalidConfigException
* @throws SiteNotFoundException
* @throws \Throwable
* @throws \yii\base\Exception
*/
public function getStripeBillingPortalSessionPaymentMethodUpdateUrl(?string $configurationId = null, ?string $returnUrl = null, array $params = []): ?string
{
$customer = $this->getStripeCustomer();
if (!$customer) {
return null;
}

$returnUrl = $returnUrl ? UrlHelper::siteUrl($returnUrl) : UrlHelper::siteUrl();

$params = ArrayHelper::merge([
'flow_data' => [
'type' => 'payment_method_update',
],
], $params);

return Plugin::getInstance()->getBillingPortal()->getCustomerBillingPortalSessionUrl($customer, $configurationId, $returnUrl, $params);
}
}
16 changes: 12 additions & 4 deletions src/controllers/CheckoutController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
use Craft;
use craft\stripe\Plugin;
use craft\web\Controller;
use yii\base\InvalidConfigException;
use yii\web\BadRequestHttpException;
use yii\web\MethodNotAllowedHttpException;
use yii\web\Response;

/**
Expand All @@ -19,8 +22,13 @@
*/
class CheckoutController extends Controller
{

/**
*
* @return Response
* @throws \Throwable
* @throws InvalidConfigException
* @throws BadRequestHttpException
* @throws MethodNotAllowedHttpException
*/
public function actionCheckout(): Response
{
Expand All @@ -38,12 +46,12 @@
return $this->asFailure(Craft::t('stripe', 'Please specify the quantity'));
}

$successUrl = $request->getBodyParam('successUrl', null);
$cancelUrl = $request->getBodyParam('cancelUrl', null);
$params = $request->getBodyParam('params', null);
$successUrl = $request->getValidatedBodyParam('successUrl', null);

Check failure on line 49 in src/controllers/CheckoutController.php

View workflow job for this annotation

GitHub Actions / ci / Code Quality / PHPStan / PHPStan

Method craft\web\Request::getValidatedBodyParam() invoked with 2 parameters, 1 required.
$cancelUrl = $request->getValidatedBodyParam('cancelUrl', null);

Check failure on line 50 in src/controllers/CheckoutController.php

View workflow job for this annotation

GitHub Actions / ci / Code Quality / PHPStan / PHPStan

Method craft\web\Request::getValidatedBodyParam() invoked with 2 parameters, 1 required.
$params = $request->getValidatedBodyParam('params', null);

Check failure on line 51 in src/controllers/CheckoutController.php

View workflow job for this annotation

GitHub Actions / ci / Code Quality / PHPStan / PHPStan

Method craft\web\Request::getValidatedBodyParam() invoked with 2 parameters, 1 required.

// start checkout session
$url = Plugin::getInstance()->getCheckout()->getCheckoutUrl($lineItems, $currentUser, $successUrl, $cancelUrl, $params);

Check failure on line 54 in src/controllers/CheckoutController.php

View workflow job for this annotation

GitHub Actions / ci / Code Quality / PHPStan / PHPStan

Parameter #5 $params of method craft\stripe\services\Checkout::getCheckoutUrl() expects array|null, string|null given.

return $this->redirect($url);
}
Expand Down
70 changes: 69 additions & 1 deletion src/elements/Subscription.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
use Craft;
use craft\base\Element;
use craft\elements\User;
use craft\errors\SiteNotFoundException;
use craft\helpers\ArrayHelper;
use craft\helpers\Cp;
use craft\helpers\Html;
use craft\helpers\Json;
Expand All @@ -22,6 +24,8 @@
use craft\stripe\Plugin;
use craft\stripe\records\Subscription as SubscriptionRecord;
use craft\stripe\web\assets\stripecp\StripeCpAsset;
use yii\base\Exception;
use yii\base\InvalidConfigException;
use DateTime;

/**
Expand Down Expand Up @@ -101,7 +105,7 @@ class Subscription extends Element
* @var DateTime|null
*/
public ?\DateTime $trialEnd = null;

/**
* @var array|null
*/
Expand Down Expand Up @@ -561,4 +565,68 @@ public function setCustomer(Customer $customer): void
{
$this->_customer = $customer;
}

/**
* Returns the URL to update the subscription in the billing portal.
*
* @param string|null $returnUrl
* @param array $params
* @return string|null
* @throws \Throwable
* @throws SiteNotFoundException
* @throws Exception
* @throws InvalidConfigException
*/
public function getBillingPortalSessionUpdateUrl(
?string $returnUrl = null,
array $params = [],
): ?string {
$returnUrl = $returnUrl ? UrlHelper::siteUrl($returnUrl) : UrlHelper::siteUrl();
if ($this->status !== self::STATUS_LIVE) {
return '';
}

$params = ArrayHelper::merge([
'flow_data' => [
'type' => 'subscription_update',
'subscription_update' => [
'subscription' => $this->stripeId,
],
],
], $params);

return Plugin::getInstance()->getBillingPortal()->getSessionUrl($this->customerId, null, $returnUrl, $params);
}

/**
* Returns the URL to cancel the subscription in the billing portal.
*
* @param string|null $returnUrl
* @param array $params
* @return string|null
* @throws Exception
* @throws InvalidConfigException
* @throws SiteNotFoundException
* @throws \Throwable
*/
public function getBillingPortalSessionCancelUrl(
?string $returnUrl = null,
array $params = [],
): ?string {
$returnUrl = $returnUrl ? UrlHelper::siteUrl($returnUrl) : UrlHelper::siteUrl();
if ($this->status !== self::STATUS_LIVE) {
return '';
}

$params = ArrayHelper::merge([
'flow_data' => [
'type' => 'subscription_cancel',
'subscription_cancel' => [
'subscription' => $this->stripeId,
],
],
], $params);

return Plugin::getInstance()->getBillingPortal()->getSessionUrl($this->customerId, null, $returnUrl, $params);
}
}
23 changes: 23 additions & 0 deletions src/events/BillingPortalSessionEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php
/**
* @link https://craftcms.com/
* @copyright Copyright (c) Pixel & Tonic, Inc.
* @license MIT
*/

namespace craft\stripe\events;

use yii\base\Event;

/**
* Class BillingPortalSessionEvent
*
* @author Pixel & Tonic, Inc. <[email protected]>
*/
class BillingPortalSessionEvent extends Event
{
/**
* @var array|null Modify the params to use to instantiate the billing portal session with
*/
public ?array $params = null;
}
1 change: 1 addition & 0 deletions src/migrations/Install.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ public function createTables(): void
'id' => $this->primaryKey(),
'stripeId' => $this->string()->notNull(),
'email' => $this->string()->notNull(),
'stripeCreated' => $this->dateTime()->notNull(),
'data' => $this->json(),
'dateCreated' => $this->dateTime()->notNull(),
'dateUpdated' => $this->dateTime()->notNull(),
Expand Down
18 changes: 6 additions & 12 deletions src/models/Customer.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

use craft\stripe\base\Model;
use craft\stripe\Plugin;
use DateTime;

/**
* Stripe customer model
Expand All @@ -22,6 +23,11 @@ class Customer extends Model
*/
public ?string $email = null;

/**
* @var ?DateTime The customer creation date in Stripe
*/
public ?DateTime $stripeCreated = null;

/**
* @var array|string[] Array of params that should be expanded when fetching Customer from the Stripe API
*/
Expand All @@ -36,16 +42,4 @@ public function getStripeEditUrl(): string
{
return Plugin::getInstance()->stripeBaseUrl . "/customers/{$this->stripeId}";
}

// /**
// * @inheritdoc
// */
// protected function defineRules(): array
// {
// $rules = parent::defineRules();
// $rules[] = [['reference'], UniqueValidator::class, 'targetClass' => CustomerRecord::class];
// $rules[] = [['gatewayId', 'userId', 'reference', 'data'], 'required'];
//
// return $rules;
// }
}
1 change: 0 additions & 1 deletion src/services/Api.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,6 @@ public function fetchAllSubscriptions(): array
public function fetchSubscriptionById(string $id): StripeSubscription
{
return $this->fetchOne($id, 'subscriptions', [
'status' => 'all',
'expand' => Subscription::$expandParams,
]);
}
Expand Down
Loading
Loading