From 0a6214a5e1163e935cab4fc2d16c7363dc348772 Mon Sep 17 00:00:00 2001 From: Dries Vints Date: Fri, 28 Jun 2019 14:57:42 +0200 Subject: [PATCH] Implement Payment Intents for charges and subscriptions These changes bring support for the new payment intents api to charges and subscriptions. As there are quite some breaking changes here let's go over the most prominent ones below: Any payment action will now throw an exception when a payment either fails or when the payment requires a secondary action in order to be completed. This goes for single charges, invoicing customers directly, subscribing to a new plan or swapping plans. Developers can catch these exceptions and decide for themselves how to handle these by either letting Stripe handle everything for them (to be set up in the Stripe dashboard) or use the custom built-in solution which will be added in the next commit. A new status column is introduced for subscriptions as well. Whenever an attempt is made to subscribe to a plan but a secondary payment action is required, the subscription will be put into a state of "incomplete" while payment confirmation is awaited. As soon as payment has been properly processed, a webhook will update the subscription's status to active. After these changes, webhooks will be a fundamental part of how Cashier works and they're now required in order to properly handle any payment confirmations and off-session updates to subscriptions & customers. The charge method now only accepts a payment method instead of a token. Developers will need to update their JS integration to retrieve a payment method id instead of a source token. These changes were done because this is now the recommended way by Stripe to work with payment methods. More info about that can be found here: https://stripe.com/docs/payments/payment-methods#transitioning In an upcoming update all card methods as well as the create method on the subscription builder will be updated as well. Closes https://github.com/laravel/cashier/issues/636 --- ...5_03_000002_create_subscriptions_table.php | 1 + src/Billable.php | 39 +++-- src/Exceptions/IncompletePayment.php | 33 ++++ src/Exceptions/PaymentActionRequired.php | 22 +++ src/Exceptions/PaymentFailure.php | 22 +++ src/Exceptions/SubscriptionCreationFailed.php | 14 -- src/Http/Controllers/WebhookController.php | 9 ++ src/Payment.php | 146 ++++++++++++++++++ src/Subscription.php | 65 ++++++-- src/SubscriptionBuilder.php | 26 ++-- tests/Integration/ChargesTest.php | 40 +++-- tests/Integration/SubscriptionsTest.php | 119 +++++++++++--- tests/Unit/SubscriptionTest.php | 39 +++++ 13 files changed, 500 insertions(+), 75 deletions(-) create mode 100644 src/Exceptions/IncompletePayment.php create mode 100644 src/Exceptions/PaymentActionRequired.php create mode 100644 src/Exceptions/PaymentFailure.php delete mode 100644 src/Exceptions/SubscriptionCreationFailed.php create mode 100644 src/Payment.php create mode 100644 tests/Unit/SubscriptionTest.php diff --git a/database/migrations/2019_05_03_000002_create_subscriptions_table.php b/database/migrations/2019_05_03_000002_create_subscriptions_table.php index 9143a7d4..470e539d 100644 --- a/database/migrations/2019_05_03_000002_create_subscriptions_table.php +++ b/database/migrations/2019_05_03_000002_create_subscriptions_table.php @@ -17,6 +17,7 @@ public function up() $table->bigIncrements('id'); $table->unsignedBigInteger('user_id'); $table->string('name'); + $table->string('status'); $table->string('stripe_id')->collation('utf8mb4_bin'); $table->string('stripe_plan'); $table->integer('quantity'); diff --git a/src/Billable.php b/src/Billable.php index 2d869a9b..e754a2f3 100644 --- a/src/Billable.php +++ b/src/Billable.php @@ -7,12 +7,12 @@ use Stripe\Card as StripeCard; use Stripe\Token as StripeToken; use Illuminate\Support\Collection; -use Stripe\Charge as StripeCharge; -use Stripe\Refund as StripeRefund; use Stripe\Invoice as StripeInvoice; use Stripe\Customer as StripeCustomer; use Stripe\BankAccount as StripeBankAccount; use Stripe\InvoiceItem as StripeInvoiceItem; +use Stripe\Error\Card as StripeCardException; +use Stripe\PaymentIntent as StripePaymentIntent; use Laravel\Cashier\Exceptions\InvalidStripeCustomer; use Stripe\Error\InvalidRequest as StripeErrorInvalidRequest; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -25,12 +25,14 @@ trait Billable * * @param int $amount * @param array $options - * @return \Stripe\Charge + * @return \Laravel\Cashier\Payment * @throws \InvalidArgumentException */ public function charge($amount, array $options = []) { $options = array_merge([ + 'confirmation_method' => 'automatic', + 'confirm' => true, 'currency' => $this->preferredCurrency(), ], $options); @@ -40,26 +42,32 @@ public function charge($amount, array $options = []) $options['customer'] = $this->stripe_id; } - if (! array_key_exists('source', $options) && ! array_key_exists('customer', $options)) { - throw new InvalidArgumentException('No payment source provided.'); + if (! array_key_exists('payment_method', $options) && ! array_key_exists('customer', $options)) { + throw new InvalidArgumentException('No payment method provided.'); } - return StripeCharge::create($options, Cashier::stripeOptions()); + $payment = new Payment( + StripePaymentIntent::create($options, Cashier::stripeOptions()) + ); + + $payment->validate(); + + return $payment; } /** * Refund a customer for a charge. * - * @param string $charge + * @param string $paymentIntent * @param array $options * @return \Stripe\Refund * @throws \InvalidArgumentException */ - public function refund($charge, array $options = []) + public function refund($paymentIntent, array $options = []) { - $options['charge'] = $charge; + $intent = StripePaymentIntent::retrieve($paymentIntent, Cashier::stripeOptions()); - return StripeRefund::create($options, Cashier::stripeOptions()); + return $intent->charges->data[0]->refund($options); } /** @@ -217,9 +225,18 @@ public function invoice(array $options = []) $parameters = array_merge($options, ['customer' => $this->stripe_id]); try { - return StripeInvoice::create($parameters, Cashier::stripeOptions())->pay(); + /** @var \Stripe\Invoice $invoice */ + $invoice = StripeInvoice::create($parameters, Cashier::stripeOptions()); + + return $invoice->pay(); } catch (StripeErrorInvalidRequest $e) { return false; + } catch (StripeCardException $exception) { + $payment = new Payment( + StripePaymentIntent::retrieve($invoice->refresh()->payment_intent, Cashier::stripeOptions()) + ); + + $payment->validate(); } } diff --git a/src/Exceptions/IncompletePayment.php b/src/Exceptions/IncompletePayment.php new file mode 100644 index 00000000..845cd838 --- /dev/null +++ b/src/Exceptions/IncompletePayment.php @@ -0,0 +1,33 @@ +payment = $payment; + } +} diff --git a/src/Exceptions/PaymentActionRequired.php b/src/Exceptions/PaymentActionRequired.php new file mode 100644 index 00000000..7ea4f330 --- /dev/null +++ b/src/Exceptions/PaymentActionRequired.php @@ -0,0 +1,22 @@ +plan->nickname}\" for customer \"{$subscription->customer}\" failed because the subscription was incomplete. For more information on incomplete subscriptions, see https://stripe.com/docs/billing/lifecycle#incomplete"); - } -} diff --git a/src/Http/Controllers/WebhookController.php b/src/Http/Controllers/WebhookController.php index e9e81bba..b2c8af59 100644 --- a/src/Http/Controllers/WebhookController.php +++ b/src/Http/Controllers/WebhookController.php @@ -86,6 +86,15 @@ protected function handleCustomerSubscriptionUpdated(array $payload) } } + // Status... + if (isset($data['status'])) { + if (in_array($data['status'], ['incomplete', 'incomplete_expired'])) { + $subscription->status = 'incomplete'; + } else { + $subscription->status = 'active'; + } + } + $subscription->save(); }); } diff --git a/src/Payment.php b/src/Payment.php new file mode 100644 index 00000000..2454c54d --- /dev/null +++ b/src/Payment.php @@ -0,0 +1,146 @@ +paymentIntent = $paymentIntent; + } + + /** + * The Stripe PaymentIntent ID. + * + * @return string + */ + public function id() + { + return $this->paymentIntent->id; + } + + /** + * Get the total amount that will be paid. + * + * @return string + */ + public function amount() + { + return Cashier::formatAmount($this->rawAmount(), $this->paymentIntent->currency); + } + + /** + * Get the raw total amount that will be paid. + * + * @return int + */ + public function rawAmount() + { + return $this->paymentIntent->amount; + } + + /** + * The Stripe PaymentIntent client secret. + * + * @return string + */ + public function clientSecret() + { + return $this->paymentIntent->client_secret; + } + + /** + * Determine if the payment needs a valid payment method. + * + * @return bool + */ + public function requiresPaymentMethod() + { + return $this->paymentIntent->status === 'requires_payment_method'; + } + + /** + * Determine if the payment needs an extra action like 3D Secure. + * + * @return bool + */ + public function requiresAction() + { + return $this->paymentIntent->status === 'requires_action'; + } + + /** + * Determine if the payment was cancelled. + * + * @return bool + */ + public function isCancelled() + { + return $this->paymentIntent->status === 'cancelled'; + } + + /** + * Determine if the payment was successful. + * + * @return bool + */ + public function isSucceeded() + { + return $this->paymentIntent->status === 'succeeded'; + } + + /** + * Validate if the payment intent was successful and throw an exception if not. + * + * @return void + * + * @throws \Laravel\Cashier\Exceptions\PaymentActionRequired + * @throws \Laravel\Cashier\Exceptions\PaymentFailure + */ + public function validate() + { + if ($this->requiresPaymentMethod()) { + throw PaymentFailure::cardError($this); + } elseif ($this->requiresAction()) { + throw PaymentActionRequired::incomplete($this); + } + } + + /** + * The Stripe PaymentIntent instance. + * + * @return \Stripe\PaymentIntent + */ + public function asStripePaymentIntent() + { + return $this->paymentIntent; + } + + /** + * Dynamically get values from the Stripe PaymentIntent. + * + * @param string $key + * @return mixed + */ + public function __get($key) + { + return $this->paymentIntent->{$key}; + } +} diff --git a/src/Subscription.php b/src/Subscription.php index 9e0de3dc..9baa82fd 100644 --- a/src/Subscription.php +++ b/src/Subscription.php @@ -5,8 +5,8 @@ use Carbon\Carbon; use LogicException; use DateTimeInterface; -use Stripe\Error\Card as StripeCard; use Illuminate\Database\Eloquent\Model; +use Laravel\Cashier\Exceptions\IncompletePayment; class Subscription extends Model { @@ -73,6 +73,37 @@ public function valid() return $this->active() || $this->onTrial() || $this->onGracePeriod(); } + /** + * Determine if the subscription is incomplete. + * + * @return bool + */ + public function incomplete() + { + return $this->status === 'incomplete'; + } + + /** + * Filter query by incomplete. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @return void + */ + public function scopeIncomplete($query) + { + $query->where('status', 'incomplete'); + } + + /** + * Mark the subscription as incomplete. + * + * @return void + */ + public function markAsIncomplete() + { + $this->fill(['status' => 'incomplete'])->save(); + } + /** * Determine if the subscription is active. * @@ -80,7 +111,7 @@ public function valid() */ public function active() { - return is_null($this->ends_at) || $this->onGracePeriod(); + return (is_null($this->ends_at) || $this->onGracePeriod()) && ! $this->incomplete(); } /** @@ -91,11 +122,21 @@ public function active() */ public function scopeActive($query) { - $query->whereNull('ends_at')->orWhere(function ($query) { + $query->whereNull('ends_at')->where('status', '!=', 'incomplete')->orWhere(function ($query) { $query->onGracePeriod(); }); } + /** + * Mark the subscription as active. + * + * @return void + */ + public function markAsActive() + { + $this->fill(['status' => 'active'])->save(); + } + /** * Determine if the subscription is recurring and not on trial. * @@ -348,6 +389,8 @@ public function skipTrial() * @param string $plan * @param array $options * @return $this + * + * @throws \Laravel\Cashier\Exceptions\IncompletePayment */ public function swap($plan, $options = []) { @@ -385,19 +428,19 @@ public function swap($plan, $options = []) $subscription->save(); - try { - $this->user->invoice(['subscription' => $subscription->id]); - } catch (StripeCard $exception) { - // When the payment for the plan swap fails, we continue to let the user swap to the - // new plan. This is because Stripe may attempt to retry the payment later on. If - // all attempts to collect payment fail, webhooks will handle any update to it. - } - $this->fill([ 'stripe_plan' => $plan, 'ends_at' => null, ])->save(); + try { + $this->user->invoice(['subscription' => $subscription->id]); + } catch (IncompletePayment $exception) { + $this->markAsIncomplete(); + + throw $exception; + } + return $this; } diff --git a/src/SubscriptionBuilder.php b/src/SubscriptionBuilder.php index 4846b81e..bf9f2961 100644 --- a/src/SubscriptionBuilder.php +++ b/src/SubscriptionBuilder.php @@ -4,7 +4,6 @@ use Carbon\Carbon; use DateTimeInterface; -use Laravel\Cashier\Exceptions\SubscriptionCreationFailed; class SubscriptionBuilder { @@ -202,13 +201,8 @@ public function create($token = null, array $options = []) { $customer = $this->getStripeCustomer($token, $options); - $subscription = $customer->subscriptions->create($this->buildPayload()); - - if (in_array($subscription->status, ['incomplete', 'incomplete_expired'])) { - $subscription->cancel(); - - throw SubscriptionCreationFailed::incomplete($subscription); - } + /** @var \Stripe\Subscription $stripeSubscription */ + $stripeSubscription = $customer->subscriptions->create($this->buildPayload()); if ($this->skipTrial) { $trialEndsAt = null; @@ -216,14 +210,25 @@ public function create($token = null, array $options = []) $trialEndsAt = $this->trialExpires; } - return $this->owner->subscriptions()->create([ + $subscription = $this->owner->subscriptions()->create([ 'name' => $this->name, - 'stripe_id' => $subscription->id, + 'status' => 'active', + 'stripe_id' => $stripeSubscription->id, 'stripe_plan' => $this->plan, 'quantity' => $this->quantity, 'trial_ends_at' => $trialEndsAt, 'ends_at' => null, ]); + + if ($stripeSubscription->status === 'incomplete') { + $subscription->markAsIncomplete(); + + $payment = new Payment($stripeSubscription->latest_invoice->payment_intent); + + $payment->validate(); + } + + return $subscription; } /** @@ -259,6 +264,7 @@ protected function buildPayload() 'quantity' => $this->quantity, 'tax_percent' => $this->getTaxPercentageForPayload(), 'trial_end' => $this->getTrialEndForPayload(), + 'expand' => ['latest_invoice.payment_intent'], ]); } diff --git a/tests/Integration/ChargesTest.php b/tests/Integration/ChargesTest.php index 3b641b0b..d8e290e1 100644 --- a/tests/Integration/ChargesTest.php +++ b/tests/Integration/ChargesTest.php @@ -2,8 +2,9 @@ namespace Laravel\Cashier\Tests\Integration; -use Stripe\Charge; +use Laravel\Cashier\Payment; use Stripe\Error\InvalidRequest; +use Laravel\Cashier\Exceptions\PaymentActionRequired; class ChargesTest extends IntegrationTestCase { @@ -15,29 +16,29 @@ public function test_customer_can_be_charged() $response = $user->charge(1000); - $this->assertInstanceOf(Charge::class, $response); - $this->assertEquals(1000, $response->amount); + $this->assertInstanceOf(Payment::class, $response); + $this->assertEquals(1000, $response->rawAmount()); $this->assertEquals($user->stripe_id, $response->customer); } - public function test_customer_cannot_be_charged_with_custom_source() + public function test_customer_cannot_be_charged_without_a_payment_method() { - $user = $this->createCustomer('customer_can_be_charged_with_custom_source'); + $user = $this->createCustomer('customer_cannot_be_charged_without_a_payment_method'); $user->createAsStripeCustomer(); $this->expectException(InvalidRequest::class); - $user->charge(1000, ['source' => 'tok_visa']); + $user->charge(1000); } public function test_non_stripe_customer_can_be_charged() { $user = $this->createCustomer('non_stripe_customer_can_be_charged'); - $response = $user->charge(1000, ['source' => 'tok_visa']); + $response = $user->charge(1000, ['payment_method' => 'pm_card_visa']); - $this->assertInstanceOf(Charge::class, $response); - $this->assertEquals(1000, $response->amount); + $this->assertInstanceOf(Payment::class, $response); + $this->assertEquals(1000, $response->rawAmount()); $this->assertNull($response->customer); } @@ -61,8 +62,27 @@ public function test_customer_can_be_refunded() $user->updateCard('tok_visa'); $invoice = $user->invoiceFor('Laravel Cashier', 1000); - $refund = $user->refund($invoice->charge); + $refund = $user->refund($invoice->payment_intent); $this->assertEquals(1000, $refund->amount); } + + public function test_charging_may_require_an_extra_action() + { + $user = $this->createCustomer('charging_may_require_an_extra_action'); + $user->createAsStripeCustomer(); + $user->updateCard('tok_threeDSecure2Required'); + + try { + $user->charge(1000); + + $this->fail('Expected exception '.PaymentActionRequired::class.' was not thrown.'); + } catch (PaymentActionRequired $e) { + // Assert that the payment needs an extra action. + $this->assertTrue($e->payment->requiresAction()); + + // Assert that the payment was for the correct amount. + $this->assertEquals(1000, $e->payment->rawAmount()); + } + } } diff --git a/tests/Integration/SubscriptionsTest.php b/tests/Integration/SubscriptionsTest.php index e3c8a389..4dbf89dc 100644 --- a/tests/Integration/SubscriptionsTest.php +++ b/tests/Integration/SubscriptionsTest.php @@ -8,7 +8,9 @@ use Stripe\Coupon; use Stripe\Product; use Illuminate\Support\Str; -use Laravel\Cashier\Exceptions\SubscriptionCreationFailed; +use Laravel\Cashier\Subscription; +use Laravel\Cashier\Exceptions\PaymentFailure; +use Laravel\Cashier\Exceptions\PaymentActionRequired; class SubscriptionsTest extends IntegrationTestCase { @@ -193,37 +195,96 @@ public function test_swapping_subscription_with_coupon() $this->assertEquals(static::$couponId, $subscription->asStripeSubscription()->discount->coupon->id); } - public function test_creating_subscription_fails_when_card_is_declined() + public function test_declined_card_during_subscribing_results_in_an_exception() { - $user = $this->createCustomer('creating_subscription_fails_when_card_is_declined'); + $user = $this->createCustomer('declined_card_during_subscribing_results_in_an_exception'); try { $user->newSubscription('main', static::$planId)->create('tok_chargeCustomerFail'); - $this->fail('Expected exception '.SubscriptionCreationFailed::class.' was not thrown.'); - } catch (SubscriptionCreationFailed $e) { - // Assert no subscription was added to the billable entity. - $this->assertEmpty($user->subscriptions); + $this->fail('Expected exception '.PaymentFailure::class.' was not thrown.'); + } catch (PaymentFailure $e) { + // Assert that the payment needs a valid card. + $this->assertTrue($e->payment->requiresPaymentMethod()); - // Assert subscription was cancelled. - $this->assertEmpty($user->asStripeCustomer()->subscriptions->data); + // Assert subscription was added to the billable entity. + $this->assertInstanceOf(Subscription::class, $subscription = $user->subscription('main')); + + // Assert subscription is incomplete. + $this->assertTrue($subscription->incomplete()); + } + } + + public function test_next_action_needed_during_subscribing_results_in_an_exception() + { + $user = $this->createCustomer('next_action_needed_during_subscribing_results_in_an_exception'); + + try { + $user->newSubscription('main', static::$planId)->create('tok_threeDSecure2Required'); + + $this->fail('Expected exception '.PaymentActionRequired::class.' was not thrown.'); + } catch (PaymentActionRequired $e) { + // Assert that the payment needs an extra action. + $this->assertTrue($e->payment->requiresAction()); + + // Assert subscription was added to the billable entity. + $this->assertInstanceOf(Subscription::class, $subscription = $user->subscription('main')); + + // Assert subscription is incomplete. + $this->assertTrue($subscription->incomplete()); } } - public function test_plan_swap_succeeds_even_if_payment_fails() + public function test_declined_card_during_plan_swap_results_in_an_exception() { - $user = $this->createCustomer('plan_swap_succeeds_even_if_payment_fails'); + $user = $this->createCustomer('declined_card_during_plan_swap_results_in_an_exception'); $subscription = $user->newSubscription('main', static::$planId)->create('tok_visa'); // Set a faulty card as the customer's default card. $user->updateCard('tok_chargeCustomerFail'); - // Attempt to swap and pay with a faulty card. - $subscription = $subscription->swap(static::$premiumPlanId); + try { + // Attempt to swap and pay with a faulty card. + $subscription = $subscription->swap(static::$premiumPlanId); + + $this->fail('Expected exception '.PaymentFailure::class.' was not thrown.'); + } catch (PaymentFailure $e) { + // Assert that the payment needs a valid card. + $this->assertTrue($e->payment->requiresPaymentMethod()); + + // Assert that the plan was swapped anyway. + $this->assertEquals(static::$premiumPlanId, $subscription->refresh()->stripe_plan); - // Assert that the plan was swapped. - $this->assertEquals(static::$premiumPlanId, $subscription->stripe_plan); + // Assert subscription is incomplete. + $this->assertTrue($subscription->incomplete()); + } + } + + public function test_next_action_needed_during_plan_swap_results_in_an_exception() + { + $user = $this->createCustomer('next_action_needed_during_plan_swap_results_in_an_exception'); + + $subscription = $user->newSubscription('main', static::$planId)->create('tok_visa'); + + // Set a card that requires a next action as the customer's default card. + $user->updateCard('tok_threeDSecure2Required'); + + try { + // Attempt to swap and pay with a faulty card. + $subscription = $subscription->swap(static::$premiumPlanId); + + $this->fail('Expected exception '.PaymentActionRequired::class.' was not thrown.'); + } catch (PaymentActionRequired $e) { + // Assert that the payment needs an extra action. + $this->assertTrue($e->payment->requiresAction()); + + // Assert that the plan was swapped anyway. + $this->assertEquals(static::$premiumPlanId, $subscription->refresh()->stripe_plan); + + // Assert subscription is incomplete. + $this->assertTrue($subscription->incomplete()); + } } public function test_creating_subscription_with_coupons() @@ -378,8 +439,10 @@ public function test_subscription_state_scopes() { $user = $this->createCustomer('subscription_state_scopes'); + // Start with an incomplete subscription. $subscription = $user->subscriptions()->create([ 'name' => 'yearly', + 'status' => 'incomplete', 'stripe_id' => 'xxxx', 'stripe_plan' => 'stripe-yearly', 'quantity' => 1, @@ -387,7 +450,22 @@ public function test_subscription_state_scopes() 'ends_at' => null, ]); - // subscription is active + // Subscription is incomplete + $this->assertTrue($user->subscriptions()->incomplete()->exists()); + $this->assertFalse($user->subscriptions()->active()->exists()); + $this->assertFalse($user->subscriptions()->onTrial()->exists()); + $this->assertTrue($user->subscriptions()->notOnTrial()->exists()); + $this->assertTrue($user->subscriptions()->recurring()->exists()); + $this->assertFalse($user->subscriptions()->cancelled()->exists()); + $this->assertTrue($user->subscriptions()->notCancelled()->exists()); + $this->assertFalse($user->subscriptions()->onGracePeriod()->exists()); + $this->assertTrue($user->subscriptions()->notOnGracePeriod()->exists()); + $this->assertFalse($user->subscriptions()->ended()->exists()); + + // Activate. + $subscription->update(['status' => 'active']); + + $this->assertFalse($user->subscriptions()->incomplete()->exists()); $this->assertTrue($user->subscriptions()->active()->exists()); $this->assertFalse($user->subscriptions()->onTrial()->exists()); $this->assertTrue($user->subscriptions()->notOnTrial()->exists()); @@ -398,9 +476,10 @@ public function test_subscription_state_scopes() $this->assertTrue($user->subscriptions()->notOnGracePeriod()->exists()); $this->assertFalse($user->subscriptions()->ended()->exists()); - // put on trial + // Put on trial. $subscription->update(['trial_ends_at' => Carbon::now()->addDay()]); + $this->assertFalse($user->subscriptions()->incomplete()->exists()); $this->assertTrue($user->subscriptions()->active()->exists()); $this->assertTrue($user->subscriptions()->onTrial()->exists()); $this->assertFalse($user->subscriptions()->notOnTrial()->exists()); @@ -411,9 +490,10 @@ public function test_subscription_state_scopes() $this->assertTrue($user->subscriptions()->notOnGracePeriod()->exists()); $this->assertFalse($user->subscriptions()->ended()->exists()); - // put on grace period + // Put on grace period. $subscription->update(['ends_at' => Carbon::now()->addDay()]); + $this->assertFalse($user->subscriptions()->incomplete()->exists()); $this->assertTrue($user->subscriptions()->active()->exists()); $this->assertTrue($user->subscriptions()->onTrial()->exists()); $this->assertFalse($user->subscriptions()->notOnTrial()->exists()); @@ -424,9 +504,10 @@ public function test_subscription_state_scopes() $this->assertFalse($user->subscriptions()->notOnGracePeriod()->exists()); $this->assertFalse($user->subscriptions()->ended()->exists()); - // end subscription + // End subscription. $subscription->update(['ends_at' => Carbon::now()->subDay()]); + $this->assertFalse($user->subscriptions()->incomplete()->exists()); $this->assertFalse($user->subscriptions()->active()->exists()); $this->assertTrue($user->subscriptions()->onTrial()->exists()); $this->assertFalse($user->subscriptions()->notOnTrial()->exists()); diff --git a/tests/Unit/SubscriptionTest.php b/tests/Unit/SubscriptionTest.php new file mode 100644 index 00000000..ae224168 --- /dev/null +++ b/tests/Unit/SubscriptionTest.php @@ -0,0 +1,39 @@ + 'incomplete']); + + $this->assertTrue($subscription->incomplete()); + $this->assertFalse($subscription->active()); + } + + public function test_we_can_check_if_a_subscription_is_active() + { + $subscription = new Subscription(['status' => 'active']); + + $this->assertFalse($subscription->incomplete()); + $this->assertTrue($subscription->active()); + } + + public function test_an_incomplete_subscription_is_not_valid() + { + $subscription = new Subscription(['status' => 'incomplete']); + + $this->assertFalse($subscription->valid()); + } + + public function test_an_active_subscription_is_valid() + { + $subscription = new Subscription(['status' => 'active']); + + $this->assertTrue($subscription->valid()); + } +}