tipo: modificación del cpanel y gitignore de la carpeta vendor

This commit is contained in:
2026-07-07 11:35:09 -06:00
parent a0c91dc567
commit 676ef0d506
8510 changed files with 1132803 additions and 4 deletions
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Laravel\Paddle;
use Laravel\Paddle\Concerns\ManagesCustomer;
use Laravel\Paddle\Concerns\ManagesSubscriptions;
use Laravel\Paddle\Concerns\ManagesTransactions;
use Laravel\Paddle\Concerns\PerformsCharges;
trait Billable
{
use ManagesCustomer;
use ManagesSubscriptions;
use ManagesTransactions;
use PerformsCharges;
}
+398
View File
@@ -0,0 +1,398 @@
<?php
namespace Laravel\Paddle;
use Exception;
use Illuminate\Support\Facades\Http;
use Laravel\Paddle\Exceptions\PaddleException;
use Money\Currencies\ISOCurrencies;
use Money\Currency;
use Money\Formatter\IntlMoneyFormatter;
use Money\Money;
use NumberFormatter;
class Cashier
{
const VERSION = '2.6.3';
/**
* The custom currency formatter.
*
* @var callable
*/
protected static $formatCurrencyUsing;
/**
* Indicates if Cashier routes will be registered.
*
* @var bool
*/
public static $registersRoutes = true;
/**
* Indicates if Cashier will mark past due subscriptions as invalid.
*
* @var bool
*/
public static $deactivatePastDue = true;
/**
* The customer model class name.
*
* @var string
*/
public static $customerModel = Customer::class;
/**
* The subscription model class name.
*
* @var string
*/
public static $subscriptionModel = Subscription::class;
/**
* The subscription item model class name.
*
* @var string
*/
public static $subscriptionItemModel = SubscriptionItem::class;
/**
* The transaction model class name.
*
* @var string
*/
public static $transactionModel = Transaction::class;
/**
* Preview prices for a given set of items.
*
* @param array|string $items
* @param array $options
* @return \Illuminate\Support\Collection
*/
public static function previewPrices($items, array $options = [])
{
$items = static::api('POST', 'pricing-preview', array_merge([
'items' => static::normalizeItems($items),
], $options))['data']['details']['line_items'];
return collect($items)->map(function (array $item) {
return new PricePreview($item);
});
}
/**
* Get the customer instance by its Paddle customer ID.
*
* @param string $customerId
* @return \Laravel\Paddle\Billable|null
*/
public static function findBillable($customerId)
{
return (new static::$customerModel)->where('paddle_id', $customerId)->first()?->billable;
}
/**
* Get the Paddle webhook url.
*
* @return string
*/
public static function webhookUrl()
{
return config('cashier.webhook') ?? route('cashier.webhook');
}
/**
* Perform a Paddle API call.
*
* @param string $method
* @param string $uri
* @param array|null $payload
* @return \Illuminate\Http\Client\Response
*
* @throws \Laravel\Paddle\Exceptions\PaddleException
*/
public static function api($method, $uri, ?array $payload = null)
{
if (empty($apiKey = config('cashier.api_key', config('cashier.auth_code')))) {
throw new Exception('Paddle API key not set.');
}
$host = static::apiUrl();
/** @var \Illuminate\Http\Client\Response $response */
$response = Http::withToken($apiKey)
->withUserAgent('Laravel\Paddle/'.static::VERSION)
->withHeaders(['Paddle-Version' => 1])
->$method("{$host}/{$uri}", $payload);
if (isset($response['error'])) {
$message = "Paddle API error '{$response['error']['detail']}' occurred";
if (isset($response['error']['errors'])) {
$message .= ' with validation errors ('.json_encode($response['error']['errors']).')';
}
throw (new PaddleException($message))->setError($response['error']);
}
return $response;
}
/**
* Get the Paddle API url.
*
* @return string
*/
public static function apiUrl()
{
return 'https://'.(config('cashier.sandbox') ? 'sandbox-' : '').'api.paddle.com';
}
/**
* Normalize the given items to a Paddle accepted format.
*
* @param array|string $items
* @param string $priceKey
* @return array
*/
public static function normalizeItems($items, string $priceKey = 'price_id'): array
{
return collect($items)->map(function ($item, $key) use ($priceKey) {
if (is_array($item)) {
return $item;
}
if (is_string($key)) {
return [
$priceKey => $key,
'quantity' => $item,
];
}
return [
$priceKey => $item,
'quantity' => 1,
];
})->values()->all();
}
/**
* Set the custom currency formatter.
*
* @param callable $callback
* @return void
*/
public static function formatCurrencyUsing(callable $callback)
{
static::$formatCurrencyUsing = $callback;
}
/**
* Format the given amount into a displayable currency.
*
* @param int $amount
* @param string $currency
* @param string|null $locale
* @param array $options
* @return string
*/
public static function formatAmount($amount, $currency, $locale = null, array $options = [])
{
if (static::$formatCurrencyUsing) {
return call_user_func(static::$formatCurrencyUsing, $amount, $currency, $locale, $options);
}
$money = new Money($amount, new Currency(strtoupper($currency)));
$locale = $locale ?? config('cashier.currency_locale');
$numberFormatter = new NumberFormatter($locale, NumberFormatter::CURRENCY);
if (isset($options['min_fraction_digits'])) {
$numberFormatter->setAttribute(NumberFormatter::MIN_FRACTION_DIGITS, $options['min_fraction_digits']);
}
$moneyFormatter = new IntlMoneyFormatter($numberFormatter, new ISOCurrencies());
return $moneyFormatter->format($money);
}
/**
* Determine if the given currency uses cents.
*
* @param \Money\Currency $currency
* @return bool
*/
public static function currencyUsesCents(Currency $currency)
{
return ! in_array($currency->getCode(), ['JPY', 'KRW'], true);
}
/**
* Configure Cashier to not register its routes.
*
* @return static
*/
public static function ignoreRoutes()
{
static::$registersRoutes = false;
return new static;
}
/**
* Configure Cashier to maintain past due subscriptions as active.
*
* @return static
*/
public static function keepPastDueSubscriptionsActive()
{
static::$deactivatePastDue = false;
return new static;
}
/**
* Set the customer model class name.
*
* @param string $customerModel
* @return void
*/
public static function useCustomerModel($customerModel)
{
static::$customerModel = $customerModel;
}
/**
* Set the subscription model class name.
*
* @param string $subscriptionModel
* @return void
*/
public static function useSubscriptionModel($subscriptionModel)
{
static::$subscriptionModel = $subscriptionModel;
}
/**
* Set the subscription item model class name.
*
* @param string $subscriptionItemModel
* @return void
*/
public static function useSubscriptionItemModel($subscriptionItemModel)
{
static::$subscriptionItemModel = $subscriptionItemModel;
}
/**
* Set the transaction model class name.
*
* @param string $transactionModel
* @return void
*/
public static function useTransactionModel($transactionModel)
{
static::$transactionModel = $transactionModel;
}
/**
* Create a fake Cashier instance.
*
* @return \Laravel\Paddle\CashierFake
*/
public static function fake(...$arguments)
{
return CashierFake::fake(...$arguments);
}
/**
* Pass-thru to the CashierFake method of the same name.
*
* @param callable|int|null $callback
* @return void
*/
public static function assertCustomerUpdated($callback = null)
{
CashierFake::assertCustomerUpdated($callback);
}
/**
* Pass-thru to the CashierFake method of the same name.
*
* @param callable|int|null $callback
* @return void
*/
public static function assertTransactionCompleted($callback = null)
{
CashierFake::assertTransactionCompleted($callback);
}
/**
* Pass-thru to the CashierFake method of the same name.
*
* @param callable|int|null $callback
* @return void
*/
public static function assertTransactionUpdated($callback = null)
{
CashierFake::assertTransactionUpdated($callback);
}
/**
* Pass-thru to the CashierFake method of the same name.
*
* @param callable|int|null $callback
* @return void
*/
public static function assertSubscriptionCreated($callback = null)
{
CashierFake::assertSubscriptionCreated($callback);
}
/**
* Pass-thru to the CashierFake method of the same name.
*
* @param callable|int|null $callback
* @return void
*/
public static function assertSubscriptionNotCreated($callback = null)
{
CashierFake::assertSubscriptionNotCreated($callback);
}
/**
* Pass-thru to the CashierFake method of the same name.
*
* @param callable|int|null $callback
* @return void
*/
public static function assertSubscriptionUpdated($callback = null)
{
CashierFake::assertSubscriptionUpdated($callback);
}
/**
* Pass-thru to the CashierFake method of the same name.
*
* @param callable|int|null $callback
* @return void
*/
public static function assertSubscriptionCanceled($callback = null)
{
CashierFake::assertSubscriptionCanceled($callback);
}
/**
* Pass-thru to the CashierFake method of the same name.
*
* @param callable|int|null $callback
* @return void
*/
public static function assertSubscriptionPaused($callback = null)
{
CashierFake::assertSubscriptionPaused($callback);
}
}
+218
View File
@@ -0,0 +1,218 @@
<?php
namespace Laravel\Paddle;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Laravel\Paddle\Events\CustomerUpdated;
use Laravel\Paddle\Events\SubscriptionCanceled;
use Laravel\Paddle\Events\SubscriptionCreated;
use Laravel\Paddle\Events\SubscriptionPaused;
use Laravel\Paddle\Events\SubscriptionUpdated;
use Laravel\Paddle\Events\TransactionCompleted;
use Laravel\Paddle\Events\TransactionUpdated;
class CashierFake
{
/**
* The array of callbacks for each response.
*
* @var array
*/
protected $responses = [];
/**
* Initialize the fake instance and fake Cashier's events and API calls.
*
* @param array $endpoints
* @param string|array $events
* @return void
*/
public function __construct(array $endpoints = [], $events = [])
{
foreach ($endpoints as $endpoint => $response) {
if (! Arr::isAssoc($endpoints)) {
$endpoint = $response;
$response = null;
}
$this->fakeHttpResponse($endpoint, Arr::wrap($response));
}
Event::fake(array_merge([
CustomerUpdated::class,
TransactionCompleted::class,
TransactionUpdated::class,
SubscriptionCreated::class,
SubscriptionUpdated::class,
SubscriptionCanceled::class,
SubscriptionPaused::class,
], Arr::wrap($events)));
}
/**
* Syntactic sugar for the constructor.
*
* @return static
*/
public static function fake(...$arguments)
{
return new static(...$arguments);
}
/**
* Set the successful response for a given endpoint.
*
* @param string $endpoint
* @param array $data
* @return self
*/
public function response(string $endpoint, array $data)
{
$this->fakeHttpResponse($endpoint, [
'data' => $data,
]);
return $this;
}
/**
* Set an error response for a given endpoint.
*
* @param string $endpoint
* @param string $message
* @param int $code
* @return self
*
* @see https://developer.paddle.com/api-reference/ZG9jOjI1MzUzOTkw-api-error-codes
*/
public function error(string $endpoint, $message = '', $code = 0)
{
$this->fakeHttpResponse($endpoint, [
'error' => ['detail' => $message],
]);
return $this;
}
/**
* Fake the given endpoint with the provided response.
*
* @param string $endpoint
* @param mixed $response
* @return void
*/
protected function fakeHttpResponse(string $endpoint, $response)
{
$notFaked = ! Arr::exists($this->responses, $endpoint);
$this->responses[$endpoint] = $response;
if ($notFaked) {
Http::fake([static::getFormattedApiUrl($endpoint) => function () use ($endpoint) {
return $this->responses[$endpoint];
}]);
}
}
/**
* Format the given path into a full API url.
*
* @param string $path
* @return string
*/
public static function getFormattedApiUrl(string $path): string
{
return Cashier::apiUrl().Str::start($path, '/');
}
/**
* Assert if the CustomerUpdated event was dispatched based on a truth-test callback.
*
* @param callable|int|null $callback
* @return void
*/
public static function assertCustomerUpdated($callback = null)
{
Event::assertDispatched(CustomerUpdated::class, $callback);
}
/**
* Assert if the TransactionCompleted event was dispatched based on a truth-test callback.
*
* @param callable|int|null $callback
* @return void
*/
public static function assertTransactionCompleted($callback = null)
{
Event::assertDispatched(TransactionCompleted::class, $callback);
}
/**
* Assert if the TransactionUpdated event was dispatched based on a truth-test callback.
*
* @param callable|int|null $callback
* @return void
*/
public static function assertTransactionUpdated($callback = null)
{
Event::assertDispatched(TransactionUpdated::class, $callback);
}
/**
* Assert if the SubscriptionCreated event was dispatched based on a truth-test callback.
*
* @param callable|int|null $callback
* @return void
*/
public static function assertSubscriptionCreated($callback = null)
{
Event::assertDispatched(SubscriptionCreated::class, $callback);
}
/**
* Assert if the SubscriptionCreated event was not dispatched based on a truth-test callback.
*
* @param callable|int|null $callback
* @return void
*/
public static function assertSubscriptionNotCreated($callback = null)
{
Event::assertNotDispatched(SubscriptionCreated::class, $callback);
}
/**
* Assert if the SubscriptionUpdated event was dispatched based on a truth-test callback.
*
* @param callable|int|null $callback
* @return void
*/
public static function assertSubscriptionUpdated($callback = null)
{
Event::assertDispatched(SubscriptionUpdated::class, $callback);
}
/**
* Assert if the SubscriptionCanceled event was dispatched based on a truth-test callback.
*
* @param callable|int|null $callback
* @return void
*/
public static function assertSubscriptionCanceled($callback = null)
{
Event::assertDispatched(SubscriptionCanceled::class, $callback);
}
/**
* Assert if the SubscriptionPaused event was dispatched based on a truth-test callback.
*
* @param callable|int|null $callback
* @return void
*/
public static function assertSubscriptionPaused($callback = null)
{
Event::assertDispatched(SubscriptionPaused::class, $callback);
}
}
@@ -0,0 +1,113 @@
<?php
namespace Laravel\Paddle;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Laravel\Paddle\Components\Button;
use Laravel\Paddle\Components\Checkout;
class CashierServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
$this->mergeConfigFrom(
__DIR__.'/../config/cashier.php', 'cashier'
);
}
/**
* Bootstrap any package services.
*
* @return void
*/
public function boot()
{
$this->bootRoutes();
$this->bootResources();
$this->bootPublishing();
$this->bootDirectives();
$this->bootComponents();
}
/**
* Boot the package routes.
*
* @return void
*/
protected function bootRoutes()
{
if (Cashier::$registersRoutes) {
Route::group([
'prefix' => config('cashier.path'),
'namespace' => 'Laravel\Paddle\Http\Controllers',
'as' => 'cashier.',
], function () {
$this->loadRoutesFrom(__DIR__.'/../routes/web.php');
});
}
}
/**
* Boot the package resources.
*
* @return void
*/
protected function bootResources()
{
$this->loadViewsFrom(__DIR__.'/../resources/views', 'cashier');
}
/**
* Boot the package's publishable resources.
*
* @return void
*/
protected function bootPublishing()
{
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__.'/../config/cashier.php' => $this->app->configPath('cashier.php'),
], 'cashier-config');
$this->publishes([
__DIR__.'/../database/migrations' => $this->app->databasePath('migrations'),
], 'cashier-migrations');
$this->publishes([
__DIR__.'/../resources/views' => $this->app->resourcePath('views/vendor/cashier'),
], 'cashier-views');
}
}
/**
* Boot the package directives.
*
* @return void
*/
protected function bootDirectives()
{
Blade::directive('paddleJS', function ($expression) {
$expression = $expression ?: '[]';
return '<?php echo view("cashier::js", ["nonce" => '.$expression.'["nonce"] ?? ""]); ?>';
});
}
/**
* Boot the package components.
*
* @return void
*/
protected function bootComponents()
{
Blade::component(Button::class, 'paddle-button');
Blade::component(Checkout::class, 'paddle-checkout');
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
namespace Laravel\Paddle;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
use LogicException;
class Checkout implements Arrayable, JsonSerializable
{
/**
* The custom data for the checkout.
*/
protected array $custom = [];
/**
* The URL which the customer will be returned to after starting the subscription.
*/
protected ?string $returnTo = null;
/**
* Create a new checkout instance.
*/
public function __construct(
protected ?Customer $customer,
protected array $items = [],
protected array $transaction = []
) {
$this->items = Cashier::normalizeItems($items, 'priceId');
}
/**
* Create a new checkout instance for a guest.
*/
public static function guest(array $items = []): self
{
return new static(null, $items);
}
/**
* Create a new checkout instance for an existing customer.
*/
public static function customer(Customer $customer, array $items = []): self
{
return new static($customer, $items);
}
/**
* Create a new transaction on Paddle and return a new checkout instance.
*/
public static function transaction(array $transaction, ?Customer $customer = null): self
{
return new static($customer, [], $transaction);
}
/**
* Add custom data to the checkout.
*/
public function customData(array $custom): self
{
// Make sure subscription_type doesn't gets unset.
if (isset($this->custom['subscription_type']) && isset($custom['subscription_type'])) {
throw new LogicException('The subscription_type can not be overwritten.');
}
$this->custom = $custom;
return $this;
}
/**
* Convert the checkout to an array compatible with `Paddle.Checkout.open`.
*/
public function options(): array
{
$options = [
'settings' => array_filter([
'displayMode' => 'inline',
'frameStyle' => 'width: 100%; background-color: transparent; border: none;',
'successUrl' => $this->returnTo,
'allowLogout' => false,
]),
'items' => $this->items,
];
if ($customer = $this->customer) {
$options['customer'] = ['id' => $customer->paddle_id];
}
if ($custom = $this->custom) {
$options['customData'] = $custom;
}
return $options;
}
/**
* The URL the customer should be returned to after a successful checkout.
*/
public function returnTo(string $returnTo): self
{
$this->returnTo = $returnTo;
return $this;
}
/**
* Get the customer for the checkout.
*/
public function getCustomer(): ?Customer
{
return $this->customer;
}
/**
* Get the items for the checkout.
*/
public function getItems(): array
{
return $this->items;
}
/**
* Get the Paddle transaction data.
*/
public function getTransaction(): array
{
return $this->transaction;
}
/**
* Get the custom data for the checkout.
*/
public function getCustomData(): array
{
return $this->custom;
}
/**
* Get the URL the customer should be returned to after a successful checkout.
*/
public function getReturnUrl(): ?string
{
return $this->returnTo;
}
/**
* Get the checkout's JSON serializable attributes.
*/
public function jsonSerialize(): mixed
{
return $this->options();
}
/**
* Convert the checkout to its array representation.
*
* @return array
*/
public function toArray()
{
return $this->options();
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Laravel\Paddle\Components;
use Illuminate\View\Component;
use Laravel\Paddle\Checkout as PaddleCheckout;
class Button extends Component
{
/**
* Initialise the Button component class.
*/
public function __construct(public PaddleCheckout $checkout)
{
//
}
/**
* Get the view / view contents that represent the component.
*
* @return \Illuminate\View\View|string
*/
public function render()
{
return view('cashier::components.button');
}
}
@@ -0,0 +1,51 @@
<?php
namespace Laravel\Paddle\Components;
use Illuminate\View\Component;
use Laravel\Paddle\Checkout as PaddleCheckout;
class Checkout extends Component
{
/**
* Initialise the Checkout component class.
*/
public function __construct(
protected PaddleCheckout $checkout,
public string $id = 'paddle-checkout-container',
protected int $height = 366,
protected array $settings = []
) {
//
}
/**
* Get the view / view contents that represent the component.
*
* @return \Illuminate\View\View|string
*/
public function render()
{
return view('cashier::components.checkout');
}
/**
* Get the options for the inline Paddle Checkout script.
*
* @return array
*/
public function options()
{
$options = $this->checkout->options();
$options['settings']['frameTarget'] = $this->id;
$options['settings']['frameInitialHeight'] = $this->height;
$options['settings'] = array_filter(
array_merge($options['settings'], $this->settings),
fn ($option) => ! is_null($option)
);
return $options;
}
}
@@ -0,0 +1,109 @@
<?php
namespace Laravel\Paddle\Concerns;
use Laravel\Paddle\Cashier;
use LogicException;
trait ManagesCustomer
{
/**
* Create a Paddle customer for the given model.
*
* @return \Laravel\Paddle\Customer
*/
public function createAsCustomer(array $options = [])
{
if ($customer = $this->customer) {
return $customer;
}
if (! array_key_exists('name', $options) && $name = $this->paddleName()) {
$options['name'] = $name;
}
if (! array_key_exists('email', $options) && $email = $this->paddleEmail()) {
$options['email'] = $email;
}
if (! isset($options['email'])) {
throw new LogicException('Unable to create Paddle customer without an email.');
}
$trialEndsAt = $options['trial_ends_at'] ?? null;
unset($options['trial_ends_at']);
// Attempt to find the customer by email address first...
$response = Cashier::api('GET', 'customers', [
'status' => 'active,archived',
'email' => $options['email'],
])['data'][0] ?? null;
// If we can't find the customer by email, we'll create them on Paddle...
if (is_null($response)) {
$response = Cashier::api('POST', 'customers', $options)['data'];
}
if (Cashier::$customerModel::where('paddle_id', $response['id'])->exists()) {
throw new LogicException("The Paddle customer [{$response['id']}] already exists in the database.");
}
$customer = $this->customer()->make();
$customer->paddle_id = $response['id'];
$customer->name = $response['name'] ?? '';
$customer->email = $response['email'];
$customer->trial_ends_at = $trialEndsAt;
$customer->save();
$this->refresh();
return $customer;
}
/**
* Get the customer related to the billable model.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphOne
*/
public function customer()
{
return $this->morphOne(Cashier::$customerModel, 'billable');
}
/**
* Get price previews for a set of price ids for this billable model.
*
* @param array|string $items
* @param array $options
* @return \Illuminate\Support\Collection
*/
public function previewPrices($items, array $options = [])
{
if ($customer = $this->customer) {
$options['customer_id'] = $customer->paddle_id;
}
return Cashier::previewPrices($items, $options);
}
/**
* Get the billable model's name to associate with Paddle.
*
* @return string|null
*/
public function paddleName()
{
return $this->name;
}
/**
* Get the billable model's email address to associate with Paddle.
*
* @return string|null
*/
public function paddleEmail()
{
return $this->email;
}
}
@@ -0,0 +1,217 @@
<?php
namespace Laravel\Paddle\Concerns;
use Laravel\Paddle\Cashier;
use Laravel\Paddle\Subscription;
trait ManagesSubscriptions
{
/**
* Get all of the subscriptions for the Billable model.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
*/
public function subscriptions()
{
return $this->morphMany(Cashier::$subscriptionModel, 'billable')->orderByDesc('created_at');
}
/**
* Get a subscription instance by type.
*
* @param string $type
* @return \Laravel\Paddle\Subscription|null
*/
public function subscription($type = 'default')
{
return $this->subscriptions->where('type', $type)->first();
}
/**
* Determine if the Billable model is on trial.
*
* @param string $type
* @param int|null $price
* @return bool
*/
public function onTrial($type = 'default', $price = null)
{
if (func_num_args() === 0 && $this->onGenericTrial()) {
return true;
}
$subscription = $this->subscription($type);
if (! $subscription || ! $subscription->onTrial()) {
return false;
}
return $price ? $subscription->hasPrice($price) : true;
}
/**
* Determine if the Billable model's trial has ended.
*
* @param string $type
* @param int|null $price
* @return bool
*/
public function hasExpiredTrial($type = 'default', $price = null)
{
if (func_num_args() === 0 && $this->hasExpiredGenericTrial()) {
return true;
}
$subscription = $this->subscription($type);
if (! $subscription || ! $subscription->hasExpiredTrial()) {
return false;
}
return $price ? $subscription->hasPrice($price) : true;
}
/**
* Determine if the Billable model is on a "generic" trial at the model level.
*
* @return bool
*/
public function onGenericTrial()
{
if (is_null($this->customer)) {
return false;
}
return $this->customer->onGenericTrial();
}
/**
* Determine if the Billable model's "generic" trial at the model level has expired.
*
* @return bool
*/
public function hasExpiredGenericTrial()
{
if (is_null($this->customer)) {
return false;
}
return $this->customer->hasExpiredGenericTrial();
}
/**
* Get the ending date of the trial.
*
* @param string $type
* @return \Illuminate\Support\Carbon|null
*/
public function trialEndsAt($type = 'default')
{
if (is_null($this->customer)) {
return null;
}
if (func_num_args() === 0 && $this->onGenericTrial()) {
return $this->customer->trial_ends_at;
}
if ($subscription = $this->subscription($type)) {
return $subscription->trial_ends_at;
}
return $this->customer->trial_ends_at;
}
/**
* Determine if the customer has a given subscription.
*
* @param string $type
* @param string|null $price
* @return bool
*/
public function subscribed($type = 'default', $price = null)
{
$subscription = $this->subscription($type);
if (! $subscription || ! $subscription->valid()) {
return false;
}
return $price ? $subscription->hasPrice($price) : true;
}
/**
* Determine if the customer is actively subscribed to one of the given products.
*
* @param string|string[] $products
* @param string $type
* @return bool
*/
public function subscribedToProduct($products, $type = 'default')
{
$subscription = $this->subscription($type);
if (! $subscription || ! $subscription->valid()) {
return false;
}
foreach ((array) $products as $product) {
if ($subscription->hasProduct($product)) {
return true;
}
}
return false;
}
/**
* Determine if the customer is actively subscribed to one of the given prices.
*
* @param string|string[] $prices
* @param string $type
* @return bool
*/
public function subscribedToPrice($prices, $type = 'default')
{
$subscription = $this->subscription($type);
if (! $subscription || ! $subscription->valid()) {
return false;
}
foreach ((array) $prices as $price) {
if ($subscription->hasPrice($price)) {
return true;
}
}
return false;
}
/**
* Determine if the customer has a valid subscription on the given product.
*
* @param string $product
* @return bool
*/
public function onProduct($product)
{
return ! is_null($this->subscriptions->first(function (Subscription $subscription) use ($product) {
return $subscription->valid() && $subscription->hasProduct($product);
}));
}
/**
* Determine if the customer has a valid subscription on the given price.
*
* @param string $price
* @return bool
*/
public function onPrice($price)
{
return ! is_null($this->subscriptions->first(function (Subscription $subscription) use ($price) {
return $subscription->valid() && $subscription->hasPrice($price);
}));
}
}
@@ -0,0 +1,18 @@
<?php
namespace Laravel\Paddle\Concerns;
use Laravel\Paddle\Cashier;
trait ManagesTransactions
{
/**
* Get all of the transactions for the Billable model.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
*/
public function transactions()
{
return $this->morphMany(Cashier::$transactionModel, 'billable')->orderByDesc('billed_at');
}
}
@@ -0,0 +1,91 @@
<?php
namespace Laravel\Paddle\Concerns;
use Laravel\Paddle\Cashier;
use Laravel\Paddle\Checkout;
use Laravel\Paddle\Subscription;
use Laravel\Paddle\SubscriptionBuilder;
trait PerformsCharges
{
/**
* Get a checkout instance for a given list of prices.
*
* @param string|array $prices
* @param int $quantity
* @return \Laravel\Paddle\Checkout
*/
public function checkout($prices, int $quantity = 1)
{
$customer = $this->createAsCustomer();
return Checkout::customer($customer, is_array($prices) ? $prices : [$prices => $quantity]);
}
/**
* Subscribe the customer to a new plan variant.
*
* @param string|array $prices
* @param string $type
* @return \Laravel\Paddle\Checkout
*/
public function subscribe($prices, string $type = Subscription::DEFAULT_TYPE)
{
return $this->checkout($prices)->customData(['subscription_type' => $type]);
}
/**
* Subscribe the customer to a new product.
*
* @param int $amount
* @param string $name
* @param string $type
* @return \Laravel\Paddle\SubscriptionBuilder
*/
public function newSubscription(int $amount, string $name, string $type = Subscription::DEFAULT_TYPE)
{
return new SubscriptionBuilder($this, $amount, $name, $type);
}
/**
* Creates a transaction for a "one off" charge for the given amount and returns a checkout instance.
*
* @param int $amount
* @param string $name
* @param array $options
* @return \Laravel\Paddle\Checkout
*/
public function charge(int $amount, string $name, array $options = [])
{
return $this->chargeMany([array_replace_recursive([
'price' => [
'description' => "$name Custom Price",
'unit_price' => [
'amount' => (string) $amount,
'currency_code' => config('cashier.currency'),
],
'product' => [
'name' => $name,
'tax_category' => 'standard',
],
],
'quantity' => 1,
], $options)]);
}
/**
* Creates a transaction for a "one off" charge for the given items and returns a checkout instance.
*
* @param array $items
* @return \Laravel\Paddle\Checkout
*/
public function chargeMany(array $items)
{
$customer = $this->createAsCustomer();
$transaction = Cashier::api('POST', 'transactions', ['items' => $items])->json()['data'];
return Checkout::transaction($transaction, $customer);
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace Laravel\Paddle\Concerns;
/**
* @link https://developer.paddle.com/concepts/subscriptions/proration
*/
trait Prorates
{
/**
* The current proration behavior.
*
* @var bool
*/
protected $prorationBehavior = 'prorated_next_billing_period';
/**
* Indicate that the buyer is billed the prorated amount on their next renewal.
*
* @return $this
*/
public function prorate()
{
$this->prorationBehavior = 'prorated_next_billing_period';
return $this;
}
/**
* Indicate that the buyer is billed for the full amount on their next renewal.
*
* @return $this
*/
public function noProrate()
{
$this->prorationBehavior = 'full_next_billing_period';
return $this;
}
/**
* Indicate that the buyer is billed the prorated amount now.
*
* @return $this
*/
public function prorateImmediately()
{
$this->prorationBehavior = 'prorated_immediately';
return $this;
}
/**
* Indicate that the buyer is billed the full amount now.
*
* @return $this
*/
public function immediatelyWithoutProrate()
{
$this->prorationBehavior = 'full_immediately';
return $this;
}
/**
* Indicate that the buyer is not billed for the prorated amount or the full amount.
*
* @return $this
*/
public function doNotBill()
{
$this->prorationBehavior = 'do_not_bill';
return $this;
}
/**
* Set the proration behavior.
*
* @param string $prorationBehavior
* @return $this
*/
public function setProrationBehavior($prorationBehavior)
{
$this->prorationBehavior = $prorationBehavior;
return $this;
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace Laravel\Paddle;
use Illuminate\Database\Eloquent\Model;
/**
* @property \Laravel\Paddle\Billable $billable
*/
class Customer extends Model
{
/**
* The attributes that are not mass assignable.
*
* @var array
*/
protected $guarded = [];
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'trial_ends_at' => 'datetime',
];
/**
* Get the billable model related to the customer.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
*/
public function billable()
{
return $this->morphTo();
}
/**
* Determine if the Paddle model is on a "generic" trial at the model level.
*
* @return bool
*/
public function onGenericTrial()
{
return $this->trial_ends_at && $this->trial_ends_at->isFuture();
}
/**
* Determine if the Paddle model has an expired "generic" trial at the model level.
*
* @return bool
*/
public function hasExpiredGenericTrial()
{
return $this->trial_ends_at && $this->trial_ends_at->isPast();
}
}
@@ -0,0 +1,49 @@
<?php
namespace Laravel\Paddle\Events;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Laravel\Paddle\Customer;
class CustomerUpdated
{
use Dispatchable, SerializesModels;
/**
* The billable entity.
*
* @var \Illuminate\Database\Eloquent\Model
*/
public $billable;
/**
* The customer instance.
*
* @var \Laravel\Paddle\Customer
*/
public $customer;
/**
* The webhook payload.
*
* @var array
*/
public $payload;
/**
* Create a new event instance.
*
* @param \Illuminate\Database\Eloquent\Model $billable
* @param \Laravel\Paddle\Customer $customer
* @param array $payload
* @return void
*/
public function __construct(Model $billable, Customer $customer, array $payload)
{
$this->billable = $billable;
$this->customer = $customer;
$this->payload = $payload;
}
}
@@ -0,0 +1,39 @@
<?php
namespace Laravel\Paddle\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Laravel\Paddle\Subscription;
class SubscriptionCanceled
{
use Dispatchable, SerializesModels;
/**
* The subscription instance.
*
* @var \Laravel\Paddle\Subscription
*/
public $subscription;
/**
* The webhook payload.
*
* @var array
*/
public $payload;
/**
* Create a new event instance.
*
* @param \Laravel\Paddle\Subscription $subscription
* @param array $payload
* @return void
*/
public function __construct(Subscription $subscription, array $payload)
{
$this->subscription = $subscription;
$this->payload = $payload;
}
}
@@ -0,0 +1,49 @@
<?php
namespace Laravel\Paddle\Events;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Laravel\Paddle\Subscription;
class SubscriptionCreated
{
use Dispatchable, SerializesModels;
/**
* The billable entity.
*
* @var \Illuminate\Database\Eloquent\Model
*/
public $billable;
/**
* The subscription instance.
*
* @var \Laravel\Paddle\Subscription
*/
public $subscription;
/**
* The payload array.
*
* @var array
*/
public $payload;
/**
* Create a new event instance.
*
* @param \Illuminate\Database\Eloquent\Model $billable
* @param \Laravel\Paddle\Subscription $subscription
* @param array $payload
* @return void
*/
public function __construct(Model $billable, Subscription $subscription, array $payload)
{
$this->billable = $billable;
$this->subscription = $subscription;
$this->payload = $payload;
}
}
@@ -0,0 +1,39 @@
<?php
namespace Laravel\Paddle\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Laravel\Paddle\Subscription;
class SubscriptionPaused
{
use Dispatchable, SerializesModels;
/**
* The subscription instance.
*
* @var \Laravel\Paddle\Subscription
*/
public $subscription;
/**
* The webhook payload.
*
* @var array
*/
public $payload;
/**
* Create a new event instance.
*
* @param \Laravel\Paddle\Subscription $subscription
* @param array $payload
* @return void
*/
public function __construct(Subscription $subscription, array $payload)
{
$this->subscription = $subscription;
$this->payload = $payload;
}
}
@@ -0,0 +1,39 @@
<?php
namespace Laravel\Paddle\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Laravel\Paddle\Subscription;
class SubscriptionUpdated
{
use Dispatchable, SerializesModels;
/**
* The subscription instance.
*
* @var \Laravel\Paddle\Subscription
*/
public $subscription;
/**
* The webhook payload.
*
* @var array
*/
public $payload;
/**
* Create a new event instance.
*
* @param \Laravel\Paddle\Subscription $subscription
* @param array $payload
* @return void
*/
public function __construct(Subscription $subscription, array $payload)
{
$this->subscription = $subscription;
$this->payload = $payload;
}
}
@@ -0,0 +1,49 @@
<?php
namespace Laravel\Paddle\Events;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Laravel\Paddle\Transaction;
class TransactionCompleted
{
use Dispatchable, SerializesModels;
/**
* The billable entity.
*
* @var \Illuminate\Database\Eloquent\Model
*/
public $billable;
/**
* The transaction instance.
*
* @var \Laravel\Paddle\Transaction
*/
public $transaction;
/**
* The webhook payload.
*
* @var array
*/
public $payload;
/**
* Create a new event instance.
*
* @param \Illuminate\Database\Eloquent\Model $billable
* @param \Laravel\Paddle\Transaction $transaction
* @param array $payload
* @return void
*/
public function __construct(Model $billable, Transaction $transaction, array $payload)
{
$this->billable = $billable;
$this->transaction = $transaction;
$this->payload = $payload;
}
}
@@ -0,0 +1,49 @@
<?php
namespace Laravel\Paddle\Events;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Laravel\Paddle\Transaction;
class TransactionUpdated
{
use Dispatchable, SerializesModels;
/**
* The billable entity.
*
* @var \Illuminate\Database\Eloquent\Model
*/
public $billable;
/**
* The transaction instance.
*
* @var \Laravel\Paddle\Transaction
*/
public $transaction;
/**
* The webhook payload.
*
* @var array
*/
public $payload;
/**
* Create a new event instance.
*
* @param \Illuminate\Database\Eloquent\Model $billable
* @param \Laravel\Paddle\Transaction $transaction
* @param array $payload
* @return void
*/
public function __construct(Model $billable, Transaction $transaction, array $payload)
{
$this->billable = $billable;
$this->transaction = $transaction;
$this->payload = $payload;
}
}
@@ -0,0 +1,28 @@
<?php
namespace Laravel\Paddle\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class WebhookHandled
{
use Dispatchable;
use SerializesModels;
/**
* @var array
*/
public $payload;
/**
* Create a new event instance.
*
* @param array $payload
* @return void
*/
public function __construct(array $payload)
{
$this->payload = $payload;
}
}
@@ -0,0 +1,28 @@
<?php
namespace Laravel\Paddle\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class WebhookReceived
{
use Dispatchable;
use SerializesModels;
/**
* @var array
*/
public $payload;
/**
* Create a new event instance.
*
* @param array $payload
* @return void
*/
public function __construct(array $payload)
{
$this->payload = $payload;
}
}
@@ -0,0 +1,38 @@
<?php
namespace Laravel\Paddle\Exceptions;
use Exception;
class PaddleException extends Exception
{
/**
* The error response from Paddle.
*
* @var array
*/
protected array $error = [];
/**
* Get the error response from Paddle.
*
* @return array
*/
public function getError(): array
{
return $this->error;
}
/**
* Set the error response from Paddle.
*
* @param array $error
* @return self
*/
public function setError(array $error): self
{
$this->error = $error;
return $this;
}
}
@@ -0,0 +1,357 @@
<?php
namespace Laravel\Paddle\Http\Controllers;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Str;
use Laravel\Paddle\Cashier;
use Laravel\Paddle\Events\CustomerUpdated;
use Laravel\Paddle\Events\SubscriptionCanceled;
use Laravel\Paddle\Events\SubscriptionCreated;
use Laravel\Paddle\Events\SubscriptionPaused;
use Laravel\Paddle\Events\SubscriptionUpdated;
use Laravel\Paddle\Events\TransactionCompleted;
use Laravel\Paddle\Events\TransactionUpdated;
use Laravel\Paddle\Events\WebhookHandled;
use Laravel\Paddle\Events\WebhookReceived;
use Laravel\Paddle\Http\Middleware\VerifyWebhookSignature;
use Laravel\Paddle\Subscription;
use Symfony\Component\HttpFoundation\Response;
class WebhookController extends Controller
{
/**
* Create a new WebhookController instance.
*
* @return void
*/
public function __construct()
{
if (config('cashier.webhook_secret')) {
$this->middleware(VerifyWebhookSignature::class);
}
}
/**
* Handle a Paddle webhook call.
*
* @param \Illuminate\Http\Request $request
* @return \Symfony\Component\HttpFoundation\Response
*/
public function __invoke(Request $request)
{
$payload = $request->all();
$method = 'handle'.Str::studly(Str::replace('.', ' ', $payload['event_type']));
WebhookReceived::dispatch($payload);
if (method_exists($this, $method)) {
$this->{$method}($payload);
WebhookHandled::dispatch($payload);
return new Response('Webhook Handled');
}
return new Response();
}
/**
* Handle customer updated.
*
* @param array $payload
* @return void
*/
protected function handleCustomerUpdated(array $payload)
{
$data = $payload['data'];
if (! $customer = $this->findCustomer($data['id'])) {
return;
}
$customer->update([
'name' => $data['name'] ?? '',
'email' => $data['email'],
]);
CustomerUpdated::dispatch($customer->billable, $customer, $payload);
}
/**
* Handle transaction completed.
*
* @param array $payload
* @return void
*/
protected function handleTransactionCompleted(array $payload)
{
$data = $payload['data'];
if ($this->transactionExists($data['id'])) {
return;
}
if (! $billable = $this->findBillable($data['customer_id'])) {
return;
}
$transaction = $billable->transactions()->create([
'paddle_id' => $data['id'],
'paddle_subscription_id' => $data['subscription_id'],
'invoice_number' => $data['invoice_number'],
'status' => $data['status'],
'total' => $data['details']['totals']['total'],
'tax' => $data['details']['totals']['tax'],
'currency' => $data['currency_code'],
'billed_at' => Carbon::parse($data['billed_at'], 'UTC'),
]);
TransactionCompleted::dispatch($billable, $transaction, $payload);
}
/**
* Handle transaction updated.
*
* @param array $payload
* @return void
*/
protected function handleTransactionUpdated(array $payload)
{
$data = $payload['data'];
if (! $transaction = $this->findTransaction($data['id'])) {
return;
}
$transaction->update([
'invoice_number' => $data['invoice_number'],
'status' => $data['status'],
'total' => $data['details']['totals']['total'],
'tax' => $data['details']['totals']['tax'],
'billed_at' => Carbon::parse($data['billed_at'], 'UTC'),
]);
TransactionUpdated::dispatch($transaction->billable, $transaction, $payload);
}
/**
* Handle subscription created.
*
* @param array $payload
* @return void
*/
protected function handleSubscriptionCreated(array $payload)
{
$data = $payload['data'];
if ($this->subscriptionExists($data['id'])) {
return;
}
if (! $billable = $this->findBillable($data['customer_id'])) {
return;
}
$subscription = $billable->subscriptions()->create([
'type' => $data['custom_data']['subscription_type'] ?? Subscription::DEFAULT_TYPE,
'paddle_id' => $data['id'],
'status' => $data['status'],
'trial_ends_at' => $data['status'] === Subscription::STATUS_TRIALING
? Carbon::parse($data['next_billed_at'], 'UTC')
: null,
]);
foreach ($data['items'] as $item) {
$subscription->items()->create([
'product_id' => $item['price']['product_id'],
'price_id' => $item['price']['id'],
'status' => $item['status'],
'quantity' => $item['quantity'] ?? 1,
]);
}
$billable->customer->update(['trial_ends_at' => null]);
SubscriptionCreated::dispatch($billable, $subscription, $payload);
}
/**
* Handle subscription updated.
*
* @param array $payload
* @return void
*/
protected function handleSubscriptionUpdated(array $payload)
{
$data = $payload['data'];
if (! $subscription = $this->findSubscription($data['id'])) {
return;
}
$subscription->status = $data['status'];
if ($data['status'] === Subscription::STATUS_TRIALING) {
$subscription->trial_ends_at = Carbon::parse($data['next_billed_at'], 'UTC');
} else {
$subscription->trial_ends_at = null;
}
if (isset($data['paused_at'])) {
$subscription->paused_at = Carbon::parse($data['paused_at'], 'UTC');
} elseif (isset($data['scheduled_change']) && $data['scheduled_change']['action'] === 'pause') {
$subscription->paused_at = Carbon::parse($data['scheduled_change']['effective_at'], 'UTC');
} else {
$subscription->paused_at = null;
}
if (isset($data['canceled_at'])) {
$subscription->ends_at = Carbon::parse($data['canceled_at'], 'UTC');
} elseif (isset($data['scheduled_change']) && $data['scheduled_change']['action'] === 'cancel') {
$subscription->ends_at = Carbon::parse($data['scheduled_change']['effective_at'], 'UTC');
} else {
$subscription->ends_at = null;
}
$subscription->save();
$prices = [];
foreach ($data['items'] as $item) {
$prices[] = $item['price']['id'];
$subscription->items()->updateOrCreate([
'price_id' => $item['price']['id'],
], [
'product_id' => $item['price']['product_id'],
'status' => $item['status'],
'quantity' => $item['quantity'] ?? 1,
]);
}
// Delete items that aren't attached to the subscription anymore...
$subscription->items()->whereNotIn('price_id', $prices)->delete();
SubscriptionUpdated::dispatch($subscription, $payload);
}
/**
* Handle subscription paused.
*
* @param array $payload
* @return void
*/
protected function handleSubscriptionPaused(array $payload)
{
$data = $payload['data'];
if (! $subscription = $this->findSubscription($data['id'])) {
return;
}
$subscription->status = $data['status'];
$subscription->paused_at = Carbon::parse($data['paused_at'], 'UTC');
$subscription->ends_at = null;
$subscription->save();
SubscriptionPaused::dispatch($subscription, $payload);
}
/**
* Handle subscription canceled.
*
* @param array $payload
* @return void
*/
protected function handleSubscriptionCanceled(array $payload)
{
$data = $payload['data'];
if (! $subscription = $this->findSubscription($data['id'])) {
return;
}
$subscription->status = $data['status'];
$subscription->ends_at = Carbon::parse($data['canceled_at'], 'UTC');
$subscription->paused_at = null;
$subscription->save();
SubscriptionCanceled::dispatch($subscription, $payload);
}
/**
* Get the customer instance by its Paddle customer ID.
*
* @param string $customerId
* @return \Laravel\Paddle\Billable|null
*/
protected function findBillable($customerId)
{
return Cashier::findBillable($customerId);
}
/**
* Find the first customer matching a Paddle customer ID.
*
* @param string $customerId
* @return \Laravel\Paddle\Customer|null
*/
protected function findCustomer(string $customerId)
{
return Cashier::$customerModel::firstWhere('paddle_id', $customerId);
}
/**
* Find the first subscription matching a Paddle subscription ID.
*
* @param string $subscriptionId
* @return \Laravel\Paddle\Subscription|null
*/
protected function findSubscription(string $subscriptionId)
{
return Cashier::$subscriptionModel::firstWhere('paddle_id', $subscriptionId);
}
/**
* Determine if a subscription with a given Paddle ID already exists.
*
* @param string $subscriptionId
* @return bool
*/
protected function subscriptionExists(string $subscriptionId)
{
return Cashier::$subscriptionModel::where('paddle_id', $subscriptionId)->exists();
}
/**
* Find the first transaction matching a Paddle transaction ID.
*
* @param string $transactionId
* @return \Laravel\Paddle\Transaction|null
*/
protected function findTransaction(string $transactionId)
{
return Cashier::$transactionModel::firstWhere('paddle_id', $transactionId);
}
/**
* Determine if a transaction with a given ID already exists.
*
* @param string $transactionId
* @return bool
*/
protected function transactionExists(string $transactionId)
{
return Cashier::$transactionModel::where('paddle_id', $transactionId)->count() > 0;
}
}
@@ -0,0 +1,108 @@
<?php
namespace Laravel\Paddle\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* @see https://developer.paddle.com/webhook-reference/verifying-webhooks
*/
class VerifyWebhookSignature
{
public const SIGNATURE_HEADER = 'Paddle-Signature';
public const HASH_ALGORITHM_1 = 'h1';
protected ?int $maximumVariance = 5;
/**
* Handle the incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return \Illuminate\Http\Response
*
* @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
*/
public function handle(Request $request, Closure $next)
{
$signature = $request->header(self::SIGNATURE_HEADER);
if ($this->isInvalidSignature($request, $signature)) {
throw new AccessDeniedHttpException('Invalid webhook signature.');
}
return $next($request);
}
/**
* Validate signature.
*
* @param \Illuminate\Http\Request $request
* @param string $signature
* @return bool
*/
//the signature is not $signature[0] it's $signature
//the true it's false and false it's true when if ($this->isInvalidSignature($request, $signature)) { throw new AccessDeniedHttpException('Invalid webhook signature.'); }
protected function isInvalidSignature(Request $request, $signature)
{
if (empty($signature)) {
return true;
}
[$timestamp, $hashes] = $this->parseSignature($signature);
if ($this->maximumVariance > 0 && time() > $timestamp + $this->maximumVariance) {
return true;
}
$secret = config('cashier.webhook_secret');
$data = $request->getContent();
foreach ($hashes as $hashAlgorithm => $possibleHashes) {
$hash = match ($hashAlgorithm) {
'h1' => hash_hmac('sha256', "{$timestamp}:{$data}", $secret),
};
foreach ($possibleHashes as $possibleHash) {
if (hash_equals($hash, $possibleHash)) {
return false;
}
}
}
return true;
}
/**
* Parse the signature header.
*
* @param string $header
* @return array
*/
public function parseSignature(string $header): array
{
$components = [
'ts' => 0,
'hashes' => [],
];
foreach (explode(';', $header) as $part) {
if (str_contains($part, '=')) {
[$key, $value] = explode('=', $part, 2);
match ($key) {
'ts' => $components['ts'] = (int) $value,
'h1' => $components['hashes']['h1'][] = $value,
};
}
}
return [
$components['ts'],
$components['hashes'],
];
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
namespace Laravel\Paddle;
use Carbon\Carbon;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Jsonable;
use JsonSerializable;
use Money\Currency;
class Payment implements Arrayable, Jsonable, JsonSerializable
{
/**
* The amount of the payment.
*
* @var string
*/
public $amount;
/**
* The currency of the payment.
*
* @var string
*/
public $currency;
/**
* The payment date.
*
* @var \Carbon\Carbon
*/
public $date;
/**
* Create a new Payment instance.
*
* @param string $amount
* @param string $currency
* @param \Carbon\Carbon $date
* @return void
*/
public function __construct($amount, $currency, $date)
{
$this->amount = $amount;
$this->currency = $currency;
$this->date = $date;
}
/**
* Get the total amount of the payment.
*
* @return string
*/
public function amount()
{
return Cashier::formatAmount($this->amount, $this->currency);
}
/**
* Get the raw total of the payment.
*
* @return string
*/
public function rawAmount()
{
return $this->amount;
}
/**
* Get the currency used for the payment.
*
* @return \Money\Currency
*/
public function currency(): Currency
{
return new Currency($this->currency);
}
/**
* Get the date of the payment as a Carbon instance.
*
* @return \Carbon\Carbon
*/
public function date()
{
return $this->date;
}
/**
* Get the instance as an array.
*
* @return array
*/
public function toArray()
{
return [
'amount' => $this->amount(),
'currency' => $this->currency,
'date' => $this->date()->toIso8601String(),
];
}
/**
* Convert the object to its JSON representation.
*
* @param int $options
* @return string
*/
public function toJson($options = 0)
{
return json_encode($this->jsonSerialize(), $options);
}
/**
* Convert the object into something JSON serializable.
*
* @return array
*/
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
return $this->toArray();
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace Laravel\Paddle;
use Money\Currency;
class Price
{
/**
* The price attributes.
*
* @var array
*/
protected $price;
/**
* Create a new Price instance.
*
* @param array $price
* @return void
*/
public function __construct(array $price)
{
$this->price = $price;
}
/**
* Get the amount.
*
* @return string
*/
public function amount()
{
return Cashier::formatAmount($this->rawAmount(), $this->currency());
}
/**
* Get the raw amount.
*
* @return string
*/
public function rawAmount()
{
return $this->price['unit_price']['amount'];
}
/**
* Get the interval for the price.
*
* @return string|null
*/
public function interval()
{
return $this->price['billing_cycle']['interval'] ?? null;
}
/**
* Get the frequency for the price.
*
* @return int|null
*/
public function frequency()
{
return $this->price['billing_cycle']['frequency'] ?? null;
}
/**
* Get the used currency for the price.
*
* @return \Money\Currency
*/
public function currency(): Currency
{
return new Currency($this->price['unit_price']['currency_code']);
}
/**
* Dynamically get values from the Paddle price.
*
* @param string $key
* @return mixed
*/
public function __get($key)
{
return $this->price[$key];
}
}
+162
View File
@@ -0,0 +1,162 @@
<?php
namespace Laravel\Paddle;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Jsonable;
use JsonSerializable;
use Money\Currency;
class PricePreview implements Arrayable, Jsonable, JsonSerializable
{
/**
* The price preview attributes.
*
* @var array
*/
protected $item;
/**
* Create a new PricePreview instance.
*
* @param array $item
* @return void
*/
public function __construct(array $item)
{
$this->item = $item;
}
/**
* Get the price object for the preview.
*
* @return \Laravel\Paddle\Price
*/
public function price()
{
return new Price($this->item['price']);
}
/**
* Get the total amount.
*
* @return string
*/
public function total()
{
return $this->item['formatted_totals']['total'];
}
/**
* Get the raw total amount.
*
* @return string
*/
public function rawTotal()
{
return $this->item['totals']['total'];
}
/**
* Get the subtotal amount.
*
* @return string
*/
public function subtotal()
{
return $this->item['formatted_totals']['subtotal'];
}
/**
* Get the raw subtotal amount.
*
* @return string
*/
public function rawSubtotal()
{
return $this->item['totals']['subtotal'];
}
/**
* Get the tax amount.
*
* @return string
*/
public function tax()
{
return $this->item['formatted_totals']['tax'];
}
/**
* Determine if the price has tax.
*
* @return bool
*/
public function hasTax()
{
return $this->rawTax() > 0;
}
/**
* Get the raw tax amount.
*
* @return string
*/
public function rawTax()
{
return $this->item['totals']['tax'];
}
/**
* Get the used currency for the price preview.
*
* @return \Money\Currency
*/
public function currency(): Currency
{
return $this->price()->currency();
}
/**
* Get the instance as an array.
*
* @return array
*/
public function toArray()
{
return $this->item;
}
/**
* Convert the object to its JSON representation.
*
* @param int $options
* @return string
*/
public function toJson($options = 0)
{
return json_encode($this->jsonSerialize(), $options);
}
/**
* Convert the object into something JSON serializable.
*
* @return array
*/
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
return $this->toArray();
}
/**
* Dynamically get values from the price preview.
*
* @param string $key
* @return mixed
*/
public function __get($key)
{
return $this->item[$key];
}
}
+967
View File
@@ -0,0 +1,967 @@
<?php
namespace Laravel\Paddle;
use Carbon\Carbon;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
use Laravel\Paddle\Concerns\Prorates;
use LogicException;
/**
* @property \Laravel\Paddle\Billable $billable
*/
class Subscription extends Model
{
use Prorates;
const STATUS_ACTIVE = 'active';
const STATUS_TRIALING = 'trialing';
const STATUS_PAST_DUE = 'past_due';
const STATUS_PAUSED = 'paused';
const STATUS_CANCELED = 'canceled';
const INTERVAL_DAY = 'day';
const INTERVAL_WEEK = 'week';
const INTERVAL_MONTH = 'month';
const INTERVAL_YEAR = 'year';
const DEFAULT_TYPE = 'default';
/**
* The attributes that are not mass assignable.
*
* @var array
*/
protected $guarded = [];
/**
* The relations to eager load on every query.
*
* @var array
*/
protected $with = ['items'];
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'trial_ends_at' => 'datetime',
'paused_at' => 'datetime',
'ends_at' => 'datetime',
];
/**
* Get the billable model related to the subscription.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
*/
public function billable()
{
return $this->morphTo();
}
/**
* Get the subscription items related to the subscription.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function items()
{
return $this->hasMany(Cashier::$subscriptionItemModel);
}
/**
* Get the subscription item for the given price.
*
* @param string $price
* @return \Laravel\Paddle\SubscriptionItem
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public function findItemOrFail($price)
{
return $this->items()->where('price_id', $price)->firstOrFail();
}
/**
* Retrieve a specific item by price or the single item on a subscription.
*
* @param string|null $price
* @return \Laravel\Paddle\SubscriptionItem
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
* @throws \InvalidArgumentException
*/
protected function singleItemOrFail($price = null)
{
if ($this->items()->count() > 1 && is_null($price)) {
throw new InvalidArgumentException(
'Please provide a price when retrieving an item of a subscription with multiple prices.'
);
}
return $price ? $this->findItemOrFail($price) : $this->items()->firstOrFail();
}
/**
* Get all of the transactions for the Billable model.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function transactions()
{
return $this->hasMany(Cashier::$transactionModel, 'paddle_subscription_id', 'paddle_id')
->orderByDesc('created_at');
}
/**
* Determine if the subscription has multiple prices.
*
* @return bool
*/
public function hasMultiplePrices()
{
return $this->items->count() > 1;
}
/**
* Determine if the subscription has a single price.
*
* @return bool
*/
public function hasSinglePrice()
{
return ! $this->hasMultiplePrices();
}
/**
* Determine if the subscription has a specific product.
*
* @param string $product
* @return bool
*/
public function hasProduct($product)
{
return $this->items->contains(function (SubscriptionItem $item) use ($product) {
return $item->product_id === $product;
});
}
/**
* Determine if the subscription has a specific price.
*
* @param string $price
* @return bool
*/
public function hasPrice($price)
{
return $this->items->contains(function (SubscriptionItem $item) use ($price) {
return $item->price_id === $price;
});
}
/**
* Determine if the subscription is active, on trial, or within its grace period.
*
* @return bool
*/
public function valid()
{
return $this->onTrial() || $this->active() || (! Cashier::$deactivatePastDue && $this->pastDue());
}
/**
* Filter query by valid.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return void
*/
public function scopeValid($query)
{
$query->where('status', self::STATUS_TRIALING)
->orWhere('status', self::STATUS_ACTIVE);
if (! Cashier::$deactivatePastDue) {
$query->orWhere('status', self::STATUS_PAST_DUE);
}
}
/**
* Determine if the subscription is within its trial period.
*
* @return bool
*/
public function onTrial()
{
return $this->status === self::STATUS_TRIALING;
}
/**
* Filter query by on trial.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return void
*/
public function scopeOnTrial($query)
{
$query->where('status', self::STATUS_TRIALING);
}
/**
* Determine if the subscription's trial has expired.
*
* @return bool
*/
public function hasExpiredTrial()
{
return $this->trial_ends_at && $this->trial_ends_at->isPast();
}
/**
* Filter query by expired trial.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return void
*/
public function scopeExpiredTrial($query)
{
$query->whereNotNull('trial_ends_at')->where('trial_ends_at', '<', Carbon::now());
}
/**
* Filter query by not on trial.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return void
*/
public function scopeNotOnTrial($query)
{
$query->where('status', '!=', self::STATUS_TRIALING);
}
/**
* Determine if the subscription is active.
*
* @return bool
*/
public function active()
{
return $this->status === self::STATUS_ACTIVE;
}
/**
* Filter query by active.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return void
*/
public function scopeActive($query)
{
$query->where('status', '=', self::STATUS_ACTIVE);
}
/**
* Determine if the subscription is active and not on any grace period.
*
* @return bool
*/
public function recurring()
{
return $this->active() && ! $this->onPausedGracePeriod() && ! $this->onGracePeriod();
}
/**
* Filter query by recurring.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return void
*/
public function scopeRecurring($query)
{
$query->active()->notOnPausedGracePeriod()->notOnGracePeriod();
}
/**
* Determine if the subscription is past due.
*
* @return bool
*/
public function pastDue()
{
return $this->status === self::STATUS_PAST_DUE;
}
/**
* Filter query by past due.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return void
*/
public function scopePastDue($query)
{
$query->where('status', self::STATUS_PAST_DUE);
}
/**
* Determine if the subscription is paused.
*
* @return bool
*/
public function paused()
{
return $this->status === self::STATUS_PAUSED;
}
/**
* Filter query by paused.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return void
*/
public function scopePaused($query)
{
$query->where('status', self::STATUS_PAUSED);
}
/**
* Filter query by not paused.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return void
*/
public function scopeNotPaused($query)
{
$query->where('status', '!=', self::STATUS_PAUSED);
}
/**
* Determine if the subscription is within its grace period after being paused.
*
* @return bool
*/
public function onPausedGracePeriod()
{
return $this->paused_at && $this->paused_at->isFuture();
}
/**
* Filter query by on trial grace period.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return void
*/
public function scopeOnPausedGracePeriod($query)
{
$query->whereNotNull('paused_at')->where('paused_at', '>', Carbon::now());
}
/**
* Filter query by not on trial grace period.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return void
*/
public function scopeNotOnPausedGracePeriod($query)
{
$query->whereNull('paused_at')->orWhere('paused_at', '<=', Carbon::now());
}
/**
* Determine if the subscription is no longer active.
*
* @return bool
*/
public function canceled()
{
return $this->status === self::STATUS_CANCELED;
}
/**
* Filter query by canceled.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return void
*/
public function scopeCanceled($query)
{
$query->where('status', self::STATUS_CANCELED);
}
/**
* Filter query by not canceled.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return void
*/
public function scopeNotCanceled($query)
{
$query->where('status', '!=', self::STATUS_CANCELED);
}
/**
* Determine if the subscription is within its grace period after cancellation.
*
* @return bool
*/
public function onGracePeriod()
{
return $this->ends_at && $this->ends_at->isFuture();
}
/**
* Filter query by on grace period.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return void
*/
public function scopeOnGracePeriod($query)
{
$query->whereNotNull('ends_at')->where('ends_at', '>', Carbon::now());
}
/**
* Filter query by not on grace period.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return void
*/
public function scopeNotOnGracePeriod($query)
{
$query->whereNull('ends_at')->orWhere('ends_at', '<=', Carbon::now());
}
/**
* Bill for one-time charges on top of the subscription.
*
* @param string|array $items
* @param bool $chargeNow
* @return $this
*
* @throws \InvalidArgumentException
*/
public function charge($items, bool $chargeNow = false)
{
if (empty($items = (array) $items)) {
throw new InvalidArgumentException('Please provide at least one item when charging one-time.');
}
$response = Cashier::api('POST', "subscriptions/{$this->paddle_id}/charge", [
'effective_from' => $chargeNow ? 'immediately' : 'next_billing_period',
'items' => Cashier::normalizeItems($items),
])['data'];
$this->forceFill([
'status' => $response['status'],
])->save();
return $this;
}
/**
* Bill for one-time charges on top of the subscription, and invoice immediately.
*
* @param string|array $items
* @return $this
*
* @throws \InvalidArgumentException
*/
public function chargeAndInvoice($items)
{
$this->setProrateAndInvoice('chargeAndInvoice');
return $this->charge($items, true);
}
/**
* Increment the quantity of a subscription item.
*
* @param int $count
* @param string|null $price
* @return $this
*
* @throws \InvalidArgumentException
*/
public function incrementQuantity($count = 1, $price = null)
{
$item = $this->singleItemOrFail($price);
return $this->updateQuantity($item->quantity + $count, $item);
}
/**
* Increment the quantity of the subscription, and invoice immediately.
*
* @param int $count
* @param string|null $price
* @return $this
*/
public function incrementAndInvoice($count = 1, $price = null)
{
$item = $this->singleItemOrFail($price);
$this->setProrateAndInvoice('incrementAndInvoice');
return $this->updateQuantity($item->quantity + $count, $item);
}
/**
* Decrement the quantity of a subscription item.
*
* @param int $count
* @param string|null $price
* @return $this
*/
public function decrementQuantity($count = 1, $price = null)
{
$item = $this->singleItemOrFail($price);
return $this->updateQuantity(max(1, $item->quantity - $count), $item);
}
/**
* Update the quantity of the subscription.
*
* @param int $quantity
* @param \Laravel\Paddle\SubscriptionItem|string|null $price
* @return $this
*/
public function updateQuantity($quantity, $price = null)
{
if ($quantity < 1) {
throw new LogicException('Quantities of zero are not allowed.');
}
$itemToUpdate = $price instanceof SubscriptionItem ? $price : $this->singleItemOrFail($price);
$items = $this->items()
->get(['quantity', 'price_id'])
->map(function ($item) {
return [
'price_id' => $item['price_id'],
'quantity' => $item['quantity'],
];
})
->toArray();
foreach ($items as $key => $item) {
if ($item['price_id'] === $itemToUpdate->price_id) {
$items[$key]['quantity'] = $quantity;
}
}
$response = $this->updatePaddleSubscription([
'items' => $items,
'proration_billing_mode' => $this->prorationBehavior,
]);
$this->forceFill([
'status' => $response['status'],
])->save();
$itemToUpdate->forceFill([
'quantity' => $quantity,
])->save();
$this->load('items');
return $this;
}
/**
* Extend the trial period of the subscription.
*
* @param \DateTimeInterface|string $until
* @return $this
*/
public function extendTrial($until)
{
$response = $this->updatePaddleSubscription([
'next_billed_at' => Carbon::parse($until)->format(DateTimeInterface::RFC3339),
'proration_billing_mode' => 'do_not_bill',
]);
$this->forceFill([
'status' => $response['status'],
'trial_ends_at' => Carbon::parse($response['next_billed_at'], 'UTC'),
])->save();
$this->syncSubscriptionItems($response['items']);
return $this;
}
/**
* Force the trial to end immediately and activate the subscription.
*
* @return $this
*/
public function activate()
{
$response = Cashier::api('POST', "subscriptions/{$this->paddle_id}/activate")['data'];
$this->forceFill([
'status' => $response['status'],
'trial_ends_at' => null,
])->save();
$this->syncSubscriptionItems($response['items']);
return $this;
}
/**
* Swap the subscription to new Paddle items.
*
* @param string|array $items
* @param array $options
* @return $this
*
* @throws \InvalidArgumentException
*/
public function swap($items, array $options = [])
{
if (empty($items = (array) $items)) {
throw new InvalidArgumentException('Please provide at least one item when swapping.');
}
$items = Cashier::normalizeItems($items);
$response = $this->updatePaddleSubscription(array_merge($options, [
'items' => $items,
'proration_billing_mode' => $this->prorationBehavior,
]));
$this->forceFill([
'status' => $response['status'],
])->save();
$this->syncSubscriptionItems($response['items']);
return $this;
}
/**
* Swap the subscription to a new Paddle plan, and invoice immediately.
*
* @param string|array $items
* @param array $options
* @return $this
*/
public function swapAndInvoice($items, array $options = [])
{
$this->setProrateAndInvoice('swapAndInvoice');
return $this->swap($items, $options);
}
/**
* Change the billing cycle anchor.
*
* @param \DateTimeInterface|string|null $date
* @return $this
*/
public function anchorBillingCycleOn($date)
{
$this->updatePaddleSubscription([
'next_billed_at' => Carbon::parse($date)->format(DateTimeInterface::RFC3339),
'proration_billing_mode' => $this->prorationBehavior,
]);
return $this;
}
/**
* Redirect the user to the Paddle payment method update URL.
*
* @return \Illuminate\Http\RedirectResponse
*/
public function redirectToUpdatePaymentMethod()
{
return redirect($this->paymentMethodUpdateUrl());
}
/**
* Get the Paddle payment method update URL.
*
* @return string
*/
public function paymentMethodUpdateUrl()
{
return Cashier::api('GET', "subscriptions/{$this->paddle_id}")['data']['management_urls']['update_payment_method'];
}
/**
* Get the raw transaction used to update the payment method on file.
*
* @return array
*/
public function paymentMethodUpdateTransaction()
{
return Cashier::api('GET', "subscriptions/{$this->paddle_id}/update-payment-method-transaction")['data'];
}
/**
* Redirect the user to the Paddle cancel URL.
*
* @return \Illuminate\Http\RedirectResponse
*/
public function redirectToCancel()
{
return redirect($this->cancelUrl());
}
/**
* Get the Paddle cancel URL.
*
* @return string
*/
public function cancelUrl()
{
return Cashier::api('GET', "subscriptions/{$this->paddle_id}")['data']['management_urls']['cancel'];
}
/**
* Pause the subscription.
*
* @param bool $pauseNow
* @param \DateTimeInterface|string|null $until
* @return $this
*/
public function pause(bool $pauseNow = false, $until = null)
{
$response = Cashier::api('POST', "subscriptions/{$this->paddle_id}/pause", [
'effective_from' => $pauseNow ? 'immediately' : 'next_billing_period',
'resume_at' => $until ? Carbon::parse($until)->format(DateTimeInterface::RFC3339) : null,
])['data'];
$pausedAt = $pauseNow ? $response['paused_at'] : $response['scheduled_change']['effective_at'];
$this->forceFill([
'status' => $response['status'],
'paused_at' => Carbon::parse($pausedAt, 'UTC'),
])->save();
$this->syncSubscriptionItems($response['items']);
return $this;
}
/**
* Pause the subscription until a certain date.
*
* @param \DateTimeInterface|string $until
* @return $this
*/
public function pauseUntil($until)
{
return $this->pause(false, $until);
}
/**
* Pause the subscription immediately.
*
* @return $this
*/
public function pauseNow()
{
return $this->pause(true);
}
/**
* Pause the subscription immediately and until a certain date.
*
* @param \DateTimeInterface|string $until
* @return $this
*/
public function pauseNowUntil($until)
{
return $this->pause(true, $until);
}
/**
* Resume a paused subscription.
*
* @param \DateTimeInterface|string|null $resumeAt
* @return $this
*
* @throws \LogicException
*/
public function resume($resumeAt = null)
{
if ($this->paused()) {
$response = Cashier::api('POST', "subscriptions/{$this->paddle_id}/resume", [
'effective_from' => $resumeAt
? Carbon::parse($resumeAt)->format(DateTimeInterface::RFC3339)
: 'immediately',
])['data'];
} elseif ($this->onPausedGracePeriod()) {
$response = Cashier::api('PATCH', "subscriptions/{$this->paddle_id}", [
'scheduled_change' => null,
])['data'];
} else {
throw new LogicException('Cannot resume a subscription that is not paused.');
}
$this->forceFill([
'status' => $response['status'],
'paused_at' => $response['paused_at'] ? Carbon::parse($response['paused_at'], 'UTC') : null,
])->save();
$this->syncSubscriptionItems($response['items']);
return $this;
}
/**
* Update the underlying Paddle subscription information for the model.
*
* @param array $options
* @return array
*/
public function updatePaddleSubscription(array $options)
{
return Cashier::api('PATCH', "subscriptions/{$this->paddle_id}", $options)['data'];
}
/**
* Cancel the subscription at the end of the current billing period.
*
* @param bool $cancelNow
* @return $this
*/
public function cancel(bool $cancelNow = false)
{
$response = Cashier::api('POST', "subscriptions/{$this->paddle_id}/cancel", [
'effective_from' => $cancelNow ? 'immediately' : 'next_billing_period',
])['data'];
$endsAt = $cancelNow ? $response['canceled_at'] : $response['scheduled_change']['effective_at'];
$this->forceFill([
'status' => $response['status'],
'ends_at' => Carbon::parse($endsAt, 'UTC'),
'trial_ends_at' => $cancelNow ? null : $this->trial_ends_at,
])->save();
return $this;
}
/**
* Cancel the subscription immediately.
*
* @return $this
*/
public function cancelNow()
{
return $this->cancel(true);
}
/**
* Stop the subscription from being canceled at the end of the current billing period.
*
* @return $this
*/
public function stopCancelation()
{
$response = $this->updatePaddleSubscription(['scheduled_change' => null]);
$this->forceFill([
'status' => $response['status'],
'ends_at' => null,
])->save();
return $this;
}
/**
* Get the last payment for the subscription.
*
* @return \Laravel\Paddle\Payment|null
*/
public function lastPayment()
{
if ($transaction = $this->transactions()->orderByDesc('billed_at')->first()) {
return new Payment($transaction->total, $transaction->currency, $transaction->billed_at);
}
}
/**
* Get the next payment for the subscription.
*
* @return \Laravel\Paddle\Payment|null
*/
public function nextPayment()
{
if ($transaction = $this->asPaddleSubscription('next_transaction')['next_transaction'] ?? null) {
return new Payment(
$transaction['details']['totals']['grand_total'],
$transaction['details']['totals']['currency_code'],
Carbon::parse($transaction['billing_period']['starts_at'], 'UTC'),
);
}
}
/**
* Get the subscription as a Paddle subscription response.
*
* @param string|null $include
* @return array
*/
public function asPaddleSubscription(?string $include = null)
{
$include = $include ? ['include' => $include] : [];
return Cashier::api('GET', "subscriptions/{$this->paddle_id}", $include)['data'];
}
/**
* Dynamically set the proration behavior when invoicing immediately.
*
* @param string $method
* @return void
*
* @throws \LogicException
*/
protected function setProrateAndInvoice($method): void
{
if ($this->prorationBehavior === 'do_not_bill') {
throw new LogicException("You cannot combine {$method} and doNotBill.");
}
if ($this->prorationBehavior === 'prorated_next_billing_period') {
$this->prorateImmediately();
} elseif ($this->prorationBehavior === 'full_next_billing_period') {
$this->immediatelyWithoutProrate();
}
}
/**
* Sync the subscription items with the latest data from Paddle.
*
* @param array $items
* @return void
*/
protected function syncSubscriptionItems(array $items)
{
$prices = [];
foreach ($items as $item) {
$prices[] = $item['price']['id'];
$this->items()->updateOrCreate([
'price_id' => $item['price']['id'],
], [
'product_id' => $item['price']['product_id'],
'status' => $item['status'],
'quantity' => $item['quantity'] ?? 1,
]);
}
// Delete items that aren't attached to the subscription anymore...
$this->items()->whereNotIn('price_id', $prices)->delete();
}
}
@@ -0,0 +1,122 @@
<?php
namespace Laravel\Paddle;
class SubscriptionBuilder
{
/**
* The quantity of the subscription.
*
* @var int
*/
protected $quantity = 1;
/**
* The interval of the subscription.
*
* @var string
*/
protected $interval = Subscription::INTERVAL_MONTH;
/**
* Create a new subscription builder instance.
*
* @param \Laravel\Paddle\Billable $billable
* @param int $amount
* @param string $name
* @param string $type
* @return void
*/
public function __construct(
protected $billable,
protected int $amount,
protected string $name,
protected string $type = Subscription::DEFAULT_TYPE
) {
}
/**
* Specify the quantity of the subscription.
*
* @param int $quantity
* @return $this
*/
public function quantity($quantity)
{
$this->quantity = $quantity;
return $this;
}
/**
* Use a daily interval for the subscription.
*/
public function daily()
{
$this->interval = Subscription::INTERVAL_DAY;
return $this;
}
/**
* Use a weekly interval for the subscription.
*
* @return $this
*/
public function weekly()
{
$this->interval = Subscription::INTERVAL_WEEK;
return $this;
}
/**
* Use a monthly interval for the subscription.
*
* @return $this
*/
public function monthly()
{
$this->interval = Subscription::INTERVAL_MONTH;
return $this;
}
/**
* Use a yearly interval for the subscription.
*
* @return $this
*/
public function yearly()
{
$this->interval = Subscription::INTERVAL_YEAR;
return $this;
}
/**
* Get a new checkout instance for the subscription.
*
* @param array $options
* @return \Laravel\Paddle\Checkout
*/
public function checkout(array $options = [])
{
return $this->billable->charge(
$this->amount,
$this->name,
array_replace_recursive([
'price' => [
'description' => $this->interval === Subscription::INTERVAL_DAY
? "{$this->name} Daily"
: $this->name.' '.ucfirst($this->interval).'ly',
'billing_cycle' => [
'interval' => $this->interval,
'frequency' => $options['frequency'] ?? 1,
],
],
'quantity' => $this->quantity,
], $options)
)->customData(['subscription_type' => $this->type]);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace Laravel\Paddle;
use Illuminate\Database\Eloquent\Model;
/**
* @property \Laravel\Paddle\Subscription|null $subscription
*/
class SubscriptionItem extends Model
{
/**
* The attributes that are not mass assignable.
*
* @var array
*/
protected $guarded = [];
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'quantity' => 'integer',
];
/**
* Get the subscription that the item belongs to.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function subscription()
{
$model = Cashier::$subscriptionModel;
return $this->belongsTo($model, (new $model)->getForeignKey());
}
}
+209
View File
@@ -0,0 +1,209 @@
<?php
namespace Laravel\Paddle;
use Exception;
use Illuminate\Database\Eloquent\Model;
use LogicException;
use Money\Currency;
/**
* @property \Laravel\Paddle\Billable $billable
* @property \Laravel\Paddle\Subscription|null $subscription
*/
class Transaction extends Model
{
const STATUS_DRAFT = 'draft';
const STATUS_READY = 'ready';
const STATUS_BILLED = 'billed';
const STATUS_PAID = 'paid';
const STATUS_COMPLETED = 'completed';
const STATUS_CANCELED = 'canceled';
const STATUS_PAST_DUE = 'past_due';
/**
* The attributes that are not mass assignable.
*
* @var array
*/
protected $guarded = [];
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'billed_at' => 'datetime',
];
/**
* Get the billable model related to the transaction.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
*/
public function billable()
{
return $this->morphTo();
}
/**
* Get the subscription related to the transaction.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function subscription()
{
return $this->belongsTo(Cashier::$subscriptionModel, 'paddle_subscription_id', 'paddle_id');
}
/**
* Get the total amount that was paid.
*
* @return string
*/
public function total()
{
return Cashier::formatAmount($this->total, $this->currency());
}
/**
* Get the total tax that was paid.
*
* @return string
*/
public function tax()
{
return Cashier::formatAmount($this->tax, $this->currency());
}
/**
* Get the used currency for the transaction.
*
* @return \Money\Currency
*/
public function currency(): Currency
{
return new Currency($this->currency);
}
/**
* Get the URL to download the invoice.
*
* @return string|null
*/
public function invoicePdf()
{
if (! $this->invoice_number) {
return null;
}
return Cashier::api('GET', "transactions/{$this->paddle_id}/invoice")['data']['url'] ?? null;
}
/**
* Get the URL to download the invoice.
*
* @return \Illuminate\Http\RedirectResponse
*/
public function redirectToInvoicePdf()
{
if ($url = $this->invoicePdf()) {
return redirect($url);
}
throw new LogicException('The transaction does not have an invoice PDF.');
}
/**
* Refund the transaction for a given price and optional amount.
*
* @param string $reason
* @param string|array $price
* @return array
*/
public function refund($reason, $prices = [])
{
return $this->adjust('refund', $reason, $prices);
}
/**
* Credit the transaction for a given price and optional amount.
*
* @param string $reason
* @param string|array $price
* @return array
*/
public function credit($reason, $prices = [])
{
return $this->adjust('credit', $reason, $prices);
}
/**
* Adjust the transaction for a given price and optional amount.
*
* @param string $type
* @param string $reason
* @param string|array $price
* @return array
*/
public function adjust($type, $reason, $prices = [])
{
if ($this->status !== 'billed' && $this->status !== 'completed') {
throw new LogicException('Only "billed" or "completed" transactions can be adjusted.');
}
$lineItems = $this->asPaddleTransaction()['details']['line_items'];
$prices = (array) $prices;
$items = collect($lineItems)
->filter(function (array $lineItem) use ($prices) {
// If no specific prices were given, we'll refund the entire transaction...
if (empty($prices)) {
return true;
}
return in_array($lineItem['price_id'], $prices);
})
->map(function (array $lineItem) use ($prices) {
// If a specific amount was given to refund for this price, we'll use that...
$amount = isset($prices[$lineItem['price_id']])
? $prices[$lineItem['price_id']]
: null;
return array_filter([
'item_id' => $lineItem['id'],
'type' => $amount ? 'partial' : 'full',
'amount' => $amount,
]);
})
->values()
->all();
if (empty($items)) {
$prices = implode(', ', $prices);
throw new Exception(
"Cannot find line items with price ID's `{$prices}` for transaction `{$this->paddle_id}`."
);
}
return Cashier::api('POST', 'adjustments', [
'action' => $type,
'transaction_id' => $this->paddle_id,
'reason' => $reason,
'items' => $items,
])['data'];
}
/**
* Get the transaction as a Paddle transaction response.
*
* @return array
*/
public function asPaddleTransaction()
{
return Cashier::api('GET', "transactions/{$this->paddle_id}")['data'];
}
}