diff --git a/app/app/Enums/ServicePlanStatus.php b/app/app/Enums/ServicePlanStatus.php new file mode 100644 index 000000000..2ebcfa9cd --- /dev/null +++ b/app/app/Enums/ServicePlanStatus.php @@ -0,0 +1,25 @@ + 'workspace_upgrade_' . (int) $workspaceId . '_' . time(), + 'workspace_id' => (int) $workspaceId, + 'subscription_id' => (int) $subscriptionId, + 'upgrade_fee' => (int) $upgradeFee, + 'current_plan' => (int) $currentPlan, + 'scheduled_plan' => (int) $scheduledPlan, + 'scheduled_effective_date' => (string) $scheduledEffectiveDate, + ]; + + return self::publish('workspace_upgrades', $payload); + } + + } diff --git a/app/app/Helpers/WorkspaceInvoiceHelper.php b/app/app/Helpers/WorkspaceInvoiceHelper.php index 7b7beb78a..c7cd7c681 100644 --- a/app/app/Helpers/WorkspaceInvoiceHelper.php +++ b/app/app/Helpers/WorkspaceInvoiceHelper.php @@ -63,22 +63,27 @@ public static function sendInvoiceForWorkspace(Workspace $workspace, $period, Us $filePrefix = 'monthly'; } - Mail::send('emails.workspace_invoices', [ - 'user' => $owner, - 'workspace' => $workspace, - 'site' => $siteName, - 'site_name' => $siteName, - 'customizations' => $customizations, - 'period' => $period - ], function ($message) use ($owner, $mailConfig, $subject, $pdf, $invoice, $filePrefix) { - $message->to($owner->email); - $message->subject($subject); - $from = $mailConfig['from']; - $message->from($from['address'], $from['name']); - $message->attachData($pdf, sprintf('%s_invoice_%s.pdf', $filePrefix, $invoice->invoice_no), [ - 'mime' => 'application/pdf' - ]); - }); + try { + Mail::send('emails.workspace_invoices', [ + 'user' => $owner, + 'workspace' => $workspace, + 'site' => $siteName, + 'site_name' => $siteName, + 'customizations' => $customizations, + 'period' => $period + ], function ($message) use ($owner, $mailConfig, $subject, $pdf, $invoice, $filePrefix) { + $message->to($owner->email); + $message->subject($subject); + $from = $mailConfig['from']; + $message->from($from['address'], $from['name']); + $message->attachData($pdf, sprintf('%s_invoice_%s.pdf', $filePrefix, $invoice->invoice_no), [ + 'mime' => 'application/pdf' + ]); + }); + } catch (\Exception $e) { + \Log::error('Failed to send invoice email for workspace #' . $workspace->id . ': ' . $e->getMessage()); + throw $e; + } return [ 'workspace_id' => $workspace->id, @@ -257,7 +262,8 @@ private static function createInvoice(User $owner, Workspace $workspace, $period 'recording_costs' => $recordingCosts, 'fax_costs' => $faxCosts, 'membership_costs' => $membershipCosts, - 'number_costs' => $numberCosts + 'number_costs' => $numberCosts, + 'source_service' => 'SITE' ]); $invoice->created_at = $rangeStart; diff --git a/app/app/Http/Controllers/Admin/ServicePlanController.php b/app/app/Http/Controllers/Admin/ServicePlanController.php index ba69fa3b5..e1450dde7 100755 --- a/app/app/Http/Controllers/Admin/ServicePlanController.php +++ b/app/app/Http/Controllers/Admin/ServicePlanController.php @@ -8,6 +8,8 @@ use App\PortNumber; use App\Subscription; use App\WorkspaceUser; + +use App\Enums\ServicePlanStatus; use App\Http\Requests\Admin\ServicePlanRequest; use App\Helpers\MainHelper; use Datatables; @@ -45,7 +47,9 @@ public function create() $features = $this->getFeatureOptions(); $callDurations = $this->getCallDurations(); $recordingSpace = $this->getRecordingSpaceOptions(); - return view('admin.serviceplan.create_edit', compact('features', 'callDurations', 'recordingSpace')); + $statuses = ServicePlanStatus::all(); + + return view('admin.serviceplan.create_edit', compact('features', 'callDurations', 'recordingSpace', 'statuses')); } /** @@ -86,12 +90,13 @@ public function edit(ServicePlan $serviceplan) $features = $this->getFeatureOptions(); $callDurations = $this->getCallDurations(); $recordingSpace = $this->getRecordingSpaceOptions(); + $statuses = ServicePlanStatus::all(); $migratePlans = []; $allPlans = ServicePlan::where('id', '!=', $serviceplan->id)->get()->toArray(); foreach ($allPlans as $key => $plan) { $migratePlans[$plan['nice_name']] = $plan['nice_name'] . ' (' . $plan['key_name'] . ')'; } - return view('admin.serviceplan.create_edit', compact('serviceplan', 'features', 'callDurations', 'recordingSpace', 'migratePlans')); + return view('admin.serviceplan.create_edit', compact('serviceplan', 'features', 'callDurations', 'recordingSpace', 'migratePlans', 'statuses')); } /** * Migrate all users on this service plan to a target service plan. diff --git a/app/app/Http/Controllers/HomeController.php b/app/app/Http/Controllers/HomeController.php index f0c00b8b1..5b3ff313f 100755 --- a/app/app/Http/Controllers/HomeController.php +++ b/app/app/Http/Controllers/HomeController.php @@ -69,11 +69,7 @@ public function index() } public function pricing(Request $request) { - $plans = ServicePlan::where('include_in_pricing_pages', '1') - ->orderBy('rank') - ->orderBy('nice_name') - ->get(); - $plans = ServicePlan::sortPlansByFeatures($plans); + $plans = ServicePlan::getAvailablePlans(TRUE); $competitors = Competitor::all(); $savings = CostSaving::select(array('cost_savings.*', DB::raw('competitors.name AS competitor_name'))); $savings = $savings->join('competitors', 'competitors.id', '=', 'cost_savings.competitor_id'); diff --git a/app/app/Http/Controllers/MergedController.php b/app/app/Http/Controllers/MergedController.php index d009173f3..53a2efd65 100755 --- a/app/app/Http/Controllers/MergedController.php +++ b/app/app/Http/Controllers/MergedController.php @@ -56,6 +56,7 @@ use App\Helpers\DNSHelper; use App\Helpers\PhoneProvisionHelper; use App\Helpers\BillingDataHelper; +use App\Helpers\RabbitMQHelper; use App\Helpers\PortalSearchHelper; use App\Helpers\SMSHelper; @@ -197,85 +198,116 @@ public function plans(Request $request) public function upgradePlan(Request $request) { $json = $request->json()->all(); - $planKey = $json['plan']; + $planKey = null; + if (isset($json['plan'])) { + $planKey = $json['plan']; + } + + if (!$planKey) { + return $this->response->errorBadRequest("Plan key is required."); + } $workspace = $this->getWorkspace($request); $user = $this->getUser($request); $now = new \DateTime(); - // 1. Get the current subscription and the target plan + // 1. Fetch data & early guard clauses $subscription = Subscription::where('workspace_id', $workspace->id)->first(); + + if (!$subscription) { + return $this->response->errorBadRequest("Invalid subscription or plan."); + } + + if (!empty($subscription->scheduled_plan_id)) { + 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 (!$subscription || !$newPlan) { - return $this->response->errorBadRequest("Invalid subscription or plan."); + if (!$newPlan || !$oldPlan) { + return $this->response->errorBadRequest("Invalid subscription or plan config."); } - // 2. Calculate Proration - // We determine the daily rate difference and multiply by days remaining - $periodEnd = new \DateTime($subscription->current_period_end); - $remainingDays = $now->diff($periodEnd)->days; - - // Determine the total days in this cycle to get an accurate daily ratio + // 2. Determine start date cleanly without a ternary $start_date = $subscription->last_billed_at; if (!$start_date) { $start_date = $subscription->created_at; } + + // 3. Proration Calculation using Timestamp Precision + $periodEnd = new \DateTime($subscription->current_period_end); $lastBilled = new \DateTime($start_date); - $totalCycleDays = $lastBilled->diff($periodEnd)->days; - if ($totalCycleDays == 0) { - $totalCycleDays = 30; // Default to 30 if zero + + $totalCycleSeconds = $periodEnd->getTimestamp() - $lastBilled->getTimestamp(); + $remainingSeconds = $periodEnd->getTimestamp() - $now->getTimestamp(); + + if ($totalCycleSeconds <= 0) { + $totalCycleSeconds = 30 * 86400; // Default fallback to 30 days in seconds } - if ($subscription->billing_cycle === 'ANNUAL') { + // Explicit pricing logic without ternaries + $isAnnual = ($subscription->billing_cycle === 'ANNUAL'); + if ($isAnnual) { $oldPrice = $oldPlan->annual_cost_cents; $newPrice = $newPlan->annual_cost_cents; } else { $oldPrice = $oldPlan->monthly_cost_cents; $newPrice = $newPlan->monthly_cost_cents; } - + $priceDiff = $newPrice - $oldPrice; $prorationCents = 0; - if ($priceDiff > 0) { - $prorationCents = ($priceDiff * $remainingDays) / $totalCycleDays; - - // 3. Create the pending line item (orphan item with invoice_id = NULL) - UserInvoiceLineItem::create([ - 'created_at' => $now, - 'updated_at' => $now, - 'is_recurring' => 0, - 'name' => "Upgrade Adjustment: {$oldPlan->name} to {$newPlan->name}", - 'cents' => (int) $prorationCents, - 'invoice_id' => null, - 'key_name' => 'plan_upgrade_proration' - ]); + if ($priceDiff > 0 && $remainingSeconds > 0) { + $prorationCents = ($priceDiff * $remainingSeconds) / $totalCycleSeconds; } - // 4. Update Subscription and Workspace - $subscription->update([ - 'current_plan_id' => $newPlan->id, - 'updated_at' => $now - ]); + // 4. Determine Scheduled Effective Date without ternaries + $scheduledEffectiveDate = null; + $customizations = CustomizationsKVStore::getRecord(); + + $billingFlow = 'ANNIVERSARY'; + if (isset($customizations['billing_flow'])) { + $billingFlow = $customizations['billing_flow']; + } - // Update Plan Usage Periods - PlanUsagePeriod::where('workspace_id', $workspace->id) - ->whereNull('ended_at') - ->update(['ended_at' => $now]); + if ($billingFlow === 'ANNUAL') { + if ($isAnnual) { + $modifier = 'first day of january next year'; + } else { + $modifier = 'first day of next month'; + } + $scheduledEffectiveDate = (clone $now)->modify($modifier)->setTime(0, 0, 0); + } else { + $anchor_date = $subscription->last_billed_at; + if (!$anchor_date) { + $anchor_date = $subscription->created_at; + } + $anchorDateTime = new \DateTime($anchor_date); + + if ($isAnnual) { + $modifier = '+1 year'; + } else { + $modifier = '+1 month'; + } + $scheduledEffectiveDate = (clone $anchorDateTime)->modify($modifier); + } - PlanUsagePeriod::create([ - 'workspace_id' => $workspace->id, - 'started_at' => $now, - 'plan' => $planKey - ]); + RabbitMQHelper::dispatchWorkspaceUpgrade( + $workspace->id, + (int) round($prorationCents), + $subscription->id, + $subscription->current_plan_id, + $newPlan->id, + $scheduledEffectiveDate ? $scheduledEffectiveDate->format('Y-m-d H:i:s') : null + ); - // Analytics and Communication + // 6. Side Effect Operations (Ideally should be queued background jobs) $props = [ "billing_status" => "immediate_upgrade_pending_reconciliation", "new_plan" => $planKey, - "proration_applied" => $prorationCents + "proration_applied" => (int) round($prorationCents) ]; WorkspaceEvent::addEvent($workspace, 'PLAN_UPGRADED', $props); @@ -303,7 +335,15 @@ public function dashboard(Request $request) $workspace = $this->getWorkspace($request); //$plans = \Config::get("service_plans"); //$plan = $plans[ $workspace->plan ]; - $plan = ServicePlan::where('key_name', $workspace->plan)->firstOrFail(); + $subscription = Subscription::where('workspace_id', $workspace->id)->first(); + $currentPlan = ServicePlan::find($subscription->current_plan_id); + $scheduledPlan = ServicePlan::find($subscription->scheduled_plan_id); + $effectivePlan = $currentPlan; + if (!empty($scheduledPlan)) { + $effectivePlan = $scheduledPlan; + } + + $plan = $effectivePlan; while ($currentDay != $dayCount) { $labels[] = $cloned1->format("M-d"); $cloned2 = clone $cloned1; @@ -467,7 +507,8 @@ public function billing(Request $request) $cards, $config, $billingHistory, - $triggers + $triggers, + $subscription->toArray() ]); } public function getCountries() { @@ -873,10 +914,7 @@ public function getAllSettings(Request $request) { } public function getServicePlans(Request $request) { - $plans = ServicePlan::orderBy('rank') - ->orderBy('nice_name') - ->get() - ->toArray(); + $plans = ServicePlan::getAvailablePlans(); $features = [ 'fax', @@ -916,7 +954,7 @@ public function getServicePlans(Request $request) { $plan_benefits = []; // compare the previous plan with the current one to get the benefits $last_cnt = $cnt - 1; - if ( array_key_exists( $last_cnt, $plans) ) { + if ( isset( $plans[$last_cnt] ) ) { $last_plan = $plans[$last_cnt]; foreach ($features as $feature) { $has_feature_1 = $plan[$feature]; diff --git a/app/app/ServicePlan.php b/app/app/ServicePlan.php index 4ce15ccbf..60ca0d310 100755 --- a/app/app/ServicePlan.php +++ b/app/app/ServicePlan.php @@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; use App\ApiResource; use App\Helpers\MainHelper; +use App\Enums\ServicePlanStatus; class ServicePlan extends Model { use SoftDeletes; @@ -97,4 +98,17 @@ public static function getRecurringMembershipPlans() { return self::where('pay_as_you_go', '0')->get(); } + public static function getAvailablePlans($pricingPage = false) { + $plans = ServicePlan::where('status', ServicePlanStatus::ACTIVE) + ->orderBy('rank') + ->orderBy('nice_name'); + if ($pricingPage) { + $plans = $plans->where('include_in_pricing_pages', '1'); + } + + return $plans->get(); + } + + + } diff --git a/app/app/Subscription.php b/app/app/Subscription.php index 7d50598a3..cea0a4ebc 100755 --- a/app/app/Subscription.php +++ b/app/app/Subscription.php @@ -16,6 +16,17 @@ class Subscription extends Model { protected $table = "subscriptions"; protected $casts = array( ); + + public function toArray() + { + $array = parent::toArray(); + if (!empty($this->scheduled_plan_id)) { + $array['effective_plan_id'] = $this->scheduled_plan_id; + } else { + $array['effective_plan_id'] = $this->current_plan_id; + } + return $array; + } } diff --git a/app/database/migrations/2026_06_02_204015_add_source_service.php b/app/database/migrations/2026_06_02_204015_add_source_service.php new file mode 100644 index 000000000..c384bd5c3 --- /dev/null +++ b/app/database/migrations/2026_06_02_204015_add_source_service.php @@ -0,0 +1,33 @@ +string('source_service')->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('users_invoices', function (Blueprint $table) { + // + $table->dropColumn('source_service'); + }); + } +} diff --git a/app/resources/lang/en/admin/serviceplans.php b/app/resources/lang/en/admin/serviceplans.php index 1fab0f52a..6c706d4b8 100755 --- a/app/resources/lang/en/admin/serviceplans.php +++ b/app/resources/lang/en/admin/serviceplans.php @@ -30,5 +30,6 @@ 'minutes_per_month' => 'Call Minutes per month', 'migrate_users' => 'Migrate users', 'migrate_warning' => 'Warning: You cannot reverse this change. Users will be moved over to the new plan in the next billing cycle.', - 'select_plan' => 'Select plan' + 'select_plan' => 'Select plan', + 'status' => 'Status', ]; \ No newline at end of file diff --git a/app/resources/views/admin/serviceplan/create_edit.blade.php b/app/resources/views/admin/serviceplan/create_edit.blade.php index d971037e2..b511bbed3 100755 --- a/app/resources/views/admin/serviceplan/create_edit.blade.php +++ b/app/resources/views/admin/serviceplan/create_edit.blade.php @@ -81,6 +81,16 @@ +