Skip to content

Commit

Permalink
Allow setting the proration_date
Browse files Browse the repository at this point in the history
This is a different solution for #1637 that doesn't change method signatures.
  • Loading branch information
skeemer committed Feb 4, 2024
1 parent 88d6f6c commit b71ebe6
Show file tree
Hide file tree
Showing 5 changed files with 134 additions and 13 deletions.
21 changes: 21 additions & 0 deletions src/Concerns/ProratesDate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace Laravel\Cashier\Concerns;

trait ProratesDate
{
/**
* Indicates when the price change should be prorated.
*/
protected ?int $prorationDate = null;

/**
* Indicate that date for proration.
*/
public function prorateDate(\DateTimeInterface|int $date = null): static
{
$this->prorationDate = $date instanceof \DateTimeInterface ? $date->getTimestamp() : $date;

return $this;
}
}
41 changes: 30 additions & 11 deletions src/Subscription.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,6 +30,7 @@ class Subscription extends Model
use HasFactory;
use InteractsWithPaymentBehavior;
use Prorates;
use ProratesDate;

/**
* The attributes that are not mass assignable.
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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();
}
Expand All @@ -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,
Expand Down Expand Up @@ -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;

Expand All @@ -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;

Expand Down Expand Up @@ -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'],
]);

Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 6 additions & 2 deletions src/SubscriptionItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -20,6 +21,7 @@ class SubscriptionItem extends Model
use HasFactory;
use InteractsWithPaymentBehavior;
use Prorates;
use ProratesDate;

/**
* The attributes that are not mass assignable.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
30 changes: 30 additions & 0 deletions tests/Feature/SubscriptionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
47 changes: 47 additions & 0 deletions tests/Feature/SubscriptionsWithMultiplePricesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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.
*
Expand Down

0 comments on commit b71ebe6

Please sign in to comment.