Merge remote-tracking branch 'origin/next' into fix/dev-dbproxy

This commit is contained in:
Andras Bacsai committed 2026-03-06 08:04:06 +01:00
commit 31caef990d
124 files changed
+8398 -1398

No files matched your search

@@ -0,0 +1,60 @@
<?php
namespace App\Actions\Stripe;
use App\Models\Team;
use Stripe\StripeClient;
class CancelSubscriptionAtPeriodEnd
{
private StripeClient $stripe;
public function __construct(?StripeClient $stripe = null)
{
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
}
/**
* Cancel the team's subscription at the end of the current billing period.
*
* @return array{success: bool, error: string|null}
*/
public function execute(Team $team): array
{
$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.'];
}
if ($subscription->stripe_cancel_at_period_end) {
return ['success' => false, 'error' => 'Subscription is already set to cancel at the end of the billing period.'];
}
try {
$this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
'cancel_at_period_end' => true,
]);
$subscription->update([
'stripe_cancel_at_period_end' => true,
]);
\Log::info("Subscription {$subscription->stripe_subscription_id} set to cancel at period end for team {$team->name}");
return ['success' => true, 'error' => null];
} catch (\Stripe\Exception\InvalidRequestException $e) {
\Log::error("Stripe cancel at period end error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
} catch (\Exception $e) {
\Log::error("Cancel at period end error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.'];
}
}
}
+141
View File
@@ -0,0 +1,141 @@
<?php
namespace App\Actions\Stripe;
use App\Models\Team;
use Stripe\StripeClient;
class RefundSubscription
{
private StripeClient $stripe;
private const REFUND_WINDOW_DAYS = 30;
public function __construct(?StripeClient $stripe = null)
{
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
}
/**
* Check if the team's subscription is eligible for a refund.
*
* @return array{eligible: bool, days_remaining: int, reason: string}
*/
public function checkEligibility(Team $team): array
{
$subscription = $team->subscription;
if ($subscription?->stripe_refunded_at) {
return $this->ineligible('A refund has already been processed for this team.');
}
if (! $subscription?->stripe_subscription_id) {
return $this->ineligible('No active subscription found.');
}
if (! $subscription->stripe_invoice_paid) {
return $this->ineligible('Subscription invoice is not paid.');
}
try {
$stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
} catch (\Stripe\Exception\InvalidRequestException $e) {
return $this->ineligible('Subscription not found in Stripe.');
}
if (! in_array($stripeSubscription->status, ['active', 'trialing'])) {
return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.");
}
$startDate = \Carbon\Carbon::createFromTimestamp($stripeSubscription->start_date);
$daysSinceStart = (int) $startDate->diffInDays(now());
$daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart;
if ($daysRemaining <= 0) {
return $this->ineligible('The 30-day refund window has expired.');
}
return [
'eligible' => true,
'days_remaining' => $daysRemaining,
'reason' => 'Eligible for refund.',
];
}
/**
* Process a full refund and cancel the subscription.
*
* @return array{success: bool, error: string|null}
*/
public function execute(Team $team): array
{
$eligibility = $this->checkEligibility($team);
if (! $eligibility['eligible']) {
return ['success' => false, 'error' => $eligibility['reason']];
}
$subscription = $team->subscription;
try {
$invoices = $this->stripe->invoices->all([
'subscription' => $subscription->stripe_subscription_id,
'status' => 'paid',
'limit' => 1,
]);
if (empty($invoices->data)) {
return ['success' => false, 'error' => 'No paid invoice found to refund.'];
}
$invoice = $invoices->data[0];
$paymentIntentId = $invoice->payment_intent;
if (! $paymentIntentId) {
return ['success' => false, 'error' => 'No payment intent found on the invoice.'];
}
$this->stripe->refunds->create([
'payment_intent' => $paymentIntentId,
]);
$this->stripe->subscriptions->cancel($subscription->stripe_subscription_id);
$subscription->update([
'stripe_cancel_at_period_end' => false,
'stripe_invoice_paid' => false,
'stripe_trial_already_ended' => false,
'stripe_past_due' => false,
'stripe_feedback' => 'Refund requested by user',
'stripe_comment' => 'Full refund processed within 30-day window at '.now()->toDateTimeString(),
'stripe_refunded_at' => now(),
]);
$team->subscriptionEnded();
\Log::info("Refunded and cancelled subscription {$subscription->stripe_subscription_id} for team {$team->name}");
return ['success' => true, 'error' => null];
} catch (\Stripe\Exception\InvalidRequestException $e) {
\Log::error("Stripe refund error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
} catch (\Exception $e) {
\Log::error("Refund error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.'];
}
}
/**
* @return array{eligible: bool, days_remaining: int, reason: string}
*/
private function ineligible(string $reason): array
{
return [
'eligible' => false,
'days_remaining' => 0,
'reason' => $reason,
];
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Actions\Stripe;
use App\Models\Team;
use Stripe\StripeClient;
class ResumeSubscription
{
private StripeClient $stripe;
public function __construct(?StripeClient $stripe = null)
{
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
}
/**
* Resume a subscription that was set to cancel at the end of the billing period.
*
* @return array{success: bool, error: string|null}
*/
public function execute(Team $team): array
{
$subscription = $team->subscription;
if (! $subscription?->stripe_subscription_id) {
return ['success' => false, 'error' => 'No active subscription found.'];
}
if (! $subscription->stripe_cancel_at_period_end) {
return ['success' => false, 'error' => 'Subscription is not set to cancel.'];
}
try {
$this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
'cancel_at_period_end' => false,
]);
$subscription->update([
'stripe_cancel_at_period_end' => false,
]);
\Log::info("Subscription {$subscription->stripe_subscription_id} resumed for team {$team->name}");
return ['success' => true, 'error' => null];
} catch (\Stripe\Exception\InvalidRequestException $e) {
\Log::error("Stripe resume subscription error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
} catch (\Exception $e) {
\Log::error("Resume subscription error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.'];
}
}
}
@@ -0,0 +1,197 @@
<?php
namespace App\Actions\Stripe;
use App\Jobs\ServerLimitCheckJob;
use App\Models\Team;
use Stripe\StripeClient;
class UpdateSubscriptionQuantity
{
public const int MAX_SERVER_LIMIT = 100;
public const int MIN_SERVER_LIMIT = 2;
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.'%';
}
}
// Fallback tax percentage from invoice totals - use tax_rate details when available for accuracy
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
{
if ($quantity < self::MIN_SERVER_LIMIT) {
return ['success' => false, 'error' => 'Minimum server limit is '.self::MIN_SERVER_LIMIT.'.'];
}
$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;
}
}