From 701868903a39f331a63c0fe6e9bfc8ca5b0ab433 Mon Sep 17 00:00:00 2001 From: Dries Vints Date: Fri, 29 May 2020 18:04:49 +0200 Subject: [PATCH] [12.x] Implement new proration and pending updates (#949) Implement new proration and pending updates --- src/Concerns/InteractsWithPaymentBehavior.php | 59 +++++++ src/Concerns/Prorates.php | 28 +++- src/Invoice.php | 13 ++ src/Subscription.php | 77 ++++++--- src/SubscriptionBuilder.php | 7 + src/SubscriptionItem.php | 15 +- tests/Feature/MultiplanSubscriptionsTest.php | 6 +- tests/Feature/PendingUpdatesTest.php | 147 ++++++++++++++++++ tests/Feature/SubscriptionsTest.php | 16 +- 9 files changed, 325 insertions(+), 43 deletions(-) create mode 100644 src/Concerns/InteractsWithPaymentBehavior.php create mode 100644 tests/Feature/PendingUpdatesTest.php diff --git a/src/Concerns/InteractsWithPaymentBehavior.php b/src/Concerns/InteractsWithPaymentBehavior.php new file mode 100644 index 00000000..e422c70b --- /dev/null +++ b/src/Concerns/InteractsWithPaymentBehavior.php @@ -0,0 +1,59 @@ +paymentBehavior = 'allow_incomplete'; + + return $this; + } + + /** + * Set any subscription change as pending until payment is successful. + * + * @return $this + */ + public function pendingIfPaymentFails() + { + $this->paymentBehavior = 'pending_if_incomplete'; + + return $this; + } + + /** + * Prevent any subscription change if payment is unsuccessful. + * + * @return $this + */ + public function errorIfPaymentFails() + { + $this->paymentBehavior = 'error_if_incomplete'; + + return $this; + } + + /** + * Determine the payment behavior when updating the subscription. + * + * @return string + */ + public function paymentBehavior() + { + return $this->paymentBehavior; + } +} diff --git a/src/Concerns/Prorates.php b/src/Concerns/Prorates.php index 99ce6767..fc226e41 100644 --- a/src/Concerns/Prorates.php +++ b/src/Concerns/Prorates.php @@ -7,9 +7,9 @@ trait Prorates /** * Indicates if the plan change should be prorated. * - * @var bool + * @var string */ - protected $prorate = true; + protected $prorationBehavior = 'create_prorations'; /** * Indicate that the plan change should not be prorated. @@ -18,7 +18,7 @@ trait Prorates */ public function noProrate() { - $this->prorate = false; + $this->prorationBehavior = 'none'; return $this; } @@ -30,7 +30,19 @@ public function noProrate() */ public function prorate() { - $this->prorate = true; + $this->prorationBehavior = 'create_prorations'; + + return $this; + } + + /** + * Indicate that the plan change should always be invoiced. + * + * @return $this + */ + public function alwaysInvoice() + { + $this->prorationBehavior = 'always_invoice'; return $this; } @@ -38,12 +50,12 @@ public function prorate() /** * Set the prorating behavior. * - * @param bool $prorate + * @param string $prorationBehavior * @return $this */ - public function setProrate($prorate) + public function setProrationBehavior($prorationBehavior) { - $this->prorate = $prorate; + $this->prorationBehavior = $prorationBehavior; return $this; } @@ -55,6 +67,6 @@ public function setProrate($prorate) */ public function prorateBehavior() { - return $this->prorate ? 'create_prorations' : 'none'; + return $this->prorationBehavior; } } diff --git a/src/Invoice.php b/src/Invoice.php index 9586b13d..dae75fc6 100644 --- a/src/Invoice.php +++ b/src/Invoice.php @@ -452,6 +452,19 @@ public function downloadAs($filename, array $data) ]); } + /** + * Void the Stripe invoice. + * + * @param array $options + * @return $this + */ + public function void(array $options = []) + { + $this->invoice = $this->invoice->voidInvoice($options, $this->owner->stripeOptions()); + + return $this; + } + /** * Get the Stripe model instance. * diff --git a/src/Subscription.php b/src/Subscription.php index b7f8fe0f..7ffb2929 100644 --- a/src/Subscription.php +++ b/src/Subscription.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use InvalidArgumentException; +use Laravel\Cashier\Concerns\InteractsWithPaymentBehavior; use Laravel\Cashier\Concerns\Prorates; use Laravel\Cashier\Exceptions\IncompletePayment; use Laravel\Cashier\Exceptions\SubscriptionUpdateFailure; @@ -16,6 +17,7 @@ class Subscription extends Model { + use InteractsWithPaymentBehavior; use Prorates; /** @@ -394,7 +396,7 @@ public function incrementQuantity($count = 1, $plan = null) $this->guardAgainstIncomplete(); if ($plan) { - $this->findItemOrFail($plan)->setProrate($this->prorate)->incrementQuantity($count); + $this->findItemOrFail($plan)->setProrationBehavior($this->prorationBehavior)->incrementQuantity($count); return $this->refresh(); } @@ -420,8 +422,10 @@ public function incrementAndInvoice($count = 1, $plan = null) { $this->guardAgainstIncomplete(); + $this->alwaysInvoice(); + if ($plan) { - $this->findItemOrFail($plan)->setProrate($this->prorate)->incrementQuantity($count); + $this->findItemOrFail($plan)->setProrationBehavior($this->prorationBehavior)->incrementQuantity($count); return $this->refresh(); } @@ -430,8 +434,6 @@ public function incrementAndInvoice($count = 1, $plan = null) $this->incrementQuantity($count, $plan); - $this->invoice(); - return $this; } @@ -449,7 +451,7 @@ public function decrementQuantity($count = 1, $plan = null) $this->guardAgainstIncomplete(); if ($plan) { - $this->findItemOrFail($plan)->setProrate($this->prorate)->decrementQuantity($count); + $this->findItemOrFail($plan)->setProrationBehavior($this->prorationBehavior)->decrementQuantity($count); return $this->refresh(); } @@ -473,7 +475,7 @@ public function updateQuantity($quantity, $plan = null) $this->guardAgainstIncomplete(); if ($plan) { - $this->findItemOrFail($plan)->setProrate($this->prorate)->updateQuantity($quantity); + $this->findItemOrFail($plan)->setProrationBehavior($this->prorationBehavior)->updateQuantity($quantity); return $this->refresh(); } @@ -483,7 +485,7 @@ public function updateQuantity($quantity, $plan = null) $stripeSubscription = $this->asStripeSubscription(); $stripeSubscription->quantity = $quantity; - + $stripeSubscription->payment_behavior = $this->paymentBehavior(); $stripeSubscription->proration_behavior = $this->prorateBehavior(); $stripeSubscription->save(); @@ -577,6 +579,7 @@ public function swap($plans, $options = []) ); $this->fill([ + 'stripe_status' => $stripeSubscription->status, 'stripe_plan' => $stripeSubscription->plan ? $stripeSubscription->plan->id : null, 'quantity' => $stripeSubscription->quantity, 'ends_at' => null, @@ -596,6 +599,12 @@ public function swap($plans, $options = []) $this->unsetRelation('items'); + if ($stripeSubscription->latest_invoice->payment_intent) { + (new Payment( + $stripeSubscription->latest_invoice->payment_intent + ))->validate(); + } + return $this; } @@ -611,11 +620,9 @@ public function swap($plans, $options = []) */ public function swapAndInvoice($plans, $options = []) { - $subscription = $this->swap($plans, $options); - - $this->invoice(); + $this->alwaysInvoice(); - return $subscription; + return $this->swap($plans, $options); } /** @@ -668,21 +675,28 @@ protected function mergeItemsThatShouldBeDeletedDuringSwap(Collection $items) */ protected function getSwapOptions(Collection $items, $options) { - $options = array_merge([ + $payload = [ 'items' => $items->values()->all(), + 'payment_behavior' => $this->paymentBehavior(), 'proration_behavior' => $this->prorateBehavior(), - 'cancel_at_period_end' => false, - ], $options); + 'expand' => ['latest_invoice.payment_intent'], + ]; + + if ($payload['payment_behavior'] !== 'pending_if_incomplete') { + $payload['cancel_at_period_end'] = false; + } + + $payload = array_merge($payload, $options); if (! is_null($this->billingCycleAnchor)) { - $options['billing_cycle_anchor'] = $this->billingCycleAnchor; + $payload['billing_cycle_anchor'] = $this->billingCycleAnchor; } - $options['trial_end'] = $this->onTrial() + $payload['trial_end'] = $this->onTrial() ? $this->trial_ends_at->getTimestamp() : 'now'; - return $options; + return $payload; } /** @@ -709,6 +723,7 @@ public function addPlan($plan, $quantity = 1, $options = []) 'plan' => $plan, 'quantity' => $quantity, 'tax_rates' => $this->getPlanTaxRatesForPayload($plan), + 'payment_behavior' => $this->paymentBehavior(), 'proration_behavior' => $this->prorateBehavior(), ], $options)); @@ -743,11 +758,9 @@ public function addPlan($plan, $quantity = 1, $options = []) */ public function addPlanAndInvoice($plan, $quantity = 1, $options = []) { - $subscription = $this->addPlan($plan, $quantity, $options); + $this->alwaysInvoice(); - $this->invoice(); - - return $subscription; + return $this->addPlan($plan, $quantity, $options); } /** @@ -883,6 +896,16 @@ public function resume() return $this; } + /** + * Determine if the subscription has pending updates. + * + * @return bool + */ + public function pending() + { + return $this->asStripeSubscription()->pending_update !== null; + } + /** * Invoice the subscription outside of the regular billing cycle. * @@ -905,6 +928,18 @@ public function invoice(array $options = []) } } + /** + * Get the latest invoice for the subscription. + * + * @return \Laravel\Cashier\Invoice + */ + public function latestInvoice() + { + $stripeSubscription = $this->asStripeSubscription(['latest_invoice']); + + return new Invoice($this->owner, $stripeSubscription->latest_invoice); + } + /** * Sync the tax percentage of the user to the subscription. * diff --git a/src/SubscriptionBuilder.php b/src/SubscriptionBuilder.php index 204518c2..92755b92 100644 --- a/src/SubscriptionBuilder.php +++ b/src/SubscriptionBuilder.php @@ -6,10 +6,15 @@ use DateTimeInterface; use Illuminate\Support\Arr; use InvalidArgumentException; +use Laravel\Cashier\Concerns\InteractsWithPaymentBehavior; +use Laravel\Cashier\Concerns\Prorates; use Stripe\Subscription as StripeSubscription; class SubscriptionBuilder { + use InteractsWithPaymentBehavior; + use Prorates; + /** * The model that is subscribing. * @@ -315,6 +320,8 @@ protected function buildPayload() 'expand' => ['latest_invoice.payment_intent'], 'metadata' => $this->metadata, 'items' => collect($this->items)->values()->all(), + 'payment_behavior' => $this->paymentBehavior(), + 'proration_behavior' => $this->prorateBehavior(), 'trial_end' => $this->getTrialEndForPayload(), 'off_session' => true, ]); diff --git a/src/SubscriptionItem.php b/src/SubscriptionItem.php index f9c8faea..ba09cc0f 100644 --- a/src/SubscriptionItem.php +++ b/src/SubscriptionItem.php @@ -3,6 +3,7 @@ namespace Laravel\Cashier; use Illuminate\Database\Eloquent\Model; +use Laravel\Cashier\Concerns\InteractsWithPaymentBehavior; use Laravel\Cashier\Concerns\Prorates; use Stripe\SubscriptionItem as StripeSubscriptionItem; @@ -11,6 +12,7 @@ */ class SubscriptionItem extends Model { + use InteractsWithPaymentBehavior; use Prorates; /** @@ -65,9 +67,9 @@ public function incrementQuantity($count = 1) */ public function incrementAndInvoice($count = 1) { - $this->incrementQuantity($count); + $this->alwaysInvoice(); - $this->subscription->invoice(); + $this->incrementQuantity($count); return $this; } @@ -103,6 +105,8 @@ public function updateQuantity($quantity) $stripeSubscriptionItem->quantity = $quantity; + $stripeSubscriptionItem->payment_behavior = $this->paymentBehavior(); + $stripeSubscriptionItem->proration_behavior = $this->prorateBehavior(); $stripeSubscriptionItem->save(); @@ -136,6 +140,7 @@ public function swap($plan, $options = []) $options = array_merge([ 'plan' => $plan, 'quantity' => $this->quantity, + 'payment_behavior' => $this->paymentBehavior(), 'proration_behavior' => $this->prorateBehavior(), 'tax_rates' => $this->subscription->getPlanTaxRatesForPayload($plan), ], $options); @@ -173,11 +178,9 @@ public function swap($plan, $options = []) */ public function swapAndInvoice($plan, $options = []) { - $item = $this->swap($plan, $options); - - $this->subscription->invoice(); + $this->alwaysInvoice(); - return $item; + return $this->swap($plan, $options); } /** diff --git a/tests/Feature/MultiplanSubscriptionsTest.php b/tests/Feature/MultiplanSubscriptionsTest.php index 2e3d1d81..eb109919 100644 --- a/tests/Feature/MultiplanSubscriptionsTest.php +++ b/tests/Feature/MultiplanSubscriptionsTest.php @@ -295,20 +295,22 @@ public function test_subscription_item_changes_can_be_prorated() $this->assertEquals(2000, ($invoice = $user->invoices()->first())->rawTotal()); - $subscription->noProrate()->addPlanAndInvoice(self::$otherPlanId); + $subscription->noProrate()->addPlan(self::$otherPlanId); // Assert that no new invoice was created because of no prorating. $this->assertEquals($invoice->id, $user->invoices()->first()->id); - $subscription->prorate()->addPlanAndInvoice(self::$planId); + $subscription->addPlanAndInvoice(self::$planId); // Assert that a new invoice was created because of no prorating. $this->assertEquals(1000, ($invoice = $user->invoices()->first())->rawTotal()); + $this->assertEquals(4000, $user->upcomingInvoice()->rawTotal()); $subscription->noProrate()->removePlan(self::$premiumPlanId); // Assert that no new invoice was created because of no prorating. $this->assertEquals($invoice->id, $user->invoices()->first()->id); + $this->assertEquals(2000, $user->upcomingInvoice()->rawTotal()); } public function test_subscription_item_quantity_changes_can_be_prorated() diff --git a/tests/Feature/PendingUpdatesTest.php b/tests/Feature/PendingUpdatesTest.php new file mode 100644 index 00000000..6547a191 --- /dev/null +++ b/tests/Feature/PendingUpdatesTest.php @@ -0,0 +1,147 @@ + static::$productId, + 'name' => 'Laravel Cashier Test Product', + 'type' => 'service', + ]); + + Plan::create([ + 'id' => static::$planId, + 'nickname' => 'Monthly $10', + 'currency' => 'USD', + 'interval' => 'month', + 'billing_scheme' => 'per_unit', + 'amount' => 1000, + 'product' => static::$productId, + ]); + + Plan::create([ + 'id' => static::$otherPlanId, + 'nickname' => 'Monthly $10 Other', + 'currency' => 'USD', + 'interval' => 'month', + 'billing_scheme' => 'per_unit', + 'amount' => 1000, + 'product' => static::$productId, + ]); + + Plan::create([ + 'id' => static::$premiumPlanId, + 'nickname' => 'Monthly $20 Premium', + 'currency' => 'USD', + 'interval' => 'month', + 'billing_scheme' => 'per_unit', + 'amount' => 2000, + 'product' => static::$productId, + ]); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + + static::deleteStripeResource(new Plan(static::$planId)); + static::deleteStripeResource(new Plan(static::$otherPlanId)); + static::deleteStripeResource(new Plan(static::$premiumPlanId)); + static::deleteStripeResource(new Product(static::$productId)); + } + + public function test_subscription_can_error_if_incomplete() + { + $user = $this->createCustomer('subscription_can_error_if_incomplete'); + + $subscription = $user->newSubscription('main', static::$planId)->create('pm_card_visa'); + + // Set a faulty card as the customer's default payment method. + $user->updateDefaultPaymentMethod('pm_card_threeDSecure2Required'); + + try { + // Attempt to swap and pay with a faulty card. + $subscription = $subscription->errorIfPaymentFails()->swapAndInvoice(static::$premiumPlanId); + + $this->fail('Expected exception '.PaymentFailure::class.' was not thrown.'); + } catch (StripeCardException $e) { + // Assert that the plan was not swapped. + $this->assertEquals(static::$planId, $subscription->refresh()->stripe_plan); + + // Assert subscription is active. + $this->assertTrue($subscription->active()); + } + } + + // public function test_subscription_can_be_pending_if_incomplete() + // { + // $user = $this->createCustomer('subscription_can_be_pending_if_incomplete'); + // + // $subscription = $user->newSubscription('main', static::$planId)->create('pm_card_visa'); + // + // // Set a faulty card as the customer's default payment method. + // $user->updateDefaultPaymentMethod('pm_card_threeDSecure2Required'); + // + // try { + // // Attempt to swap and pay with a faulty card. + // $subscription = $subscription->pendingIfPaymentFails()->swapAndInvoice(static::$premiumPlanId); + // + // $this->fail('Expected exception '.PaymentFailure::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 not swapped. + // $this->assertEquals(static::$planId, $subscription->refresh()->stripe_plan); + // + // // Assert subscription is active. + // $this->assertTrue($subscription->active()); + // + // // Assert subscription has pending updates. + // $this->assertTrue($subscription->pending()); + // + // // Void the last invoice to cancel any pending updates. + // $subscription->latestInvoice()->void(); + // + // // Assert subscription has no more pending updates. + // $this->assertFalse($subscription->pending()); + // } + // } +} diff --git a/tests/Feature/SubscriptionsTest.php b/tests/Feature/SubscriptionsTest.php index 108713ff..c6e874d0 100644 --- a/tests/Feature/SubscriptionsTest.php +++ b/tests/Feature/SubscriptionsTest.php @@ -477,6 +477,7 @@ public function test_creating_subscription_with_explicit_trial() $this->assertEquals(Carbon::tomorrow()->hour(3)->minute(15), $subscription->trial_ends_at); } + /** @group Prorate */ public function test_subscription_changes_can_be_prorated() { $user = $this->createCustomer('subscription_changes_can_be_prorated'); @@ -485,20 +486,23 @@ public function test_subscription_changes_can_be_prorated() $this->assertEquals(2000, ($invoice = $user->invoices()->first())->rawTotal()); - $subscription->noProrate()->swapAndInvoice(static::$planId); + $subscription->noProrate()->swap(static::$planId); // Assert that no new invoice was created because of no prorating. $this->assertEquals($invoice->id, $user->invoices()->first()->id); + $this->assertEquals(1000, $user->upcomingInvoice()->rawTotal()); $subscription->swapAndInvoice(static::$premiumPlanId); - // Assert that no new invoice was created because of no prorating. - $this->assertEquals($invoice->id, $user->invoices()->first()->id); + // Assert that a new invoice was created because of immediate invoicing. + $this->assertNotSame($invoice->id, ($invoice = $user->invoices()->first())->id); + $this->assertEquals(1000, $invoice->rawTotal()); + $this->assertEquals(2000, $user->upcomingInvoice()->rawTotal()); - $subscription->prorate()->swapAndInvoice(static::$planId); + $subscription->prorate()->swap(static::$planId); - // Get back from unused time on premium plan. - $this->assertEquals(-1000, $user->invoices()->first()->rawTotal()); + // Get back from unused time on premium plan on next invoice. + $this->assertEquals(0, $user->upcomingInvoice()->rawTotal()); } public function test_trials_can_be_extended()