diff --git a/src/Concerns/ProratesDate.php b/src/Concerns/ProratesDate.php new file mode 100644 index 00000000..c63c17fb --- /dev/null +++ b/src/Concerns/ProratesDate.php @@ -0,0 +1,21 @@ +prorationDate = $date instanceof \DateTimeInterface ? $date->getTimestamp() : $date; + + return $this; + } +} diff --git a/src/Subscription.php b/src/Subscription.php index 252dc94a..97b0fc47 100644 --- a/src/Subscription.php +++ b/src/Subscription.php @@ -13,6 +13,7 @@ use Laravel\Cashier\Concerns\HandlesPaymentFailures; use Laravel\Cashier\Concerns\InteractsWithPaymentBehavior; use Laravel\Cashier\Concerns\Prorates; +use Laravel\Cashier\Concerns\ProratesDate; use Laravel\Cashier\Database\Factories\SubscriptionFactory; use Laravel\Cashier\Exceptions\IncompletePayment; use Laravel\Cashier\Exceptions\SubscriptionUpdateFailure; @@ -29,6 +30,7 @@ class Subscription extends Model use HasFactory; use InteractsWithPaymentBehavior; use Prorates; + use ProratesDate; /** * The attributes that are not mass assignable. @@ -435,7 +437,10 @@ public function incrementQuantity($count = 1, $price = null) $this->guardAgainstIncomplete(); if ($price) { - $this->findItemOrFail($price)->setProrationBehavior($this->prorationBehavior)->incrementQuantity($count); + $this->findItemOrFail($price) + ->setProrationBehavior($this->prorationBehavior) + ->prorateDate($this->prorationDate) + ->incrementQuantity($count); return $this->refresh(); } @@ -478,7 +483,10 @@ public function decrementQuantity($count = 1, $price = null) $this->guardAgainstIncomplete(); if ($price) { - $this->findItemOrFail($price)->setProrationBehavior($this->prorationBehavior)->decrementQuantity($count); + $this->findItemOrFail($price) + ->setProrationBehavior($this->prorationBehavior) + ->prorateDate($this->prorationDate) + ->decrementQuantity($count); return $this->refresh(); } @@ -502,19 +510,23 @@ public function updateQuantity($quantity, $price = null) $this->guardAgainstIncomplete(); if ($price) { - $this->findItemOrFail($price)->setProrationBehavior($this->prorationBehavior)->updateQuantity($quantity); + $this->findItemOrFail($price) + ->setProrationBehavior($this->prorationBehavior) + ->prorateDate($this->prorationDate) + ->updateQuantity($quantity); return $this->refresh(); } $this->guardAgainstMultiplePrices(); - $stripeSubscription = $this->updateStripeSubscription([ + $stripeSubscription = $this->updateStripeSubscription(array_filter([ 'payment_behavior' => $this->paymentBehavior(), 'proration_behavior' => $this->prorateBehavior(), + 'proration_date' => $this->prorationDate, 'quantity' => $quantity, 'expand' => ['latest_invoice.payment_intent'], - ]); + ])); $this->fill([ 'stripe_status' => $stripeSubscription->status, @@ -626,10 +638,11 @@ public function endTrial() return $this; } - $this->updateStripeSubscription([ + $this->updateStripeSubscription(array_filter([ 'trial_end' => 'now', 'proration_behavior' => $this->prorateBehavior(), - ]); + 'proration_date' => $this->prorationDate, + ])); $this->trial_ends_at = null; @@ -650,10 +663,11 @@ public function extendTrial(CarbonInterface $date) throw new InvalidArgumentException("Extending a subscription's trial requires a date in the future."); } - $this->updateStripeSubscription([ + $this->updateStripeSubscription(array_filter([ 'trial_end' => $date->getTimestamp(), 'proration_behavior' => $this->prorateBehavior(), - ]); + 'proration_date' => $this->prorationDate, + ])); $this->trial_ends_at = $date; @@ -811,6 +825,7 @@ protected function getSwapOptions(Collection $items, array $options = []) 'payment_behavior' => $this->paymentBehavior(), 'promotion_code' => $this->promotionCodeId, 'proration_behavior' => $this->prorateBehavior(), + 'proration_date' => $this->prorationDate, 'expand' => ['latest_invoice.payment_intent'], ]); @@ -857,6 +872,7 @@ public function addPrice($price, $quantity = 1, array $options = []) 'tax_rates' => $this->getPriceTaxRatesForPayload($price), 'payment_behavior' => $this->paymentBehavior(), 'proration_behavior' => $this->prorateBehavior(), + 'proration_date' => $this->prorationDate, ], $options))); $this->items()->create([ @@ -952,6 +968,7 @@ public function removePrice($price) $stripeItem->delete(array_filter([ 'clear_usage' => $stripeItem->price->recurring->usage_type === 'metered' ? true : null, 'proration_behavior' => $this->prorateBehavior(), + 'proration_date' => $this->prorationDate, ])); $this->items()->where('stripe_price', $price)->delete(); @@ -1011,10 +1028,11 @@ public function cancelAt($endsAt) $endsAt = $endsAt->getTimestamp(); } - $stripeSubscription = $this->updateStripeSubscription([ + $stripeSubscription = $this->updateStripeSubscription(array_filter([ 'cancel_at' => $endsAt, 'proration_behavior' => $this->prorateBehavior(), - ]); + 'proration_date' => $this->prorationDate, + ])); $this->stripe_status = $stripeSubscription->status; @@ -1190,6 +1208,7 @@ public function previewInvoice($prices, array $options = []) 'cancel_at_period_end', 'items', 'proration_behavior', + 'proration_date', 'trial_end', ]) ->mapWithKeys(function ($value, $key) { diff --git a/src/SubscriptionItem.php b/src/SubscriptionItem.php index 4494c265..329a0270 100644 --- a/src/SubscriptionItem.php +++ b/src/SubscriptionItem.php @@ -9,6 +9,7 @@ use Laravel\Cashier\Concerns\HandlesPaymentFailures; use Laravel\Cashier\Concerns\InteractsWithPaymentBehavior; use Laravel\Cashier\Concerns\Prorates; +use Laravel\Cashier\Concerns\ProratesDate; use Laravel\Cashier\Database\Factories\SubscriptionItemFactory; /** @@ -20,6 +21,7 @@ class SubscriptionItem extends Model use HasFactory; use InteractsWithPaymentBehavior; use Prorates; + use ProratesDate; /** * The attributes that are not mass assignable. @@ -109,11 +111,12 @@ public function updateQuantity($quantity) { $this->subscription->guardAgainstIncomplete(); - $stripeSubscriptionItem = $this->updateStripeSubscriptionItem([ + $stripeSubscriptionItem = $this->updateStripeSubscriptionItem(array_filter([ 'payment_behavior' => $this->paymentBehavior(), 'proration_behavior' => $this->prorateBehavior(), + 'proration_date' => $this->prorationDate, 'quantity' => $quantity, - ]); + ])); $this->fill([ 'quantity' => $stripeSubscriptionItem->quantity, @@ -155,6 +158,7 @@ public function swap($price, array $options = []) 'quantity' => $this->quantity, 'payment_behavior' => $this->paymentBehavior(), 'proration_behavior' => $this->prorateBehavior(), + 'proration_date' => $this->prorationDate, 'tax_rates' => $this->subscription->getPriceTaxRatesForPayload($price), ], function ($value) { return ! is_null($value); diff --git a/tests/Feature/SubscriptionsTest.php b/tests/Feature/SubscriptionsTest.php index 946829d8..ccb0721b 100644 --- a/tests/Feature/SubscriptionsTest.php +++ b/tests/Feature/SubscriptionsTest.php @@ -656,6 +656,36 @@ public function test_subscription_changes_can_be_prorated() $this->assertEquals(0, $user->upcomingInvoice()->rawTotal()); } + public function test_subscription_changes_can_be_prorated_on_date() + { + $user = $this->createCustomer('subscription_changes_can_be_prorated_on_date'); + + $subscription = $user->newSubscription('main', static::$premiumPriceId)->create('pm_card_visa'); + $stripeSubscription = $subscription->asStripeSubscription(); + + $this->assertEquals(2000, ($invoice = $user->invoices()->first())->rawTotal()); + + $subscription->noProrate()->prorateDate()->swap(static::$priceId); + + // Assert that no new invoice was created because of no prorating and not affected by date. + $this->assertEquals($invoice->id, $user->invoices()->first()->id); + $this->assertEquals(1000, $user->upcomingInvoice()->rawTotal()); + + $subscription->swapAndInvoice(static::$premiumPriceId); + + // 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()); + + // Prorate with timestamp. + $halfwayPoint = $stripeSubscription->current_period_start + ($stripeSubscription->current_period_end - $stripeSubscription->current_period_start) / 2; + $subscription->prorate()->prorateDate($halfwayPoint)->swap(static::$priceId); + + // Get back from unused time on premium price on next invoice. + $this->assertEquals(500, $user->upcomingInvoice()->rawTotal()); + } + public function test_trial_remains_when_customer_is_invoiced_immediately_on_swap() { $user = $this->createCustomer('trial_remains_when_customer_is_invoiced_immediately_on_swap'); diff --git a/tests/Feature/SubscriptionsWithMultiplePricesTest.php b/tests/Feature/SubscriptionsWithMultiplePricesTest.php index 3b5a9cde..891ff016 100644 --- a/tests/Feature/SubscriptionsWithMultiplePricesTest.php +++ b/tests/Feature/SubscriptionsWithMultiplePricesTest.php @@ -304,6 +304,33 @@ public function test_subscription_item_changes_can_be_prorated() $this->assertEquals(2000, $user->upcomingInvoice()->rawTotal()); } + public function test_subscription_item_changes_can_be_prorated_on_date() + { + $user = $this->createCustomer('subscription_item_changes_can_be_prorated_on_date'); + + $subscription = $user->newSubscription('main', static::$premiumPriceId)->create('pm_card_visa'); + + $stripeSubscription = $subscription->asStripeSubscription(); + $quarterSeconds = ($stripeSubscription->current_period_end - $stripeSubscription->current_period_start) / 4; + $quarterPoint = $stripeSubscription->current_period_start + $quarterSeconds; + $halfwayPoint = $stripeSubscription->current_period_start + $quarterSeconds * 2; + + $this->assertEquals(2000, ($invoice1 = $user->invoices()->first())->rawTotal()); + + $subscription->prorate()->prorateDate($quarterPoint)->addPriceAndInvoice(self::$priceId); + + // Assert that a new invoice was created because of prorating. + $this->assertNotEquals($invoice1->id, ($invoice2 = $user->invoices()->first())->id); + $this->assertEquals(750, $invoice2->rawTotal()); + $this->assertEquals(3000, $user->upcomingInvoice()->rawTotal()); + + $subscription->prorate()->prorateDate($halfwayPoint)->removePrice(self::$premiumPriceId); + + // Assert that no new invoice was created because of prorating. + $this->assertEquals($invoice2->id, $user->invoices()->first()->id); + $this->assertEquals(0, $user->upcomingInvoice()->rawTotal()); + } + public function test_subscription_item_quantity_changes_can_be_prorated() { $user = $this->createCustomer('subscription_item_quantity_changes_can_be_prorated'); @@ -319,6 +346,26 @@ public function test_subscription_item_quantity_changes_can_be_prorated() $this->assertEquals(2000, $user->upcomingInvoice()->rawTotal()); } + public function test_subscription_item_quantity_changes_can_be_prorated_on_date() + { + $user = $this->createCustomer('subscription_item_quantity_changes_can_be_prorated_on_date'); + + $subscription = $user->newSubscription('main', [self::$priceId, self::$otherPriceId]) + ->quantity(1, self::$otherPriceId) + ->create('pm_card_visa'); + + $this->assertEquals(2000, ($invoice = $user->invoices()->first())->rawTotal()); + + $stripeSubscription = $subscription->asStripeSubscription(); + $halfSeconds = ($stripeSubscription->current_period_end - $stripeSubscription->current_period_start) / 2; + $halfPoint = $stripeSubscription->current_period_start + $halfSeconds; + + $subscription->prorate()->prorateDate($halfPoint)->updateQuantity(2, self::$otherPriceId); + + // $10(price) + $20(otherPrice x2) + $5(half 2nd otherPrice) = $35 + $this->assertEquals(3500, $user->upcomingInvoice()->rawTotal()); + } + /** * Create a subscription with a single price. *