From bf9df4358b822324e53c3d6dc0460a718e8e1dbe Mon Sep 17 00:00:00 2001 From: Nadir Hamid Date: Wed, 3 Jun 2026 23:41:05 +0000 Subject: [PATCH 1/3] handle workspace upgrade events in rabbitmq consumer --- .../Commands/RabbitMQEventConsumer.php | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/app/app/Console/Commands/RabbitMQEventConsumer.php b/app/app/Console/Commands/RabbitMQEventConsumer.php index cf3898d0d..a66b62904 100644 --- a/app/app/Console/Commands/RabbitMQEventConsumer.php +++ b/app/app/Console/Commands/RabbitMQEventConsumer.php @@ -13,6 +13,7 @@ use App\WorkspaceUser; use App\Mail\WorkspaceSuspendedNotification; use Carbon\Carbon; +use App\ServicePlan; use Exception; use Log; @@ -51,6 +52,7 @@ public function handle() $channel->queue_declare('subscription_updates', false, true, false, false); $channel->queue_declare('payment_receipts', false, true, false, false); $channel->queue_declare('call_quality_surveys', false, true, false, false); + $channel->queue_declare('workspace_upgrades', false, true, false, false); $channel->queue_declare(self::CALL_ACTIVITY_QUEUE, false, true, false, false); $channel->queue_declare(RabbitMQHelper::INVOICE_QUEUE_MONTHLY, false, true, false, false); $channel->queue_declare(RabbitMQHelper::INVOICE_QUEUE_ANNUAL, false, true, false, false); @@ -78,6 +80,8 @@ public function handle() $channel->basic_consume('subscription_updates', '', false, false, false, false, [$this, 'handleSubscriptionUpdate']); $channel->basic_consume('payment_receipts', '', false, false, false, false, [$this, 'handlePaymentReceipt']); $channel->basic_consume('call_quality_surveys', '', false, false, false, false, [$this, 'handleSurvey']); + $channel->basic_consume('workspace_upgrades', '', false, false, false, false, [$this, 'handleWorkspaceUpgrade']); + $channel->basic_consume(self::CALL_ACTIVITY_QUEUE, '', false, false, false, false, [$this, 'handleCallActivity']); $channel->basic_consume(RabbitMQHelper::INVOICE_QUEUE_MONTHLY, '', false, false, false, false, [$this, 'handleMonthlyInvoiceTask']); $channel->basic_consume(RabbitMQHelper::INVOICE_QUEUE_ANNUAL, '', false, false, false, false, [$this, 'handleAnnualInvoiceTask']); @@ -604,6 +608,82 @@ private function logConsumerError($message, array $context = []) Log::error($message, $context); } + /** + * Handler for Workspace Upgrades + */ + public function handleWorkspaceUpgrade($msg) + { + try { + $data = json_decode($msg->body, true); + + if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) { + $this->logConsumerError(' [WORKSPACE_UPGRADE] Malformed JSON: ' . json_last_error_msg(), [ + 'body' => $msg->body + ]); + $msg->delivery_info['channel']->basic_ack($msg->delivery_info['delivery_tag']); + return; + } + + $workspaceId = isset($data['workspace_id']) ? (int) $data['workspace_id'] : 0; + $userId = isset($data['user_id']) ? (int) $data['user_id'] : 0; + + if ($workspaceId <= 0) { + $this->logConsumerError(' [WORKSPACE_UPGRADE] Missing or invalid workspace_id.', [ + 'payload' => $data + ]); + $msg->delivery_info['channel']->basic_ack($msg->delivery_info['delivery_tag']); + return; + } + + $this->info(" [WORKSPACE_UPGRADE] Received upgrade for workspace ID: " . $workspaceId); + + $action = isset($data['action']) ? $data['action'] : null; + + switch ($action) { + case 'SUCCESSFUL_UPGRADE': + $this->info(" [WORKSPACE_UPGRADE] Processing successful upgrade for workspace ID: " . $workspaceId); + // Implementation for successful upgrade logic goes here + $user = \App\User::find($userId); + $newPlan = \App\ServicePlan::find($data['upgraded_plan_id']); + if ($user && $newPlan) { + $planKey = $newPlan->key_name; + $emailData = ['user' => $user, 'plan' => $planKey]; + $subject = \App\Helpers\MainHelper::createEmailSubject(sprintf("Upgraded plan to %s", $planKey)); + \App\Helpers\EmailHelper::sendEmail($subject, $user->email, 'plan_upgraded', $emailData); + } + break; + + case 'FAILED_UPGRADE': + $this->error(" [WORKSPACE_UPGRADE] Processing failed upgrade for workspace ID: " . $workspaceId); + // Implementation for failed upgrade logic goes here + $user = \App\User::find($userId); + $newPlan = \App\ServicePlan::find($data['upgraded_plan_id']); + if ($user && $newPlan) { + $planKey = $newPlan->key_name; + $emailData = ['user' => $user, 'plan' => $planKey]; + $subject = \App\Helpers\MainHelper::createEmailSubject(sprintf("Failed to upgrade plan to %s", $planKey)); + \App\Helpers\EmailHelper::sendEmail($subject, $user->email, 'failed_upgrade', $emailData); + } + break; + + default: + $this->logConsumerError(' [WORKSPACE_UPGRADE] Unknown action: ' . $action, [ + 'action' => $action, + 'workspace_id' => $workspaceId + ]); + break; + } + + $msg->delivery_info['channel']->basic_ack($msg->delivery_info['delivery_tag']); + $this->info(" [v] Workspace upgrade event acknowledged for workspace #" . $workspaceId); + } catch (Exception $e) { + $this->logConsumerError(' [WORKSPACE_UPGRADE] ' . $e->getMessage(), [ + 'exception' => get_class($e) + ]); + $msg->delivery_info['channel']->basic_ack($msg->delivery_info['delivery_tag']); + } + } + public function handleMonthlyInvoiceTask($msg) { $this->handleInvoiceTask($msg, 'MONTHLY'); From 15ec29c3d169991cfe8d824ad4a88a1383759b15 Mon Sep 17 00:00:00 2001 From: Nadir Hamid Date: Thu, 4 Jun 2026 06:36:22 +0000 Subject: [PATCH 2/3] rework code for handling workspace upgrade events --- .../Commands/RabbitMQEventConsumer.php | 25 ++++-- app/app/Http/Controllers/MergedController.php | 4 - .../views/emails/failed_upgrade.blade.php | 85 +++++++++++++++++++ 3 files changed, 103 insertions(+), 11 deletions(-) create mode 100755 app/resources/views/emails/failed_upgrade.blade.php diff --git a/app/app/Console/Commands/RabbitMQEventConsumer.php b/app/app/Console/Commands/RabbitMQEventConsumer.php index a66b62904..d4a4319f5 100644 --- a/app/app/Console/Commands/RabbitMQEventConsumer.php +++ b/app/app/Console/Commands/RabbitMQEventConsumer.php @@ -624,11 +624,21 @@ public function handleWorkspaceUpgrade($msg) return; } - $workspaceId = isset($data['workspace_id']) ? (int) $data['workspace_id'] : 0; - $userId = isset($data['user_id']) ? (int) $data['user_id'] : 0; - - if ($workspaceId <= 0) { - $this->logConsumerError(' [WORKSPACE_UPGRADE] Missing or invalid workspace_id.', [ + if (!isset($data['workspace_id']) || !isset($data['creator_id'])) { + $this->logConsumerError(' [WORKSPACE_UPGRADE] Missing workspace_id or creator_id.', [ + 'payload' => $data + ]); + $msg->delivery_info['channel']->basic_ack($msg->delivery_info['delivery_tag']); + return; + } + + $workspaceId = (int) $data['workspace_id']; + $userId = (int) $data['creator_id']; + + $this->logConsumerInfo(' [WORKSPACE_UPGRADE] Entire payload:' . json_encode(['payload' => $data])); + + if ($workspaceId <= 0 || $userId <= 0) { + $this->logConsumerError(' [WORKSPACE_UPGRADE] Invalid workspace_id or creator_id.', [ 'payload' => $data ]); $msg->delivery_info['channel']->basic_ack($msg->delivery_info['delivery_tag']); @@ -638,13 +648,14 @@ public function handleWorkspaceUpgrade($msg) $this->info(" [WORKSPACE_UPGRADE] Received upgrade for workspace ID: " . $workspaceId); $action = isset($data['action']) ? $data['action'] : null; + $this->info(" [WORKSPACE_UPGRADE] Action: " . $action); switch ($action) { case 'SUCCESSFUL_UPGRADE': $this->info(" [WORKSPACE_UPGRADE] Processing successful upgrade for workspace ID: " . $workspaceId); // Implementation for successful upgrade logic goes here $user = \App\User::find($userId); - $newPlan = \App\ServicePlan::find($data['upgraded_plan_id']); + $newPlan = \App\ServicePlan::find($data['plan_id']); if ($user && $newPlan) { $planKey = $newPlan->key_name; $emailData = ['user' => $user, 'plan' => $planKey]; @@ -657,7 +668,7 @@ public function handleWorkspaceUpgrade($msg) $this->error(" [WORKSPACE_UPGRADE] Processing failed upgrade for workspace ID: " . $workspaceId); // Implementation for failed upgrade logic goes here $user = \App\User::find($userId); - $newPlan = \App\ServicePlan::find($data['upgraded_plan_id']); + $newPlan = \App\ServicePlan::find($data['plan_id']); if ($user && $newPlan) { $planKey = $newPlan->key_name; $emailData = ['user' => $user, 'plan' => $planKey]; diff --git a/app/app/Http/Controllers/MergedController.php b/app/app/Http/Controllers/MergedController.php index 723b25ecd..ee2945184 100755 --- a/app/app/Http/Controllers/MergedController.php +++ b/app/app/Http/Controllers/MergedController.php @@ -358,10 +358,6 @@ public function upgradePlan(Request $request) ]; WorkspaceEvent::addEvent($workspace, 'PLAN_UPGRADED', $props); - $data = ['user' => $user, 'plan' => $planKey]; - $subject = MainHelper::createEmailSubject(sprintf("Upgraded plan to %s", $planKey)); - EmailHelper::sendEmail($subject, $user->email, 'plan_upgraded', $data); - return $this->response->noContent(); } diff --git a/app/resources/views/emails/failed_upgrade.blade.php b/app/resources/views/emails/failed_upgrade.blade.php new file mode 100755 index 000000000..0e3201f9c --- /dev/null +++ b/app/resources/views/emails/failed_upgrade.blade.php @@ -0,0 +1,85 @@ +@extends('emails.layouts.alert_email') +@section('title') +Upgrade Failed +@endsection +@section('content') +nice_name ?: $plan->name) : (isset($plan) ? $plan : 'your new plan'); +$currentPlanName = isset($currentPlan) && is_object($currentPlan) ? ($currentPlan->nice_name ?: $currentPlan->name) : (isset($currentPlan) ? $currentPlan : null); +$newPlanName = isset($newPlan) && is_object($newPlan) ? ($newPlan->nice_name ?: $newPlan->name) : (isset($newPlan) ? $newPlan : $planName); +$userName = isset($user) ? trim($user->getName()) : ''; +?> + + + + + + + + + + + + + + +
  + + + + + + + + + + + + +
 
 
+ + + + + +
+ + + + +
+
+

We couldn't upgrade your plan

+

There was an issue processing your payment.

+
+ +

Hello {{ $userName !== '' ? $userName : 'there' }},

+

This email is to notify you that we couldn't process the payment to upgrade your workspace plan. Your plan has not been upgraded.

+ + + @if ($currentPlanName) + + + + + @endif + + + + + + + + + + + + +
Current Plan{{ $currentPlanName }}
Requested Plan{{ $newPlanName }}
Upgrade StatusFailed
What Happens NextYour services will remain on the current plan. Please update your payment information so we can process your upgrade.
+ +

You can review your billing details and update your payment method from your account dashboard at any time. Thank you for choosing {{$site_name ?? \App\Helpers\MainHelper::getSiteName()}}.

+
+
 
 
+ + +@endsection From 9d403f7e2e4f5cb0b93d723ff0dba0fef0645fb3 Mon Sep 17 00:00:00 2001 From: Nadir Hamid Date: Sat, 6 Jun 2026 00:01:49 +0000 Subject: [PATCH 3/3] fix workspace suspension code --- app/app/Enums/WorkspaceSuspensionStatus.php | 26 ++-- app/app/Helpers/BillingDataHelper.php | 101 ++++++++++++++- app/app/Helpers/WorkspaceSuspensionHelper.php | 51 ++++---- .../Controllers/Admin/WorkspaceController.php | 15 +-- app/app/Http/Controllers/MergedController.php | 119 +++++------------- app/app/Http/routes.php | 1 + app/app/WorkspaceSuspension.php | 2 +- ...iod_extension_to_workspace_suspensions.php | 2 +- 8 files changed, 171 insertions(+), 146 deletions(-) diff --git a/app/app/Enums/WorkspaceSuspensionStatus.php b/app/app/Enums/WorkspaceSuspensionStatus.php index 6486494d8..bff6ee311 100644 --- a/app/app/Enums/WorkspaceSuspensionStatus.php +++ b/app/app/Enums/WorkspaceSuspensionStatus.php @@ -7,23 +7,23 @@ */ abstract class WorkspaceSuspensionStatus { - public const NOT_SUSPENDED = '0'; - public const PENDING_SUSPENSION = 'pending_suspension'; - public const REAL_SUSPENSION = 'real_suspension'; + public const INITIATED = 'INITIATED'; + public const SUSPENDED = 'SUSPENDED'; + public const LIFTED = 'LIFTED'; public static function all(): array { return [ - self::NOT_SUSPENDED, - self::PENDING_SUSPENSION, - self::REAL_SUSPENSION, + self::INITIATED, + self::SUSPENDED, + self::LIFTED, ]; } public static function activeValues(): array { return [ - self::REAL_SUSPENSION, + self::SUSPENDED, true, 1, '1', @@ -33,7 +33,7 @@ public static function activeValues(): array public static function activeDatabaseValues(): array { return [ - self::REAL_SUSPENSION, + self::SUSPENDED, '1', ]; } @@ -41,7 +41,7 @@ public static function activeDatabaseValues(): array public static function isActive($status): bool { return in_array($status, [ - self::REAL_SUSPENSION, + self::SUSPENDED, true, 1, '1', @@ -51,13 +51,13 @@ public static function isActive($status): bool public static function label($status): string { if (self::isActive($status)) { - return 'Real Suspension'; + return 'Suspended'; } - if ($status === self::PENDING_SUSPENSION) { - return 'Pending Suspension'; + if ($status === self::INITIATED) { + return 'Initiated'; } - return 'Not Suspended'; + return 'Lifted'; } } diff --git a/app/app/Helpers/BillingDataHelper.php b/app/app/Helpers/BillingDataHelper.php index fc2ca8535..c66d5e649 100755 --- a/app/app/Helpers/BillingDataHelper.php +++ b/app/app/Helpers/BillingDataHelper.php @@ -11,6 +11,7 @@ use App\CustomizationsKVStore; use App\ApiCredentialKVStore; +use App\ServicePlan; use App\Settings; use App\UserCredit; use App\UserDebit; @@ -34,7 +35,7 @@ public static function updateWorkspaceBilling($gateway, $cardData, $user, $works } public static function getBillingHistory($workspace) { //$data = DB::select(sprintf('select * from (select status, cents, created_at, \'credit\' as type, user_id from users_credits union select status, cents, created_at, \'invoice\' as type, user_id from users_invoices order by created_at desc) as U where U.user_id = "%s";', $user->id)); $data = DB::select(sprintf('select * from (select status, cents, created_at, \'credit\' as type, user_id from users_credits union select status, cents, created_at, \'invoice\' as type, user_id from users_invoices order by created_at desc) as U where U.user_id = "%s";', $user->id)); - $data = DB::select(sprintf('select id, status, cents, created_at, \'invoice\' as type, user_id from users_invoices order by created_at desc where workspace_id = "%s";', $workspace->id)); + $data = DB::select(sprintf('select id, status, cents, created_at, \'invoice\' as type, user_id from users_invoices where workspace_id = "%s" order by created_at desc;', $workspace->id)); $data = array_map(function($item) { $array = (array) $item; $array['dollars'] = self::toDollars($array['cents']); @@ -292,4 +293,102 @@ public static function refundInvoice($invoice) $invoice->status = PaymentStatus::REFUNDED; $invoice->save(); } + + public static function getUpgradeData($subscription, $planKey, $workspace) { + $newPlan = ServicePlan::where('key_name', $planKey)->first(); + $oldPlan = ServicePlan::find($subscription->current_plan_id); + + $now = new \DateTime(); + + // 2. Determine start date cleanly without a ternary + $last_billable_date = $subscription->last_billed_at; + if (!$last_billable_date) { + $last_billable_date = $subscription->created_at; + } + + // 3. Proration Calculation using Timestamp Precision + // Calculate period end in real time based on billing flow and cycle + $customizations = CustomizationsKVStore::getRecord(); + $billingFlow = $customizations['billing_flow']; + + $isAnnualSubscription = false; + if ($subscription->billing_cycle === 'ANNUAL') { + $isAnnualSubscription = true; + } + + if ($billingFlow === 'ANNUAL') { + if ($isAnnualSubscription) { + $periodEnd = (clone $now)->modify('first day of january next year')->setTime(0, 0, 0); + } else { + $periodEnd = (clone $now)->modify('first day of next month')->setTime(0, 0, 0); + } + } else { + // ANNIVERSARY billing flow + $anchorDate = new \DateTime($last_billable_date); + if ($isAnnualSubscription) { + $periodEnd = (clone $anchorDate)->modify('+1 year'); + } else { + $periodEnd = (clone $anchorDate)->modify('+1 month'); + } + } + + $lastBilled = new \DateTime($last_billable_date); + + $totalCycleSeconds = $periodEnd->getTimestamp() - $lastBilled->getTimestamp(); + $remainingSeconds = $periodEnd->getTimestamp() - $now->getTimestamp(); + + if ($totalCycleSeconds <= 0) { + $totalCycleSeconds = 30 * 86400; // Default fallback to 30 days in seconds + } + + // Explicit pricing logic without ternaries + $isAnnualSubscription = false; + if ($subscription->billing_cycle === 'ANNUAL') { + $isAnnualSubscription = true; + } + + if ($isAnnualSubscription) { + $oldPrice = $oldPlan->annual_cost_cents; + $newPrice = $newPlan->annual_cost_cents; + } else { + $oldPrice = $oldPlan->monthly_cost_cents; + $newPrice = $newPlan->monthly_cost_cents; + } + + \Log::info('Plan upgrade pricing calculation', [ + 'workspace_id' => $workspace->id, + 'old_plan_id' => $oldPlan->id, + 'new_plan_id' => $newPlan->id, + 'is_annual' => $isAnnualSubscription, + 'old_price_cents' => $oldPrice, + 'new_price_cents' => $newPrice, + ]); + + $priceDiff = $newPrice - $oldPrice; + + $prorationCents = 0; + if ($priceDiff > 0 && $remainingSeconds > 0) { + $prorationCents = ($priceDiff * $remainingSeconds) / $totalCycleSeconds; + } + + \Log::info('Proration calculation', [ + 'workspace_id' => $workspace->id, + 'price_diff_cents' => $priceDiff, + 'remaining_seconds' => $remainingSeconds, + 'total_cycle_seconds' => $totalCycleSeconds, + 'proration_cents' => round($prorationCents), + ]); + + + + $feesCents = (int) round($prorationCents); + return [ + 'fees' => $feesCents, + 'fees_dollars' => MainHelper::toDollars($feesCents), + 'new_plan_cost_cents' => $newPrice, + 'new_plan_cost_dollars' => MainHelper::toDollars($newPrice), + 'new_plan' => $newPlan, + 'old_plan' => $oldPlan, + ]; + } } diff --git a/app/app/Helpers/WorkspaceSuspensionHelper.php b/app/app/Helpers/WorkspaceSuspensionHelper.php index 8c931f446..4d1375991 100644 --- a/app/app/Helpers/WorkspaceSuspensionHelper.php +++ b/app/app/Helpers/WorkspaceSuspensionHelper.php @@ -24,30 +24,19 @@ public static function getGlobalGracePeriod() public static function getActiveSuspension($workspaceId) { - if (!Schema::hasTable('workspace_suspensions')) { - return null; - } - - $suspensions = WorkspaceSuspension::where('workspace_id', $workspaceId) + $suspension = WorkspaceSuspension::where('workspace_id', $workspaceId) + ->where('status', '=', WorkspaceSuspensionStatus::SUSPENDED) ->orderBy('suspended_at', 'desc') - ->get(); + ->first(); - foreach ($suspensions as $suspension) { - if (WorkspaceSuspensionStatus::isActive($suspension->status)) { - return $suspension; - } - } - - return null; + return $suspension; } public static function getGracePeriodExtension($workspaceId) { - if (Schema::hasColumn('workspaces', 'grace_period_extension')) { - $workspace = Workspace::find($workspaceId); - if ($workspace && $workspace->grace_period_extension !== null) { - return (int) $workspace->grace_period_extension; - } + $workspace = Workspace::find($workspaceId); + if ($workspace && $workspace->grace_period_extension !== null) { + return (int) $workspace->grace_period_extension; } $suspension = self::getActiveSuspension($workspaceId); @@ -76,30 +65,32 @@ public static function saveGracePeriodExtension(Workspace $workspace, $value) $extension = (int) $value; } - if (Schema::hasColumn('workspaces', 'grace_period_extension')) { - $workspace->grace_period_extension = $extension; - $workspace->save(); - } + $workspace->grace_period_extension = $extension; + $workspace->save(); if ($extension !== null && !$workspace->active) { $workspace->active = true; $workspace->save(); } - if (!Schema::hasTable('workspace_suspensions')) { - return $workspace; - } - - $suspensions = WorkspaceSuspension::where('workspace_id', $workspace->id)->get(); + $suspensions = WorkspaceSuspension::where('workspace_id', $workspace->id) + ->where('status', '!=', WorkspaceSuspensionStatus::LIFTED) + ->get(); if ($suspensions->isEmpty()) { return $workspace; } foreach ($suspensions as $suspension) { - $suspension->grace_period_extension = $extension; if ($extension !== null) { - $suspension->status = WorkspaceSuspensionStatus::NOT_SUSPENDED; + $suspension->grace_period_extension = $extension; + + $now = new DateTime(); + $gracePeriodEnd = (new DateTime($suspension->suspension_initiated_at))->modify("+{$extension} days"); + if ($now < $gracePeriodEnd) { + $suspension->status = WorkspaceSuspensionStatus::INITIATED; + $suspension->suspended_at = null; + } } $suspension->save(); } @@ -109,7 +100,7 @@ public static function saveGracePeriodExtension(Workspace $workspace, $value) public static function suspendWorkspace(Workspace $workspace, $invoice = null, $daysPastDue = null, $threshold = null) { - if (!Schema::hasTable('workspace_suspensions')) { + if (!Schema::hasTable('workspaces_suspensions')) { return null; } diff --git a/app/app/Http/Controllers/Admin/WorkspaceController.php b/app/app/Http/Controllers/Admin/WorkspaceController.php index ff7449b70..29ca5d7ca 100755 --- a/app/app/Http/Controllers/Admin/WorkspaceController.php +++ b/app/app/Http/Controllers/Admin/WorkspaceController.php @@ -140,25 +140,14 @@ public function destroy(Workspace $workspace) public function data() { $globalGracePeriod = WorkspaceSuspensionHelper::getGlobalGracePeriod(); - $realSuspensionStatus = WorkspaceSuspensionStatus::REAL_SUSPENSION; - $activeSuspensionSelect = Schema::hasTable('workspace_suspensions') - ? DB::raw("(select count(*) from workspace_suspensions ws_status where ws_status.workspace_id = workspaces.id and ws_status.status in ('" . $realSuspensionStatus . "', '1')) as active_suspension") - : DB::raw('0 as active_suspension'); + $activeSuspensionSelect = DB::raw("(select count(*) from workspaces_suspensions ws_status where ws_status.workspace_id = workspaces.id and ws_status.status = 'SUSPENDED') as active_suspension"); $gracePeriodSources = array(); - if (Schema::hasColumn('workspaces', 'grace_period_extension')) { - $gracePeriodSources[] = 'workspaces.grace_period_extension'; - } - if (Schema::hasTable('workspace_suspensions')) { - $gracePeriodSources[] = "(select ws.grace_period_extension from workspace_suspensions ws where ws.workspace_id = workspaces.id and ws.status in ('" . $realSuspensionStatus . "', '1') and ws.grace_period_extension is not null order by ws.suspended_at desc limit 1)"; - } - $gracePeriodSources[] = (int) $globalGracePeriod; - $gracePeriodSelect = DB::raw('COALESCE(' . implode(', ', $gracePeriodSources) . ') as grace_period'); $workspaces = DB::table('workspaces')->select(array( 'workspaces.id', 'workspaces.name', 'workspaces.active', - $gracePeriodSelect, + DB::raw('workspaces.grace_period_extension AS grace_period'), 'workspaces.created_at', $activeSuspensionSelect )); diff --git a/app/app/Http/Controllers/MergedController.php b/app/app/Http/Controllers/MergedController.php index ee2945184..972794b5d 100755 --- a/app/app/Http/Controllers/MergedController.php +++ b/app/app/Http/Controllers/MergedController.php @@ -195,6 +195,29 @@ public function plans(Request $request) return $this->response->array($plans); } + public function getUpgradeFees(Request $request) + { + $planKey = $request->get('plan_key'); + if (!$planKey) { + return $this->response->errorBadRequest("Plan key is required."); + } + + $workspace = $this->getWorkspace($request); + $subscription = Subscription::where('workspace_id', $workspace->id)->first(); + + if (!$subscription) { + return $this->response->errorBadRequest("Invalid subscription or plan."); + } + + $upgradeData = BillingDataHelper::getUpgradeData($subscription, $planKey, $workspace); + return $this->response->array([ + 'fees' => $upgradeData['fees'], + 'fees_dollars' => $upgradeData['fees_dollars'], + 'new_plan_cost_cents' => $upgradeData['new_plan_cost_cents'], + 'new_plan_cost_dollars' => $upgradeData['new_plan_cost_dollars'] + ]); + } + public function upgradePlan(Request $request) { $json = $request->json()->all(); @@ -223,97 +246,17 @@ public function upgradePlan(Request $request) return $this->response->errorBadRequest("A plan upgrade is already scheduled."); } - $newPlan = ServicePlan::where('key_name', $planKey)->first(); - $oldPlan = ServicePlan::find($subscription->current_plan_id); - - if (!$newPlan || !$oldPlan) { - return $this->response->errorBadRequest("Invalid subscription or plan config."); - } - - // 2. Determine start date cleanly without a ternary - $last_billable_date = $subscription->last_billed_at; - if (!$last_billable_date) { - $last_billable_date = $subscription->created_at; - } - - // 3. Proration Calculation using Timestamp Precision - // Calculate period end in real time based on billing flow and cycle + $upgradeData = BillingDataHelper::getUpgradeData($subscription, $planKey, $workspace); + // 4. Determine Scheduled Effective Date without ternaries + $scheduledEffectiveDate = null; $customizations = CustomizationsKVStore::getRecord(); - $billingFlow = $customizations['billing_flow']; - - $isAnnualSubscription = false; - if ($subscription->billing_cycle === 'ANNUAL') { - $isAnnualSubscription = true; - } - if ($billingFlow === 'ANNUAL') { - if ($isAnnualSubscription) { - $periodEnd = (clone $now)->modify('first day of january next year')->setTime(0, 0, 0); - } else { - $periodEnd = (clone $now)->modify('first day of next month')->setTime(0, 0, 0); - } - } else { - // ANNIVERSARY billing flow - $anchorDate = new \DateTime($last_billable_date); - if ($isAnnualSubscription) { - $periodEnd = (clone $anchorDate)->modify('+1 year'); - } else { - $periodEnd = (clone $anchorDate)->modify('+1 month'); - } - } - - $lastBilled = new \DateTime($last_billable_date); - - $totalCycleSeconds = $periodEnd->getTimestamp() - $lastBilled->getTimestamp(); - $remainingSeconds = $periodEnd->getTimestamp() - $now->getTimestamp(); - - if ($totalCycleSeconds <= 0) { - $totalCycleSeconds = 30 * 86400; // Default fallback to 30 days in seconds - } + $billingFlow = $customizations['billing_flow']; - // Explicit pricing logic without ternaries $isAnnualSubscription = false; if ($subscription->billing_cycle === 'ANNUAL') { $isAnnualSubscription = true; } - - if ($isAnnualSubscription) { - $oldPrice = $oldPlan->annual_cost_cents; - $newPrice = $newPlan->annual_cost_cents; - } else { - $oldPrice = $oldPlan->monthly_cost_cents; - $newPrice = $newPlan->monthly_cost_cents; - } - - \Log::info('Plan upgrade pricing calculation', [ - 'workspace_id' => $workspace->id, - 'old_plan_id' => $oldPlan->id, - 'new_plan_id' => $newPlan->id, - 'is_annual' => $isAnnualSubscription, - 'old_price_cents' => $oldPrice, - 'new_price_cents' => $newPrice, - ]); - - $priceDiff = $newPrice - $oldPrice; - - $prorationCents = 0; - if ($priceDiff > 0 && $remainingSeconds > 0) { - $prorationCents = ($priceDiff * $remainingSeconds) / $totalCycleSeconds; - } - - \Log::info('Proration calculation', [ - 'workspace_id' => $workspace->id, - 'price_diff_cents' => $priceDiff, - 'remaining_seconds' => $remainingSeconds, - 'total_cycle_seconds' => $totalCycleSeconds, - 'proration_cents' => round($prorationCents), - ]); - - // 4. Determine Scheduled Effective Date without ternaries - $scheduledEffectiveDate = null; - $customizations = CustomizationsKVStore::getRecord(); - - $billingFlow = $customizations['billing_flow']; if ($billingFlow === 'ANNUAL') { if ($isAnnualSubscription) { $modifier = 'first day of january next year'; @@ -336,14 +279,15 @@ public function upgradePlan(Request $request) $scheduledEffectiveDate = (clone $anchorDateTime)->modify($modifier); } + $newPlan = $upgradeData['new_plan']; RabbitMQHelper::dispatchWorkspaceUpgrade( $workspace->id, $workspace->creator_id, - (int) round($prorationCents), + $upgradeData['fees'], $subscription->id, $subscription->current_plan_id, $newPlan->id, - $scheduledEffectiveDate->format('Y-m-d H:i:s'), + $scheduledEffectiveDate->format('Y-m-d'), $card->id, $card->stripe_payment_method_id, $card->issuer, @@ -354,13 +298,14 @@ public function upgradePlan(Request $request) $props = [ "billing_status" => "immediate_upgrade_pending_reconciliation", "new_plan" => $planKey, - "proration_applied" => (int) round($prorationCents) + "proration_applied" => $upgradeData['fees'] ]; WorkspaceEvent::addEvent($workspace, 'PLAN_UPGRADED', $props); return $this->response->noContent(); } + public function dashboard(Request $request) { $dayCount = 7; diff --git a/app/app/Http/routes.php b/app/app/Http/routes.php index 44b352b63..aa35aba20 100755 --- a/app/app/Http/routes.php +++ b/app/app/Http/routes.php @@ -587,6 +587,7 @@ $api->get('dashboard', '\App\Http\Controllers\MergedController@dashboard'); $api->get('feed', '\App\Http\Controllers\MergedController@feed'); $api->post('upgradePlan', '\App\Http\Controllers\MergedController@upgradePlan'); + $api->get('getUpgradeFees', '\App\Http\Controllers\MergedController@getUpgradeFees'); $api->get('plans', '\App\Http\Controllers\MergedController@plans'); $api->get('billing', '\App\Http\Controllers\MergedController@billing'); $api->get('billing/viewEstimatedCharges', '\App\Http\Controllers\BillingController@viewEstimatedCharges'); diff --git a/app/app/WorkspaceSuspension.php b/app/app/WorkspaceSuspension.php index d9a04df3d..2f07a79c6 100644 --- a/app/app/WorkspaceSuspension.php +++ b/app/app/WorkspaceSuspension.php @@ -11,7 +11,7 @@ class WorkspaceSuspension extends Model protected $dates = ['suspended_at']; protected $guarded = array('id'); - protected $table = 'workspace_suspensions'; + protected $table = 'workspaces_suspensions'; protected $casts = array( 'status' => 'boolean', 'grace_period_extension' => 'integer', diff --git a/app/database/migrations/2026_05_22_000001_add_grace_period_extension_to_workspace_suspensions.php b/app/database/migrations/2026_05_22_000001_add_grace_period_extension_to_workspace_suspensions.php index 414f9feab..fac0a8780 100644 --- a/app/database/migrations/2026_05_22_000001_add_grace_period_extension_to_workspace_suspensions.php +++ b/app/database/migrations/2026_05_22_000001_add_grace_period_extension_to_workspace_suspensions.php @@ -17,7 +17,7 @@ public function up() $table->timestamp('suspended_at'); $table->integer('grace_period_extension')->nullable(); $table->string('reason'); - $table->boolean('status')->default(true); + $table->string('status')->default('INITIATED'); $table->foreign('workspace_id')->references('id')->on('workspaces')->onDelete('CASCADE'); $table->index(array('workspace_id', 'status'));