Files
Sistema-Educativo-Laravel/vendor/laravel/cashier-paddle/src/Subscription.php
T

968 lines
25 KiB
PHP

<?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();
}
}