Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions config/cashier.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,19 @@

'logger' => env('CASHIER_LOGGER'),

/*
|--------------------------------------------------------------------------
| Default Billing Mode
|--------------------------------------------------------------------------
|
| This setting defines the default billing mode for new subscriptions.
| The billing mode can be either "classic" (default) or "flexible".
| Flexible mode enables more flexible subscription behavior including
| improved prorations, consolidated invoicing, and mixed intervals.
| Requires Stripe API version 2025-06-30.basil or later.
|
*/

'default_billing_mode' => env('CASHIER_DEFAULT_BILLING_MODE', 'classic'),

];
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('subscription_schedules', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id');
$table->string('stripe_id')->unique();
$table->string('stripe_status');
$table->string('subscription_id')->nullable();
$table->timestamp('current_phase_started_at')->nullable();
$table->timestamp('current_phase_ends_at')->nullable();
$table->timestamp('canceled_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamp('released_at')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();

$table->index(['user_id', 'stripe_status']);
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('subscription_schedules');
}
};
40 changes: 40 additions & 0 deletions database/migrations/2019_05_03_000003_create_quotes_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('quotes', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id');
$table->string('stripe_id')->unique();
$table->string('status');
$table->string('number')->nullable();
$table->integer('amount_subtotal')->nullable();
$table->integer('amount_total')->nullable();
$table->string('currency');
$table->timestamp('expires_at')->nullable();
$table->timestamp('status_transitions_finalized_at')->nullable();
$table->timestamp('status_transitions_accepted_at')->nullable();
$table->timestamp('status_transitions_canceled_at')->nullable();
$table->timestamps();

$table->index(['user_id', 'status']);
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('quotes');
}
};
8 changes: 8 additions & 0 deletions src/Billable.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,28 @@
namespace Laravel\Cashier;

use Laravel\Cashier\Concerns\HandlesTaxes;
use Laravel\Cashier\Concerns\ManagesBillingCredits;
use Laravel\Cashier\Concerns\ManagesCustomer;
use Laravel\Cashier\Concerns\ManagesInvoices;
use Laravel\Cashier\Concerns\ManagesPaymentMethods;
use Laravel\Cashier\Concerns\ManagesPricingModels;
use Laravel\Cashier\Concerns\ManagesQuotes;
use Laravel\Cashier\Concerns\ManagesSubscriptions;
use Laravel\Cashier\Concerns\ManagesSubscriptionSchedules;
use Laravel\Cashier\Concerns\ManagesUsageBilling;
use Laravel\Cashier\Concerns\PerformsCharges;

trait Billable
{
use HandlesTaxes;
use ManagesBillingCredits;
use ManagesCustomer;
use ManagesInvoices;
use ManagesPaymentMethods;
use ManagesPricingModels;
use ManagesQuotes;
use ManagesSubscriptions;
use ManagesSubscriptionSchedules;
use ManagesUsageBilling;
use PerformsCharges;
}
129 changes: 128 additions & 1 deletion src/CheckoutBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,31 @@ class CheckoutBuilder
use AllowsCoupons;
use HandlesTaxes;

/**
* The Stripe model instance.
*
* @var \Illuminate\Database\Eloquent\Model|null
*/
protected $owner;

/**
* The billing mode for the subscription.
*
* @var array|null
*/
protected $billingMode = null;

/**
* Create a new checkout builder instance.
*
* @param \Illuminate\Database\Eloquent\Model|null $owner
* @param object|null $parentInstance
* @return void
*/
public function __construct(protected $owner = null, ?object $parentInstance = null)
public function __construct($owner = null, ?object $parentInstance = null)
{
$this->owner = $owner;

if ($parentInstance && in_array(AllowsCoupons::class, class_uses_recursive($parentInstance))) {
$this->couponId = $parentInstance->couponId;
$this->promotionCodeId = $parentInstance->promotionCodeId;
Expand All @@ -31,6 +47,23 @@ public function __construct(protected $owner = null, ?object $parentInstance = n
$this->estimationBillingAddress = $parentInstance->estimationBillingAddress;
$this->collectTaxIds = $parentInstance->collectTaxIds;
}

if ($parentInstance) {
// Use reflection to access the protected billingMode property if it exists
try {
$reflection = new \ReflectionClass($parentInstance);
if ($reflection->hasProperty('billingMode')) {
$property = $reflection->getProperty('billingMode');
$property->setAccessible(true);
$billingMode = $property->getValue($parentInstance);
if ($billingMode !== null) {
$this->billingMode = $billingMode;
}
}
} catch (\ReflectionException $e) {
// Ignore reflection errors
}
}
}

/**
Expand All @@ -45,6 +78,93 @@ public static function make($owner = null, ?object $instance = null)
return new static($owner, $instance);
}

/**
* Set the billing mode for the subscription.
*
* @param string $type
* @return $this
*/
public function withBillingMode($type = 'flexible')
{
$this->billingMode = ['type' => $type];

return $this;
}

/**
* Get the default billing mode from config.
*
* @return string
*/
protected function getDefaultBillingMode()
{
return config('cashier.default_billing_mode', 'classic');
}

/**
* Get the effective billing mode.
*
* @return string
*/
protected function getEffectiveBillingMode()
{
return $this->billingMode['type'] ?? $this->getDefaultBillingMode();
}

/**
* Get the billing mode for the Stripe payload.
*
* @return array|null
*/
protected function getBillingModeForPayload()
{
$effectiveMode = $this->getEffectiveBillingMode();

// Only include billing_mode in payload if it's flexible
// Classic mode is Stripe's default, so we omit it for backwards compatibility
if ($effectiveMode === 'flexible') {
$this->validateFlexibleBillingSupport();

return ['type' => 'flexible'];
}

return null;
}

/**
* Validate that flexible billing mode is supported by the current API version.
*
* @return void
*
* @throws \InvalidArgumentException
*/
protected function validateFlexibleBillingSupport()
{
$apiVersion = config('cashier.stripe.api_version') ?? \Stripe\Stripe::getApiVersion();

if ($apiVersion && version_compare($apiVersion, '2025-06-30', '<')) {
throw new \InvalidArgumentException(
'Flexible billing mode requires Stripe API version 2025-06-30.basil or later. '.
'Current version: '.$apiVersion.'. Please update your API version.'
);
}
}

/**
* Create a new checkout session for subscriptions.
*
* @param array|string $items
* @param array $sessionOptions
* @param array $customerOptions
* @return \Laravel\Cashier\Checkout
*/
public function createSubscription($items, array $sessionOptions = [], array $customerOptions = [])
{
$sessionOptions['mode'] = 'subscription';

return $this->create($items, $sessionOptions, $customerOptions);
}

/**
* Create a new checkout session.
*
Expand Down Expand Up @@ -75,6 +195,13 @@ public function create(string|array $items, array $sessionOptions = [], array $c
: [],
]);

// Add billing mode for subscription mode only
if (($sessionOptions['mode'] ?? null) === 'subscription') {
if ($billingMode = $this->getBillingModeForPayload()) {
$payload['subscription_data']['billing_mode'] = $billingMode;
}
}

return Checkout::create($this->owner, array_merge($payload, $sessionOptions), $customerOptions);
}
}
Loading