From d6c9e864490da451a486889dc6ba66796de37603 Mon Sep 17 00:00:00 2001 From: Nadir Hamid Date: Wed, 24 Jun 2026 17:29:05 +0000 Subject: [PATCH] integrate code and database fields for free trial support --- app/app/Helpers/RabbitMQHelper.php | 5 +- .../Admin/CustomizationsController.php | 14 + .../Admin/ServicePlanController.php | 1 + app/app/Http/Controllers/MergedController.php | 110 ++- .../Http/Controllers/RegisterController.php | 798 +++++++++--------- app/create_billing_task.php | 29 + ...026_06_24_160500_add_free_trial_fields.php | 33 + ...5_add_free_trial_opts_to_service_plans.php | 33 + app/resources/lang/en/admin/serviceplans.php | 1 + .../views/admin/customizations/view.blade.php | 29 + 10 files changed, 632 insertions(+), 421 deletions(-) create mode 100755 app/create_billing_task.php create mode 100644 app/database/migrations/2026_06_24_160500_add_free_trial_fields.php create mode 100644 app/database/migrations/2026_06_24_160715_add_free_trial_opts_to_service_plans.php diff --git a/app/app/Helpers/RabbitMQHelper.php b/app/app/Helpers/RabbitMQHelper.php index 4e8f939a2..ccc3163b6 100755 --- a/app/app/Helpers/RabbitMQHelper.php +++ b/app/app/Helpers/RabbitMQHelper.php @@ -126,7 +126,7 @@ public static function dispatchWorkspaceSuspended($workspace, $suspension, $owne * Dispatches the 'immediate' billing task. * $amount should be the prorated value calculated via MainHelper. */ - public static function dispatchImmediateBilling($workspace, $subscription, $user, $servicePlan, $billingCycle, $amount) + public static function dispatchImmediateBilling($workspace, $subscription, $user, $servicePlan, $billingCycle, $amount, $nextBillingDate) { $payload = [ 'run_id' => 'signup_' . $user->id . '_' . time(), @@ -136,7 +136,8 @@ public static function dispatchImmediateBilling($workspace, $subscription, $user 'creator_id' => (int) $user->id, 'action' => 'IMMEDIATE', 'amount' => $amount, - 'plan_to_bill' => (int) $servicePlan->id + 'plan_to_bill' => (int) $servicePlan->id, + 'next_billing_date' => $nextBillingDate ]; return self::publish('billing_tasks', $payload); diff --git a/app/app/Http/Controllers/Admin/CustomizationsController.php b/app/app/Http/Controllers/Admin/CustomizationsController.php index 6b5293969..cb30ac905 100755 --- a/app/app/Http/Controllers/Admin/CustomizationsController.php +++ b/app/app/Http/Controllers/Admin/CustomizationsController.php @@ -151,6 +151,20 @@ public function save(Request $request) } $update_params['admin_portal_logo'] = $admin_logo; } + $is_trial_enabled = false; + if ( !empty( $update_params['is_trial_enabled'] ) ) { + if ( $update_params['billing_flow'] === 'ANNUAL' ) { + $session = $request->session(); + $session->flash('type', 'error'); + $session->flash('message', 'Trial mode cannot be enabled with ANNUAL billing flow'); + return redirect("/admin/customizations"); + } + if ( $update_params['billing_flow'] === 'ANNIVERSARY' ) { + $is_trial_enabled = true; + } + } + $update_params['is_trial_enabled'] = $is_trial_enabled; + $payments_enabled = false; if ( $update_params['payment_gateway_enabled'] =='yes') { diff --git a/app/app/Http/Controllers/Admin/ServicePlanController.php b/app/app/Http/Controllers/Admin/ServicePlanController.php index e1450dde7..11e38d083 100755 --- a/app/app/Http/Controllers/Admin/ServicePlanController.php +++ b/app/app/Http/Controllers/Admin/ServicePlanController.php @@ -192,6 +192,7 @@ private function getFeatureOptions() { $this->createFeatureOption('featured_plan'), $this->createFeatureOption('pay_as_you_go'), $this->createFeatureOption('include_in_pricing_pages'), + $this->createFeatureOption('free_trial_exempt'), ]; } diff --git a/app/app/Http/Controllers/MergedController.php b/app/app/Http/Controllers/MergedController.php index efce3dbe5..5c8e84f0a 100755 --- a/app/app/Http/Controllers/MergedController.php +++ b/app/app/Http/Controllers/MergedController.php @@ -86,6 +86,7 @@ use League\Fractal\Resource\Collection; use App\Transformers\RecordingTransformer; use App\Transformers\CallTransformer; +use App\Enums\WorkspaceUserStatus; use App\UserCredit; use DateTime; @@ -760,7 +761,8 @@ public function acceptWorkspaceInvite(Request $request) { $workspace = Workspace::findOrFail($workspaceUser->workspace_id); $workspaceUser->update([ 'accepted' => TRUE, - 'hash_expired' => TRUE + 'hash_expired' => TRUE, + 'status' => WorkspaceUserStatus::ACTIVE ]); @@ -962,9 +964,113 @@ public function getServicePlans(Request $request) { $item['benefits'] = $plan_benefits; $results[] = $item; } - return $this->response->array($results); + + // Calculate next billing dates once + $currentDate = new \DateTime(); + $billingDates = []; + + // Check billing flow configuration (anniversary vs annual billing) + $customizations = CustomizationsKVStore::getRecord(); + $billingFlow = $customizations['billing_flow']; + + // For anniversary billing: add 1 month to current date with date clamping + if ($billingFlow === 'ANNIVERSARY') { + // Add 1 month for monthly billing + $nextMonthlyDate = clone $currentDate; + $nextMonthlyDate->add(new \DateInterval('P1M')); + + // Handle date clamping: if the day doesn't exist in next month, move to end of month + $originalDay = $currentDate->format('d'); + $lastDayOfMonth = $nextMonthlyDate->format('t'); + if ((int)$originalDay > (int)$lastDayOfMonth) { + $nextMonthlyDate->setDate($nextMonthlyDate->format('Y'), $nextMonthlyDate->format('m'), $lastDayOfMonth); + } + + $billingDates['next_monthly_billing_date'] = $nextMonthlyDate->format('Y-m-d'); + $billingDates['next_monthly_billing_date_formatted'] = $nextMonthlyDate->format('M d, Y'); + + // Add 1 year for annual billing with same date clamping + $nextAnnualDate = clone $currentDate; + $nextAnnualDate->add(new \DateInterval('P1Y')); + + // Handle date clamping for leap years (Feb 29) + $originalDay = $currentDate->format('d'); + $lastDayOfMonth = $nextAnnualDate->format('t'); + if ((int)$originalDay > (int)$lastDayOfMonth) { + $nextAnnualDate->setDate($nextAnnualDate->format('Y'), $nextAnnualDate->format('m'), $lastDayOfMonth); + } + + // Apply trial period to billing dates if enabled and plan is not exempt + $isTrialEnabled = $customizations['is_trial_enabled']; + $trialDurationDays = $customizations['trial_duration_days']; + + $billingDates['next_annual_billing_date'] = $nextAnnualDate->format('Y-m-d'); + $billingDates['next_annual_billing_date_formatted'] = $nextAnnualDate->format('M d, Y'); + $billingDates['next_monthly_billing_date'] = $nextMonthlyDate->format('Y-m-d'); + $billingDates['next_monthly_billing_date_formatted'] = $nextMonthlyDate->format('M d, Y'); + + + if ($isTrialEnabled && $trialDurationDays > 0) { + // Add trial duration to billing dates + $trialInterval = new \DateInterval('P' . $trialDurationDays . 'D'); + + $nextMonthlyDateWithTrial = clone $currentDate; + $nextMonthlyDateWithTrial->add(new \DateInterval('P1M')); + + // Handle date clamping for monthly + $originalDay = $currentDate->format('d'); + $lastDayOfMonth = $nextMonthlyDateWithTrial->format('t'); + if ((int)$originalDay > (int)$lastDayOfMonth) { + $nextMonthlyDateWithTrial->setDate($nextMonthlyDateWithTrial->format('Y'), $nextMonthlyDateWithTrial->format('m'), $lastDayOfMonth); + } + + $nextMonthlyDateWithTrial->add($trialInterval); + $billingDates['next_monthly_billing_date_w_trial'] = $nextMonthlyDateWithTrial->format('Y-m-d'); + $billingDates['next_monthly_billing_date_w_trial_formatted'] = $nextMonthlyDateWithTrial->format('M d, Y'); + + $nextAnnualDateWithTrial = clone $currentDate; + $nextAnnualDateWithTrial->add(new \DateInterval('P1Y')); + + // Handle date clamping for annual (leap years) + $originalDay = $currentDate->format('d'); + $lastDayOfMonth = $nextAnnualDateWithTrial->format('t'); + if ((int)$originalDay > (int)$lastDayOfMonth) { + $nextAnnualDateWithTrial->setDate($nextAnnualDateWithTrial->format('Y'), $nextAnnualDateWithTrial->format('m'), $lastDayOfMonth); + } + + $nextAnnualDateWithTrial->add($trialInterval); + $billingDates['next_annual_billing_date_w_trial'] = $nextAnnualDateWithTrial->format('Y-m-d'); + $billingDates['next_annual_billing_date_w_trial_formatted'] = $nextAnnualDateWithTrial->format('M d, Y'); + + } + } else { + // Monthly billing: set to first day of next month + $nextMonthlyDate = clone $currentDate; + $nextMonthlyDate->add(new \DateInterval('P1M')); + $nextMonthlyDate->setDate($nextMonthlyDate->format('Y'), $nextMonthlyDate->format('m'), 1); + $billingDates['next_monthly_billing_date'] = $nextMonthlyDate->format('Y-m-d'); + $billingDates['next_monthly_billing_date_formatted'] = $nextMonthlyDate->format('M d, Y'); + + // Annual billing: set to first day of next year + $nextAnnualDate = clone $currentDate; + $nextAnnualDate->add(new \DateInterval('P1Y')); + $nextAnnualDate->setDate($nextAnnualDate->format('Y'), 1, 1); + $billingDates['next_annual_billing_date'] = $nextAnnualDate->format('Y-m-d'); + $billingDates['next_annual_billing_date_formatted'] = $nextAnnualDate->format('M d, Y'); } + + + $data = [ + 'plans' => $results, + 'billing_dates' => $billingDates, + 'trial_duration_days' => $trialDurationDays + ]; + + + return $this->response->array($data); + } + public function search(Request $request) { $query = $request->get("query"); $result = PortalSearchHelper::search( $query ); diff --git a/app/app/Http/Controllers/RegisterController.php b/app/app/Http/Controllers/RegisterController.php index 71a882c92..a851af0e7 100755 --- a/app/app/Http/Controllers/RegisterController.php +++ b/app/app/Http/Controllers/RegisterController.php @@ -51,7 +51,6 @@ class RegisterController extends ApiAuthController { public function register(Request $request) { - // dd($request->all()); $data = $request->all(); $email = $data['email']; @@ -87,8 +86,6 @@ public function register(Request $request) 'default_router_id' => $mainRouter->id ]); - - PlanUsagePeriod::create([ 'workspace_id' => $workspace->id, 'started_at' => new DateTime() @@ -113,7 +110,6 @@ public function registerVerify(Request $request) $data = $request->all(); $user = User::findOrFail($data['userId']); $code = $data['confirmation_code']; - $code = $data['confirmation_code']; $bypass = "BYPASS-0uu5hIw0CL"; if ($user->call_code == $code || $code == $bypass) { @@ -131,7 +127,7 @@ public function registerSendVerify(Request $request) { $code = rand(100000, 999999); $data = $request->all(); - $phoneUtil = \libphonenumber\PhoneNumberUtil::getInstance(); + $phoneUtil = \libphonenumber\PhoneNumberUtil::getInstance(); $number = $data['mobile_number']; $user = User::findOrFail($data['userId']); $customizations = CustomizationsKVStore::getRecord(); @@ -184,7 +180,7 @@ public function registerVerifyHook(Request $request) $data = $request->all(); $userId = $data['userId']; $user = User::findOrFail($userId); - $response = new VoiceResponse(); + $response = new VoiceResponse(); $callCode = str_split($user->call_code); $say = "Your verification code is"; $times = 5; @@ -200,10 +196,11 @@ public function registerVerifyHook(Request $request) } } - return response((string) $response, 200, [ + return response((string) $response, 200, [ 'Content-Type' => 'application/xml' ]); } + public function saveRegistrationQuestionResponses(Request $request) { $data = $request->json()->all(); @@ -219,175 +216,173 @@ public function saveRegistrationQuestionResponses(Request $request) return $this->response->array(['success' => TRUE]); } + public function userSpinup(Request $request) { $data = $request->all(); $user = User::findOrFail($data['userId']); - $customizations =CustomizationsKVStore::getRecord(); + $customizations = CustomizationsKVStore::getRecord(); $plan = ServicePlan::where('key_name', $data['plan'])->firstOrFail(); - //$plan = $plans[$data['plan']]; $region = SIPPoPRegion::findOrFail( $customizations->default_region ); $info = MainHelper::getHostIPForUser($region->code, $user); - //$reservedIp = AWSHelper::reserveIP($region, $ip['main'], $ip['reservedIp']); - //$reservedIp = VultrHelper::reserveIP($region, $ip['main'], $ip."/32"); - /* - if (!$reservedIp) { - return $this->response->errorInternal('could not register IP for user'); - } - */ - - $workspace = Workspace::where('creator_id', '=', $user->id)->first(); - // setup default region - $workspace->update([ - 'default_region' => $region->code - ]); - - Log::info('adding new user to SIP proxy database tables.'); - $result = SIPRouterHelper::updateProxyToEnableWorkspace($user, $workspace, $info['proxy']); - - if (!$result) { - return $this->errorInternal($request, 'could not create/provision user on PBX server'); - } + $workspace = Workspace::where('creator_id', '=', $user->id)->first(); + $workspace->update([ + 'default_region' => $region->code + ]); - // Standardize billing cycle naming for the Go service + Log::info('adding new user to SIP proxy database tables.'); + $result = SIPRouterHelper::updateProxyToEnableWorkspace($user, $workspace, $info['proxy']); - if (isset($data['billing_cycle']) && strtoupper($data['billing_cycle']) === 'ANNUAL') { - $billingCycle = 'ANNUAL'; - } else { - $billingCycle = 'MONTHLY'; - } - $recurringCost = NULL; - - $now = new DateTime(); - if ($billingCycle === 'ANNUAL') { - $periodEnd = (clone $now)->modify('first day of next year')->setTime(0,0,0); - $recurringCost = $plan->annual_cost_cents; - } else { - $periodEnd = (clone $now)->modify('first day of next month')->setTime(0,0,0); - $recurringCost = $plan->monthly_cost_cents; - } + if (!$result) { + return $this->errorInternal($request, 'could not create/provision user on PBX server'); + } - // 1. Create Subscription with Safety Gate anchor - $subscription = Subscription::create([ - 'workspace_id' => $workspace->id, - 'current_plan_id' => $plan->id, - 'status' => 'ACTIVE', - 'billing_cycle' => $billingCycle, - 'current_period_end' => $periodEnd, - 'next_billing_date' => $periodEnd - ]); + if (isset($data['billing_cycle']) && strtoupper($data['billing_cycle']) === 'ANNUAL') { + $billingCycle = 'ANNUAL'; + } else { + $billingCycle = 'MONTHLY'; + } + $recurringCost = NULL; + + $now = new DateTime(); + $anchorDay = (int)$now->format('j'); - // 2. Calculate Prorated Amount using BillingDataHelper (with prorations) - Log::info("Recurring cost: {$recurringCost} cents"); - $recurringCostInDollars = BillingDataHelper::toDollars($recurringCost); - $amountToCharge = BillingDataHelper::calculateProratedAmount($recurringCostInDollars, $billingCycle); - Log::info("Amount to charge for signup (with prorations): {$amountToCharge} cents"); - - // 3. Dispatch Immediate Billing Task - try { - RabbitMQHelper::dispatchImmediateBilling( - $workspace, - $subscription, - $user, - $plan, - $billingCycle, - $amountToCharge - ); - Log::info("Signup Billing Queued: Workspace {$workspace->id}, Amount: {$amountToCharge}"); - } catch (\Exception $e) { - Log::error("RabbitMQ Billing Dispatch Failed: " . $e->getMessage()); - } + if ($billingCycle === 'ANNUAL') { + $periodEnd = (clone $now)->modify('+1 year')->setTime(0,0,0); + $recurringCost = $plan->annual_cost_cents; + } else { + // FIXED: Look ahead to prevent native PHP +1 month edge-case rollover anomalies + $nextMonth = (clone $now)->modify('+1 month'); + $daysInNextMonth = (int)$nextMonth->format('t'); + + if ($anchorDay > $daysInNextMonth) { + // Clamp period end to absolute upper bound of the target calendar block + $periodEnd = $nextMonth->setDate((int)$nextMonth->format('Y'), (int)$nextMonth->format('n'), $daysInNextMonth)->setTime(0,0,0); + } else { + $periodEnd = (clone $now)->modify('+1 month')->setTime(0,0,0); + } + $recurringCost = $plan->monthly_cost_cents; + } + //$billingFlow = MainHelper::getBillingFlow($customizations); + $billingFlow = $customizations['billing_flow']; + $nextBillingDateStr = $periodEnd->format('Y-m-d'); - Log::info('added user successfully.'); + $subscription = Subscription::create([ + 'workspace_id' => $workspace->id, + 'current_plan_id' => $plan->id, + 'status' => 'ACTIVE', + 'billing_cycle' => $billingCycle, + 'current_period_end' => $periodEnd, + 'next_billing_date' => $nextBillingDateStr, + 'billing_anchor_day' => $anchorDay + ]); + Log::info("Recurring cost: {$recurringCost} cents"); + $recurringCostInDollars = $recurringCost / 100; - // create k8s deployments - $svc = "lineblocs-k8s-user"; - $params = array( - 'workspace' => $workspace->name, - 'user_id' => $user->id, - 'workspace_id' => $workspace->id, - ); + if ($billingFlow === 'ANNIVERSARY') { + $amountToCharge = $recurringCostInDollars; + Log::info("Anniversary Billing active: Charging 100% full plan fee."); + } else { + $amountToCharge = BillingDataHelper::calculateProratedAmount($recurringCostInDollars, $billingCycle); + Log::info("Calendar Static Billing active: Calculating prorated fee block."); + } + Log::info("Amount to charge for signup: {$amountToCharge} dollars"); - if ( $customizations->custom_code_containers_enabled ) { - Log::info('deploying new container for custom user functions'); - $result = WebSvcHelper::post($svc, '/createContainer', $params); - if (!$result) { - return $this->errorInternal($request, 'Error occured when creating user containers'); - } + try { + RabbitMQHelper::dispatchImmediateBilling( + $workspace, + $subscription, + $user, + $plan, + $billingCycle, + $amountToCharge, + $nextBillingDateStr + ); + Log::info("Signup Billing Queued: Workspace {$workspace->id}, Amount: {$amountToCharge}"); + } catch (\Exception $e) { + Log::error("RabbitMQ Billing Dispatch Failed: " . $e->getMessage()); + } - Log::info('deployed container successfully.'); - } + Log::info('added user successfully.'); + $svc = "lineblocs-k8s-user"; + $params = array( + 'workspace' => $workspace->name, + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + ); - Log::info('updating DNS records.'); - $result = DNSHelper::refreshIPs(); + if ( $customizations->custom_code_containers_enabled ) { + Log::info('deploying new container for custom user functions'); + $result = WebSvcHelper::post($svc, '/createContainer', $params); if (!$result) { - return $this->errorInternal($request, 'DNS error occured'); + return $this->errorInternal($request, 'Error occured when creating user containers'); } + Log::info('deployed container successfully.'); + } - Log::info('updated DNS successfully.'); + Log::info('updating DNS records.'); + $result = DNSHelper::refreshIPs(); + if (!$result) { + return $this->errorInternal($request, 'DNS error occured'); + } + Log::info('updated DNS successfully.'); - //add register credit for user - $registerCredits = 0; - if (!empty($customizations->register_credits)) { - $registerCredits = $customizations->register_credits; - } + $registerCredits = 0; + if (!empty($customizations->register_credits)) { + $registerCredits = $customizations->register_credits; + } - $amountInCents = $registerCredits*100; - $credit = [ - 'cents' => $amountInCents, - 'card_id' => NULL, - 'user_id' => $user->id, - 'workspace_id' => $workspace->id, - 'status' => PaymentStatus::APPROVED, - 'deduplication_key' => 'credit:register:' . $workspace->id - ]; + $amountInCents = $registerCredits*100; + $credit = [ + 'cents' => $amountInCents, + 'card_id' => NULL, + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + 'status' => PaymentStatus::APPROVED, + 'deduplication_key' => 'credit:register:' . $workspace->id + ]; - UserCredit::create($credit, $plan); - $now = new \DateTime(); - $user->update([ + UserCredit::create($credit, $plan); + $now = new \DateTime(); + $user->update([ + 'last_login' => $now + ]); + $detect = new \Mobile_Detect(); + $userAgent = $detect->getUserAgent(); + + UserDevice::create([ + 'user_id' => $user->id, + 'user_agent' => $userAgent, + 'trusted' => TRUE, 'last_login' => $now - ]); - $detect = new \Mobile_Detect(); - $userAgent = $detect->getUserAgent(); - //first device - UserDevice::create([ - 'user_id' => $user->id, - 'user_agent' => $userAgent, - 'trusted' => TRUE, - 'last_login' => $now - ]); - UsageTrigger::create([ - 'user_id' => $user->id, - 'percentage' => 50 - ]); - - // TODO integrate custom email workflows - // admin should be able to select from one of many email providers - // integrate code for handling emails - $link = route('email-verify', ['hash' => $user->email_verify_hash]); - $data = [ - 'user' => $user, - 'link' => $link - ]; + ]); + UsageTrigger::create([ + 'user_id' => $user->id, + 'percentage' => 50 + ]); - Log::info('sending new user verification email'); - $subject =MainHelper::createEmailSubject("Verify Your Email"); - $result = EmailHelper::sendEmail($subject, $user->email, 'verify_email', $data); + $link = route('email-verify', ['hash' => $user->email_verify_hash]); + $data = [ + 'user' => $user, + 'link' => $link + ]; + Log::info('sending new user verification email'); + $subject = MainHelper::createEmailSubject("Verify Your Email"); + $result = EmailHelper::sendEmail($subject, $user->email, 'verify_email', $data); - $mailData = []; - $mailData['user'] = $user; - $subject = sprintf("Welcome to %s", MainHelper::getSiteName()); - $result = EmailHelper::sendEmail($subject, $user->email, 'welcome_email', $mailData); + $mailData = []; + $mailData['user'] = $user; + $subject = sprintf("Welcome to %s", MainHelper::getSiteName()); + $result = EmailHelper::sendEmail($subject, $user->email, 'welcome_email', $mailData); - return $this->response->array(['success' => TRUE, 'workspace' => $workspace->toArrayWithRoles($user)]); + return $this->response->array(['success' => TRUE, 'workspace' => $workspace->toArrayWithRoles($user)]); } - public function getSelf(Request $request) { $user = $this->getUser($request); @@ -407,19 +402,9 @@ public function updateSelf(Request $request) $user = $this->getUser($request); $user->update( $data ); if (isset($data['password'])) { - $mail = Config::get("mail"); $data = []; $subject = "Password reset successfully"; $result = EmailHelper::sendEmail($subject, $user->email, 'password_was_reset', $data); - /* - Mail::send('emails.password_was_reset', $data, function ($message) use ($user, $mail) { - $message->to($user->email); - $subject =MainHelper::createEmailSubject("Password reset successfully"); - $message->subject($subject); - $from = $mail['from']; - $message->from($from['address'], $from['name']); - }); - */ } return $this->response->noContent(); } @@ -468,20 +453,18 @@ public function setupWorkspace(Request $request) 'plan' => $plan, 'started_at' => new DateTime() ]); - $attrs = []; return $this->response->array(['success' => TRUE, 'workspace' => $workspace->toArray()]); } + public function forgot(Request $request) { $data = $request->all(); $email = $data['email']; - // Find user by email $user = User::where('email', $email)->first(); if (!$user) { return $this->response->errorBadRequest('User not found'); } - // Generate password reset token $token = bin2hex(random_bytes(32)); \DB::table('password_resets')->insert([ 'email' => $email, @@ -489,7 +472,6 @@ public function forgot(Request $request) { 'created_at' => new DateTime() ]); - // Send email using EmailHelper with runtime SMTP config $resetUrl = MainHelper::createAppUrl('#/reset?token=' . $token . '&email=' . urlencode($email)); $emailSent = EmailHelper::sendEmail( 'Password Reset Request', @@ -506,17 +488,16 @@ public function forgot(Request $request) { return $this->response->errorBadRequest('Failed to send reset email'); } } - public function reset(Request $request) { + + public function reset(Request $request) { $credentials = $request->only( 'email', 'password', 'password_confirmation', 'token' ); - // Validate password confirmation if ($credentials['password'] !== $credentials['password_confirmation']) { return $this->response->errorBadRequest('Passwords do not match'); } - // Find reset token record $resetRecord = \DB::table('password_resets') ->where('email', $credentials['email']) ->orderBy('created_at', 'desc') @@ -526,18 +507,15 @@ public function reset(Request $request) { return $this->response->errorBadRequest('Invalid or expired token'); } - // Verify token if (!\Hash::check($credentials['token'], $resetRecord->token)) { return $this->response->errorBadRequest('Invalid token'); } - // Check token expiration (tokens expire after 1 hour) $tokenAge = (new DateTime())->getTimestamp() - (new DateTime($resetRecord->created_at))->getTimestamp(); if ($tokenAge > 3600) { return $this->response->errorBadRequest('Token has expired'); } - // Find user and update password $user = User::where('email', $credentials['email'])->first(); if (!$user) { return $this->response->errorBadRequest('User not found'); @@ -546,31 +524,31 @@ public function reset(Request $request) { $user->password = bcrypt($credentials['password']); $user->save(); - // Delete used token \DB::table('password_resets') ->where('email', $credentials['email']) ->delete(); Log::info("Password reset successful for: " . $credentials['email']); return $this->response->noContent(); - } - public function provisionCallSystem(Request $request) { + } + + public function provisionCallSystem(Request $request) { $data = $request->all(); $user = User::findOrFail($data['userId']); $workspace = Workspace::where('creator_id', '=', $user->id)->first(); $template = CallSystemTemplate::findOrFail($data['templateId']); - $status =MainHelper::provisionCallSystem($user, $workspace, $template); + $status = MainHelper::provisionCallSystem($user, $workspace, $template); if (!$status) { return $this->response->errorInternal(); } - return $this->response->noContent(); - } + return $this->response->noContent(); + } - public function thirdPartyLogin(Request $request) - { + public function thirdPartyLogin(Request $request) + { $data = $request->all(); $user = User::where('email', $data['email'])->first(); - $challenge= $request->get('challenge'); + $challenge = $request->get('challenge'); if ($challenge) { if (!MainHelper::checkUserInWorkspace($challenge, $currentUser)) { return $this->errorInternal($request, 'workspace challenge failed.'); @@ -579,7 +557,6 @@ public function thirdPartyLogin(Request $request) if ($user) { $workspace = Workspace::where('creator_id', $user->id)->first(); - //MainHelper::checkUserInWorkspace($workspace->name, $user); if ($workspace) { $token = JWTAuth::fromUser($user); if (!$token) { @@ -595,7 +572,6 @@ public function thirdPartyLogin(Request $request) 'confirmed' => FALSE, 'info' => $info, 'userId' => $user->id - ]); } return $this->response->array([ @@ -617,289 +593,277 @@ public function thirdPartyLogin(Request $request) 'confirmed' => FALSE, 'info' => $info, 'userId' => $user->id - ]); + } - } - //create a new user - - $user = MainHelper::createUser([ - 'email' => $data['email'], - 'first_name' => $data['first_name'], - 'last_name' => $data['last_name'] - ]); - $token = JWTAuth::fromUser($user); - if (!$token) { - return $token; - } - - - $info = [ - 'workspace' => [], - 'token' => MainHelper::createJWTPayload($token) - ]; - return $this->response->array([ - 'confirmed' => FALSE, - 'info' => $info, - 'userId' => $user->id + $user = MainHelper::createUser([ + 'email' => $data['email'], + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'] + ]); + $token = JWTAuth::fromUser($user); + if (!$token) { + return $token; + } - ]); + $info = [ + 'workspace' => [], + 'token' => MainHelper::createJWTPayload($token) + ]; + return $this->response->array([ + 'confirmed' => FALSE, + 'info' => $info, + 'userId' => $user->id + ]); + } - } - public function getUserInfo(Request $request) - { + public function getUserInfo(Request $request) + { $email = $request->get('email'); $user = User::where('email', $email)->first(); - if ( $user ) { + if ( $user ) { + return $this->response->array([ + 'found' => TRUE, + 'info' => [ + 'name' => $user->getName() + ] + ]); + } return $this->response->array([ - 'found' => TRUE, - 'info' => [ - 'name' => $user->getName() - ] - ]); - } - return $this->response->array([ - 'found' => FALSE - ]); - - } - - private function normalizeOneTimeLoginPayload(Request $request) - { - $payload = $request->all(); - if (empty($payload)) { - $payload = $request->json()->all(); - } + 'found' => FALSE + ]); + } - return is_array($payload) ? $payload : []; - } + private function normalizeOneTimeLoginPayload(Request $request) + { + $payload = $request->all(); + if (empty($payload)) { + $payload = $request->json()->all(); + } + return is_array($payload) ? $payload : []; + } - private function userBelongsToWorkspace($user, $workspace) - { - if (!$user || !$workspace) { - return false; - } + private function userBelongsToWorkspace($user, $workspace) + { + if (!$user || !$workspace) { + return false; + } + if ((int) $workspace->creator_id === (int) $user->id) { + return true; + } + $workspaceUserCount = WorkspaceUser::where('workspace_id', $workspace->id) + ->where('user_id', $user->id) + ->count(); + return $workspaceUserCount > 0; + } - if ((int) $workspace->creator_id === (int) $user->id) { - return true; - } + public function sendOneTimeLoginLink(Request $request) + { + $data = $this->normalizeOneTimeLoginPayload($request); + $userId = array_key_exists('userId', $data) ? (int) $data['userId'] : (array_key_exists('user_id', $data) ? (int) $data['user_id'] : 0); + $workspaceId = array_key_exists('workspaceId', $data) ? (int) $data['workspaceId'] : (array_key_exists('workspace_id', $data) ? (int) $data['workspace_id'] : 0); - $workspaceUserCount = WorkspaceUser::where('workspace_id', $workspace->id) - ->where('user_id', $user->id) - ->count(); + if ($userId <= 0 || $workspaceId <= 0) { + return $this->response->array([ + 'success' => false, + 'message' => 'userId and workspaceId are required.' + ]); + } - return $workspaceUserCount > 0; - } + $user = User::find($userId); + $workspace = Workspace::find($workspaceId); + if (!$user || !$workspace) { + return $this->response->array([ + 'success' => false, + 'message' => 'User or workspace not found.' + ]); + } - public function sendOneTimeLoginLink(Request $request) - { - $data = $this->normalizeOneTimeLoginPayload($request); - $userId = array_key_exists('userId', $data) ? (int) $data['userId'] : (array_key_exists('user_id', $data) ? (int) $data['user_id'] : 0); - $workspaceId = array_key_exists('workspaceId', $data) ? (int) $data['workspaceId'] : (array_key_exists('workspace_id', $data) ? (int) $data['workspace_id'] : 0); + if (!$this->userBelongsToWorkspace($user, $workspace)) { + return $this->response->array([ + 'success' => false, + 'message' => 'User does not belong to this workspace.' + ]); + } - if ($userId <= 0 || $workspaceId <= 0) { - return $this->response->array([ - 'success' => false, - 'message' => 'userId and workspaceId are required.' - ]); - } + $ttlMinutes = array_key_exists('expiry_minutes', $data) ? (int) $data['expiry_minutes'] : (int) env('ONE_TIME_LOGIN_LINK_TTL', 60); + if ($ttlMinutes <= 0) { + $ttlMinutes = 60; + } - $user = User::find($userId); - $workspace = Workspace::find($workspaceId); - if (!$user || !$workspace) { - return $this->response->array([ - 'success' => false, - 'message' => 'User or workspace not found.' - ]); - } + $token = TokenHelper::createToken('one_time_login', [ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + 'email' => (string) $user->email + ], $ttlMinutes); - if (!$this->userBelongsToWorkspace($user, $workspace)) { - return $this->response->array([ - 'success' => false, - 'message' => 'User does not belong to this workspace.' - ]); - } + if (empty($token)) { + return $this->response->array([ + 'success' => false, + 'message' => 'Could not generate one-time token.' + ]); + } - $ttlMinutes = array_key_exists('expiry_minutes', $data) ? (int) $data['expiry_minutes'] : (int) env('ONE_TIME_LOGIN_LINK_TTL', 60); - if ($ttlMinutes <= 0) { - $ttlMinutes = 60; - } + $tokenHash = hash('sha256', $token); + $now = new DateTime(); + $expiresAt = (clone $now)->modify(sprintf('+%d minutes', $ttlMinutes)); - $token = TokenHelper::createToken('one_time_login', [ - 'user_id' => $user->id, - 'workspace_id' => $workspace->id, - 'email' => (string) $user->email - ], $ttlMinutes); + OneTimeLoginLink::where('user_id', $user->id) + ->where('workspace_id', $workspace->id) + ->whereNull('used_at') + ->update([ + 'used_at' => $now->format('Y-m-d H:i:s') + ]); - if (empty($token)) { - return $this->response->array([ - 'success' => false, - 'message' => 'Could not generate one-time token.' - ]); - } + OneTimeLoginLink::create([ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + 'token_hash' => $tokenHash, + 'expires_at' => $expiresAt->format('Y-m-d H:i:s') + ]); - $tokenHash = hash('sha256', $token); - $now = new DateTime(); - $expiresAt = (clone $now)->modify(sprintf('+%d minutes', $ttlMinutes)); + $linkExpiresAt = time() + ($ttlMinutes * 60); + $linkParams = [ + 'token' => $token, + 'userId' => $user->id, + 'workspaceId' => $workspace->id, + 'expires' => $linkExpiresAt + ]; + $linkParams['sig'] = TokenHelper::signLinkParams($linkParams); - OneTimeLoginLink::where('user_id', $user->id) - ->where('workspace_id', $workspace->id) - ->whereNull('used_at') - ->update([ - 'used_at' => $now->format('Y-m-d H:i:s') - ]); + $loginLink = MainHelper::createUrl('one-time-login') . '?' . http_build_query($linkParams); - OneTimeLoginLink::create([ - 'user_id' => $user->id, - 'workspace_id' => $workspace->id, - 'token_hash' => $tokenHash, - 'expires_at' => $expiresAt->format('Y-m-d H:i:s') - ]); + $subject = MainHelper::createEmailSubject('Your One-Time Login Link'); + $result = EmailHelper::sendEmail($subject, $user->email, 'one_time_login_link', [ + 'user' => $user, + 'workspace' => $workspace, + 'login_link' => $loginLink, + 'expires_minutes' => $ttlMinutes + ]); - $linkExpiresAt = time() + ($ttlMinutes * 60); - $linkParams = [ - 'token' => $token, - 'userId' => $user->id, - 'workspaceId' => $workspace->id, - 'expires' => $linkExpiresAt - ]; - $linkParams['sig'] = TokenHelper::signLinkParams($linkParams); + return $this->response->array([ + 'success' => $result === TRUE, + 'email' => $user->email, + 'workspace_id' => $workspace->id, + 'expires_minutes' => $ttlMinutes + ]); + } - $loginLink = MainHelper::createUrl('one-time-login') . '?' . http_build_query($linkParams); + public function consumeOneTimeLoginLink(Request $request) + { + $token = $request->query('token'); + $linkUserId = (int) $request->query('userId', 0); + $linkWorkspaceId = (int) $request->query('workspaceId', 0); + $linkExpires = (int) $request->query('expires', 0); + $linkSignature = (string) $request->query('sig', ''); + + if (empty($token)) { + return response('Invalid or expired one-time login link.', 403); + } - $subject = MainHelper::createEmailSubject('Your One-Time Login Link'); - $result = EmailHelper::sendEmail($subject, $user->email, 'one_time_login_link', [ - 'user' => $user, - 'workspace' => $workspace, - 'login_link' => $loginLink, - 'expires_minutes' => $ttlMinutes - ]); + if ($linkExpires <= 0 || $linkExpires < time()) { + return response('This one-time login link has expired.', 403); + } - return $this->response->array([ - 'success' => $result === TRUE, - 'email' => $user->email, - 'workspace_id' => $workspace->id, - 'expires_minutes' => $ttlMinutes - ]); - } - - public function consumeOneTimeLoginLink(Request $request) - { - $token = $request->query('token'); - $linkUserId = (int) $request->query('userId', 0); - $linkWorkspaceId = (int) $request->query('workspaceId', 0); - $linkExpires = (int) $request->query('expires', 0); - $linkSignature = (string) $request->query('sig', ''); - - if (empty($token)) { - return response('Invalid or expired one-time login link.', 403); - } + $isValidLinkSignature = TokenHelper::validateLinkSignature([ + 'token' => $token, + 'userId' => $linkUserId, + 'workspaceId' => $linkWorkspaceId, + 'expires' => $linkExpires + ], $linkSignature); - if ($linkExpires <= 0 || $linkExpires < time()) { - return response('This one-time login link has expired.', 403); - } + if (!$isValidLinkSignature) { + return response('Invalid one-time login link signature.', 403); + } - $isValidLinkSignature = TokenHelper::validateLinkSignature([ - 'token' => $token, - 'userId' => $linkUserId, - 'workspaceId' => $linkWorkspaceId, - 'expires' => $linkExpires - ], $linkSignature); + $payload = TokenHelper::getPayload($token); + if (empty($payload)) { + return response('Invalid or expired one-time login link.', 403); + } - if (!$isValidLinkSignature) { - return response('Invalid one-time login link signature.', 403); - } + $userId = array_key_exists('user_id', $payload) ? (int) $payload['user_id'] : 0; + $workspaceId = array_key_exists('workspace_id', $payload) ? (int) $payload['workspace_id'] : 0; + $email = array_key_exists('email', $payload) ? (string) $payload['email'] : ''; + if ($userId <= 0 || $workspaceId <= 0 || empty($email)) { + return response('Invalid one-time login link.', 403); + } - $payload = TokenHelper::getPayload($token); - if (empty($payload)) { - return response('Invalid or expired one-time login link.', 403); - } + if ($linkUserId !== $userId || $linkWorkspaceId !== $workspaceId) { + return response('Invalid one-time login link.', 403); + } - $userId = array_key_exists('user_id', $payload) ? (int) $payload['user_id'] : 0; - $workspaceId = array_key_exists('workspace_id', $payload) ? (int) $payload['workspace_id'] : 0; - $email = array_key_exists('email', $payload) ? (string) $payload['email'] : ''; - if ($userId <= 0 || $workspaceId <= 0 || empty($email)) { - return response('Invalid one-time login link.', 403); - } + $isValidToken = TokenHelper::validateToken($token, 'one_time_login', [ + 'user_id' => $userId, + 'workspace_id' => $workspaceId, + 'email' => $email + ]); - if ($linkUserId !== $userId || $linkWorkspaceId !== $workspaceId) { - return response('Invalid one-time login link.', 403); - } + if (!$isValidToken) { + return response('Invalid or expired one-time login link.', 403); + } - $isValidToken = TokenHelper::validateToken($token, 'one_time_login', [ - 'user_id' => $userId, - 'workspace_id' => $workspaceId, - 'email' => $email - ]); + $tokenHash = hash('sha256', $token); + $record = OneTimeLoginLink::where('token_hash', $tokenHash)->first(); + if (!$record) { + return response('Login link not found.', 403); + } - if (!$isValidToken) { - return response('Invalid or expired one-time login link.', 403); - } + if (!empty($record->used_at)) { + return response('This one-time login link has already been used.', 410); + } - $tokenHash = hash('sha256', $token); - $record = OneTimeLoginLink::where('token_hash', $tokenHash)->first(); - if (!$record) { - return response('Login link not found.', 403); - } + if (strtotime($record->expires_at) <= time()) { + return response('This one-time login link has expired.', 403); + } - if (!empty($record->used_at)) { - return response('This one-time login link has already been used.', 410); - } + $user = User::find($record->user_id); + $workspace = Workspace::find($record->workspace_id); + if (!$this->userBelongsToWorkspace($user, $workspace)) { + return response('Invalid one-time login link.', 403); + } - if (strtotime($record->expires_at) <= time()) { - return response('This one-time login link has expired.', 403); - } + $updated = \DB::table('one_time_login_links') + ->where('id', $record->id) + ->whereNull('used_at') + ->update([ + 'used_at' => date('Y-m-d H:i:s') + ]); - $user = User::find($record->user_id); - $workspace = Workspace::find($record->workspace_id); - if (!$this->userBelongsToWorkspace($user, $workspace)) { - return response('Invalid one-time login link.', 403); - } + if ((int) $updated !== 1) { + return response('This one-time login link has already been used.', 410); + } - $updated = \DB::table('one_time_login_links') - ->where('id', $record->id) - ->whereNull('used_at') - ->update([ - 'used_at' => date('Y-m-d H:i:s') - ]); + $loginToken = JWTAuth::fromUser($user); + if (!$loginToken) { + return response('Could not create login token.', 500); + } - if ((int) $updated !== 1) { - return response('This one-time login link has already been used.', 410); - } + $redirectUrl = MainHelper::createPortalLink(sprintf('?auth=%s&workspaceId=%d', $loginToken, $workspace->id)); + return redirect($redirectUrl); + } - $loginToken = JWTAuth::fromUser($user); - if (!$loginToken) { - return response('Could not create login token.', 500); - } + public function isTestNumber($number) { + $tag = "\\+\\dTEST\\-0uu5hIw0CL"; + if (preg_match("/^" . $tag . "/", $number, $matches)) { + return TRUE; + } + return FALSE; + } - $redirectUrl = MainHelper::createPortalLink(sprintf('?auth=%s&workspaceId=%d', $loginToken, $workspace->id)); - return redirect($redirectUrl); - } + public function addCard(Request $request) + { + $user = User::findOrFail($request->get("user_id")); + $workspace = Workspace::findOrFail($request->get("workspace_id")); + $data = $request->json()->all(); + MainHelper::addCard($data, $user, $workspace); + return $this->response->noContent(); + } - public function isTestNumber($number) { - $tag = "\\+\\dTEST\\-0uu5hIw0CL"; - if (preg_match("/^" . $tag . "/", $number, $matches)) { - return TRUE; - } - return FALSE; - } - - public function addCard(Request $request) - { - $user = User::findOrFail($request->get("user_id")); - $workspace = Workspace::findOrFail($request->get("workspace_id")); - $data = $request->json()->all(); - MainHelper::addCard($data, $user, $workspace); - return $this->response->noContent(); - } - - public function emailTest(){ - $email = 'tgblinkss@gmail.com'; - $data = 'This is a test'; - $subject =MainHelper::createEmailSubject("Verify Your Email"); - $result = EmailHelper::sendEmail($subject, $email, 'verify_email', $data); - return json_encode($result); - // return $this->response->array(['success' => TRUE, 'workspace' => $workspace->toArrayWithRoles($user)]); - } - -} + public function emailTest(){ + $email = 'tgblinkss@gmail.com'; + $data = 'This is a test'; + $subject = MainHelper::createEmailSubject("Verify Your Email"); + $result = EmailHelper::sendEmail($subject, $email, 'verify_email', $data); + return json_encode($result); + } +} \ No newline at end of file diff --git a/app/create_billing_task.php b/app/create_billing_task.php new file mode 100755 index 000000000..8dee7e681 --- /dev/null +++ b/app/create_billing_task.php @@ -0,0 +1,29 @@ +id)->first(); +$user = User::find($workspace->creator_id); +$billingCycle = $subscription->billing_cycle; +$plan = ServicePlan::find($subscription->current_plan_id); +$nextBillingDate = (new DateTime('first day of next month'))->format('Y-m-d'); + +$amountToCharge = 1; // 1 dollar + +printf('Amount to charge: %s, Plan key_name: %s', $amountToCharge, $plan->key_name); + +RabbitMQHelper::dispatchImmediateBilling( + $workspace, + $subscription, + $user, + $plan, + $billingCycle, + $amountToCharge, + $nextBillingDate + ); + diff --git a/app/database/migrations/2026_06_24_160500_add_free_trial_fields.php b/app/database/migrations/2026_06_24_160500_add_free_trial_fields.php new file mode 100644 index 000000000..fa17f8248 --- /dev/null +++ b/app/database/migrations/2026_06_24_160500_add_free_trial_fields.php @@ -0,0 +1,33 @@ +boolean('is_trial_enabled')->default(false); + $table->integer('trial_duration_days')->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('customizations', function (Blueprint $table) { + $table->dropColumn('is_trial_enabled'); + $table->dropColumn('trial_duration_days'); + }); + } +} diff --git a/app/database/migrations/2026_06_24_160715_add_free_trial_opts_to_service_plans.php b/app/database/migrations/2026_06_24_160715_add_free_trial_opts_to_service_plans.php new file mode 100644 index 000000000..9f2532da8 --- /dev/null +++ b/app/database/migrations/2026_06_24_160715_add_free_trial_opts_to_service_plans.php @@ -0,0 +1,33 @@ +booolean('free_trial_exempt')->default(false); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('service_plans', function (Blueprint $table) { + // + $table->dropColumn('free_trial_exempt'); + }); + } +} diff --git a/app/resources/lang/en/admin/serviceplans.php b/app/resources/lang/en/admin/serviceplans.php index 6c706d4b8..ec4e72965 100755 --- a/app/resources/lang/en/admin/serviceplans.php +++ b/app/resources/lang/en/admin/serviceplans.php @@ -32,4 +32,5 @@ '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', 'status' => 'Status', + 'free_trial_exempt' => 'Free Trial Exempt' ]; \ No newline at end of file diff --git a/app/resources/views/admin/customizations/view.blade.php b/app/resources/views/admin/customizations/view.blade.php index 019b61e33..2357f1221 100755 --- a/app/resources/views/admin/customizations/view.blade.php +++ b/app/resources/views/admin/customizations/view.blade.php @@ -452,6 +452,35 @@ +
+

Free Trial Mode

+
+
+ +
+ +
+ @if ( $record->is_trial_enabled ) + + @else + + @endif + +
+
+ +
+ +
+ +
+
+