2026-03-03 11:24:13 +00:00
< ? php
namespace App\Actions\Stripe ;
use App\Jobs\ServerLimitCheckJob ;
use App\Models\Team ;
use Stripe\StripeClient ;
class UpdateSubscriptionQuantity
{
2026-03-03 11:28:16 +00:00
public const int MAX_SERVER_LIMIT = 100 ;
public const int MIN_SERVER_LIMIT = 2 ;
2026-03-03 11:24:13 +00:00
private StripeClient $stripe ;
public function __construct ( ? StripeClient $stripe = null )
{
$this -> stripe = $stripe ? ? new StripeClient ( config ( 'subscription.stripe_api_key' ));
}
/**
* Fetch a full price preview for a quantity change from Stripe .
* Returns both the prorated amount due now and the recurring cost for the next billing cycle .
*
* @ return array { success : bool , error : string | null , preview : array { due_now : int , recurring_subtotal : int , recurring_tax : int , recurring_total : int , unit_price : int , tax_description : string | null , quantity : int , currency : string } | null }
*/
public function fetchPricePreview ( Team $team , int $quantity ) : array
{
$subscription = $team -> subscription ;
if ( ! $subscription ? -> stripe_subscription_id || ! $subscription -> stripe_invoice_paid ) {
return [ 'success' => false , 'error' => 'No active subscription found.' , 'preview' => null ];
}
try {
$stripeSubscription = $this -> stripe -> subscriptions -> retrieve ( $subscription -> stripe_subscription_id );
$item = $stripeSubscription -> items -> data [ 0 ] ? ? null ;
if ( ! $item ) {
return [ 'success' => false , 'error' => 'Could not retrieve subscription details.' , 'preview' => null ];
}
$currency = strtoupper ( $item -> price -> currency ? ? 'usd' );
// Upcoming invoice gives us the prorated amount due now
$upcomingInvoice = $this -> stripe -> invoices -> upcoming ([
'customer' => $subscription -> stripe_customer_id ,
'subscription' => $subscription -> stripe_subscription_id ,
'subscription_items' => [
[ 'id' => $item -> id , 'quantity' => $quantity ],
],
'subscription_proration_behavior' => 'create_prorations' ,
]);
// Extract tax percentage — try total_tax_amounts first, fall back to invoice tax/subtotal
$taxPercentage = 0.0 ;
$taxDescription = null ;
if ( ! empty ( $upcomingInvoice -> total_tax_amounts )) {
$taxAmount = $upcomingInvoice -> total_tax_amounts [ 0 ] ? ? null ;
if ( $taxAmount ? -> tax_rate ) {
$taxRate = $this -> stripe -> taxRates -> retrieve ( $taxAmount -> tax_rate );
$taxPercentage = ( float ) ( $taxRate -> percentage ? ? 0 );
$taxDescription = $taxRate -> display_name . ' (' . $taxRate -> jurisdiction . ') ' . $taxRate -> percentage . '%' ;
}
}
2026-03-03 11:28:16 +00:00
// Fallback tax percentage from invoice totals - use tax_rate details when available for accuracy
2026-03-03 11:24:13 +00:00
if ( $taxPercentage === 0.0 && ( $upcomingInvoice -> tax ? ? 0 ) > 0 && ( $upcomingInvoice -> subtotal ? ? 0 ) > 0 ) {
$taxPercentage = round (( $upcomingInvoice -> tax / $upcomingInvoice -> subtotal ) * 100 , 2 );
}
// Recurring cost for next cycle — read from non-proration invoice lines
$recurringSubtotal = 0 ;
foreach ( $upcomingInvoice -> lines -> data as $line ) {
if ( ! $line -> proration ) {
$recurringSubtotal += $line -> amount ;
}
}
$unitPrice = $quantity > 0 ? ( int ) round ( $recurringSubtotal / $quantity ) : 0 ;
$recurringTax = $taxPercentage > 0
? ( int ) round ( $recurringSubtotal * $taxPercentage / 100 )
: 0 ;
$recurringTotal = $recurringSubtotal + $recurringTax ;
// Due now = amount_due (accounts for customer balance/credits) minus recurring
$amountDue = $upcomingInvoice -> amount_due ? ? $upcomingInvoice -> total ? ? 0 ;
$dueNow = $amountDue - $recurringTotal ;
return [
'success' => true ,
'error' => null ,
'preview' => [
'due_now' => $dueNow ,
'recurring_subtotal' => $recurringSubtotal ,
'recurring_tax' => $recurringTax ,
'recurring_total' => $recurringTotal ,
'unit_price' => $unitPrice ,
'tax_description' => $taxDescription ,
'quantity' => $quantity ,
'currency' => $currency ,
],
];
} catch ( \Exception $e ) {
\Log :: warning ( " Stripe fetch price preview error for team { $team -> id } : " . $e -> getMessage ());
return [ 'success' => false , 'error' => 'Could not load price preview.' , 'preview' => null ];
}
}
/**
* Update the subscription quantity ( server limit ) for a team .
*
* @ return array { success : bool , error : string | null }
*/
public function execute ( Team $team , int $quantity ) : array
{
2026-03-03 11:28:16 +00:00
if ( $quantity < self :: MIN_SERVER_LIMIT ) {
return [ 'success' => false , 'error' => 'Minimum server limit is ' . self :: MIN_SERVER_LIMIT . '.' ];
2026-03-03 11:24:13 +00:00
}
$subscription = $team -> subscription ;
if ( ! $subscription ? -> stripe_subscription_id ) {
return [ 'success' => false , 'error' => 'No active subscription found.' ];
}
if ( ! $subscription -> stripe_invoice_paid ) {
return [ 'success' => false , 'error' => 'Subscription is not active.' ];
}
try {
$stripeSubscription = $this -> stripe -> subscriptions -> retrieve ( $subscription -> stripe_subscription_id );
$item = $stripeSubscription -> items -> data [ 0 ] ? ? null ;
if ( ! $item ? -> id ) {
return [ 'success' => false , 'error' => 'Could not find subscription item.' ];
}
$previousQuantity = $item -> quantity ? ? $team -> custom_server_limit ;
$updatedSubscription = $this -> stripe -> subscriptions -> update ( $subscription -> stripe_subscription_id , [
'items' => [
[ 'id' => $item -> id , 'quantity' => $quantity ],
],
'proration_behavior' => 'always_invoice' ,
'expand' => [ 'latest_invoice' ],
]);
// Check if the proration invoice was paid
$latestInvoice = $updatedSubscription -> latest_invoice ;
if ( $latestInvoice && $latestInvoice -> status !== 'paid' ) {
\Log :: warning ( " Subscription { $subscription -> stripe_subscription_id } quantity updated but invoice not paid (status: { $latestInvoice -> status } ) for team { $team -> name } . Reverting to { $previousQuantity } . " );
// Revert subscription quantity on Stripe
$this -> stripe -> subscriptions -> update ( $subscription -> stripe_subscription_id , [
'items' => [
[ 'id' => $item -> id , 'quantity' => $previousQuantity ],
],
'proration_behavior' => 'none' ,
]);
// Void the unpaid invoice
if ( $latestInvoice -> id ) {
$this -> stripe -> invoices -> voidInvoice ( $latestInvoice -> id );
}
return [ 'success' => false , 'error' => 'Payment failed. Your server limit was not changed. Please check your payment method and try again.' ];
}
$team -> update ([
'custom_server_limit' => $quantity ,
]);
ServerLimitCheckJob :: dispatch ( $team );
\Log :: info ( " Subscription { $subscription -> stripe_subscription_id } quantity updated to { $quantity } for team { $team -> name } " );
return [ 'success' => true , 'error' => null ];
} catch ( \Stripe\Exception\InvalidRequestException $e ) {
\Log :: error ( " Stripe update quantity error for team { $team -> id } : " . $e -> getMessage ());
return [ 'success' => false , 'error' => 'Stripe error: ' . $e -> getMessage ()];
} catch ( \Exception $e ) {
\Log :: error ( " Update subscription quantity error for team { $team -> id } : " . $e -> getMessage ());
return [ 'success' => false , 'error' => 'An unexpected error occurred. Please contact support.' ];
}
}
private function formatAmount ( int $cents , string $currency ) : string
{
return strtoupper ( $currency ) === 'USD'
? '$' . number_format ( $cents / 100 , 2 )
: number_format ( $cents / 100 , 2 ) . ' ' . $currency ;
}
}