From 5be0cb7bb76c4c1e19fed398d5681e5f4a2abc9b Mon Sep 17 00:00:00 2001 From: Nadir Hamid Date: Mon, 13 Jul 2026 22:29:27 +0000 Subject: [PATCH 1/9] set cancel field on subscription when user cancels plans --- app/app/Enums/SubscriptionStatus.php | 24 ++++++++++++++ app/app/Http/Controllers/MergedController.php | 13 +++++--- .../Http/Controllers/RegisterController.php | 4 ++- ..._07_13_222258_add_cancel_at_period_end.php | 33 +++++++++++++++++++ 4 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 app/app/Enums/SubscriptionStatus.php create mode 100644 app/database/migrations/2026_07_13_222258_add_cancel_at_period_end.php diff --git a/app/app/Enums/SubscriptionStatus.php b/app/app/Enums/SubscriptionStatus.php new file mode 100644 index 000000000..5ed0db347 --- /dev/null +++ b/app/app/Enums/SubscriptionStatus.php @@ -0,0 +1,24 @@ +getWorkspace($request); - $servicePlan = ServicePlan::getPayAsYouGoplan(); - $workspace->update([ - 'plan' => $servicePlan->key_name + $subscription = Subscription::where('workspace_id', $workspace->id)->first(); + + if (!$subscription) { + return $this->response->errorNotFound('Subscription not found'); + } + + $subscription->update([ + 'cancel_at_period_end' => true ]); $props = array( "billing_status" => "pending_processing" diff --git a/app/app/Http/Controllers/RegisterController.php b/app/app/Http/Controllers/RegisterController.php index a9d4c9f8c..4f445cb4e 100755 --- a/app/app/Http/Controllers/RegisterController.php +++ b/app/app/Http/Controllers/RegisterController.php @@ -15,6 +15,7 @@ use App\Helpers\WebSvcHelper; use App\Helpers\EmailHelper; use App\Helpers\RabbitMQHelper; +use App\Helpers\InvoiceHelper; use App\Helpers\TokenHelper; use App\Helpers\BillingDataHelper; use \Config; @@ -43,6 +44,7 @@ use App\Subscription; use App\OneTimeLoginLink; use App\Enums\PaymentStatus; +use App\Enums\SubscriptionStatus; use PhpAmqpLib\Connection\AMQPStreamConnection; use PhpAmqpLib\Message\AMQPMessage; use DateTime; @@ -301,7 +303,7 @@ public function userSpinup(Request $request) $subscriptionParams = [ 'workspace_id' => $workspace->id, 'current_plan_id' => $plan->id, - 'status' => 'ACTIVE', + 'status' => SubscriptionStatus::ACTIVE, 'billing_cycle' => $billingCycle, 'current_period_end' => $periodEnd, 'next_billing_date' => $nextBillingDateStr, diff --git a/app/database/migrations/2026_07_13_222258_add_cancel_at_period_end.php b/app/database/migrations/2026_07_13_222258_add_cancel_at_period_end.php new file mode 100644 index 000000000..c00fb4841 --- /dev/null +++ b/app/database/migrations/2026_07_13_222258_add_cancel_at_period_end.php @@ -0,0 +1,33 @@ +boolean('cancel_at_period_end')->default(false); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('subscriptions', function (Blueprint $table) { + // + $table->dropColumn('cancel_at_period_end'); + }); + } +} From cfbf10f674c3404f3e0b9b4a514dc14c34a1772b Mon Sep 17 00:00:00 2001 From: Nadir Hamid Date: Mon, 13 Jul 2026 23:45:47 +0000 Subject: [PATCH 2/9] include subscription data in dashboard API --- app/app/Http/Controllers/MergedController.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/app/Http/Controllers/MergedController.php b/app/app/Http/Controllers/MergedController.php index 66dd9066f..dc6c768a0 100755 --- a/app/app/Http/Controllers/MergedController.php +++ b/app/app/Http/Controllers/MergedController.php @@ -398,7 +398,8 @@ public function dashboard(Request $request) $checklist, $plan, $workspace->toArrayWithRoles($user), - $metrics + $metrics, + $subscription->toArray() ]); } From 9a322468e0ca4fdfc470ba36d44462dbf504a19e Mon Sep 17 00:00:00 2001 From: Nadir Hamid Date: Tue, 14 Jul 2026 00:34:14 +0000 Subject: [PATCH 3/9] add billingReactivate API to reinstate workspace subscriptions --- app/app/Http/Controllers/MergedController.php | 101 ++++++++++++++++++ app/app/Http/routes.php | 2 + app/app/Subscription.php | 1 + 3 files changed, 104 insertions(+) diff --git a/app/app/Http/Controllers/MergedController.php b/app/app/Http/Controllers/MergedController.php index dc6c768a0..e4fa3f716 100755 --- a/app/app/Http/Controllers/MergedController.php +++ b/app/app/Http/Controllers/MergedController.php @@ -87,6 +87,7 @@ use App\Transformers\RecordingTransformer; use App\Transformers\CallTransformer; use App\Enums\WorkspaceUserStatus; +use App\Enums\SubscriptionStatus; use App\UserCredit; use DateTime; @@ -1110,6 +1111,106 @@ public function billingDiscontinue(Request $request) { WorkspaceEvent::addEvent($workspace, 'PLAN_CANCELLED', $props); } + public function billingReactivate(Request $request) { + // reactivate a cancelled subscription + $workspace = $this->getWorkspace($request); + $subscription = Subscription::where('workspace_id', $workspace->id)->first(); + + if (!$subscription) { + return $this->response->errorNotFound('Subscription not found'); + } + + // Check if subscription is already active and not cancelled + if ($subscription->status === SubscriptionStatus::ACTIVE && !$subscription->cancel_at_period_end) { + return $this->response->array(['message' => 'The subscription cannot be reactivated as it\'s active already.'], 200); + } + + // If subscription is active but marked for cancellation, just update the flags + if ($subscription->status === SubscriptionStatus::ACTIVE && $subscription->cancel_at_period_end) { + $subscription->update([ + 'cancel_at_period_end' => false + ]); + return $this->response->array(['message' => 'Subscription reactivated successfully.'], 200); + } + + $plan = ServicePlan::findOrFail($subscription->current_plan_id); + $customizations = CustomizationsKVStore::getRecord(); + $user = $this->getUser($request); + + $now = new DateTime(); + $anchorDay = (int)$now->format('j'); + $billingCycle = $subscription->billing_cycle; + + // Calculate period end based on billing cycle + if ($billingCycle === 'ANNUAL') { + $periodEnd = (clone $now)->modify('+1 year')->setTime(0, 0, 0); + $recurringCost = $plan->annual_cost_cents; + } else { + // MONTHLY - handle month edge cases + $nextMonth = (clone $now)->modify('+1 month'); + $daysInNextMonth = (int)$nextMonth->format('t'); + + if ($anchorDay > $daysInNextMonth) { + $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; + } + + $nextBillingDateStr = $periodEnd->format('Y-m-d'); + + // Update subscription + $subscription->update([ + 'status' => SubscriptionStatus::ACTIVE, + 'cancel_at_period_end' => false, + 'current_period_end' => $periodEnd, + 'next_billing_date' => $nextBillingDateStr, + 'billing_anchor_day' => $anchorDay, + 'billing_start_date' => $now + ]); + + // Calculate prorated or full amount + $recurringCostInDollars = $recurringCost / 100; + $billingFlow = 'ANNUAL'; // Default, adjust if needed from customizations + + if ($billingFlow === 'ANNIVERSARY') { + $amountToCharge = $recurringCostInDollars; + Log::info("Billing Reactivate Anniversary: Charging 100% full plan fee."); + } else { + $amountToCharge = BillingDataHelper::calculateProratedAmount($recurringCostInDollars, $billingCycle); + Log::info("Billing Reactivate Calendar: Calculating prorated fee block."); + } + + Log::info("Amount to charge for reactivation: {$amountToCharge} dollars"); + + // Dispatch billing event + try { + RabbitMQHelper::dispatchImmediateBilling( + $workspace, + $subscription, + $user, + $plan, + $billingCycle, + $amountToCharge, + $nextBillingDateStr + ); + Log::info("Reactivation Billing Queued: Workspace {$workspace->id}, Amount: {$amountToCharge}"); + } catch (\Exception $e) { + Log::error("RabbitMQ Billing Dispatch Failed: " . $e->getMessage()); + return $this->response->errorInternal(); + } + + $props = array( + "billing_status" => "pending_processing" + ); + WorkspaceEvent::addEvent($workspace, 'PLAN_REACTIVATED', $props); + + return $this->response->noContent(); + } + + + public function save2FASettings(Request $request) { $user = $this->getUser($request); $type2fa = NULL; diff --git a/app/app/Http/routes.php b/app/app/Http/routes.php index c24ec2a54..ac81307cb 100755 --- a/app/app/Http/routes.php +++ b/app/app/Http/routes.php @@ -563,6 +563,8 @@ $api->get('search', '\App\Http\Controllers\MergedController@search'); $api->post('billing/discontinue', '\App\Http\Controllers\MergedController@billingDiscontinue'); $api->post('billingDiscontinue', '\App\Http\Controllers\MergedController@billingDiscontinue'); + $api->post('billingReactivate', '\App\Http\Controllers\MergedController@billingReactivate'); + $api->post('save2FASettings', '\App\Http\Controllers\MergedController@save2FASettings'); $api->get('get2FAConfig', '\App\Http\Controllers\MergedController@get2FAConfig'); $api->get('request2FACode', '\App\Http\Controllers\MergedController@request2FACode'); diff --git a/app/app/Subscription.php b/app/app/Subscription.php index 76d615226..45081006b 100755 --- a/app/app/Subscription.php +++ b/app/app/Subscription.php @@ -16,6 +16,7 @@ class Subscription extends Model { protected $table = "subscriptions"; protected $casts = array( 'is_free_trial_active' => 'boolean', + 'cancel_at_period_end' => 'boolean', ); public function toArray() From 33d29b3fe67d86616cd642e5d1e2e3984ee9f9de Mon Sep 17 00:00:00 2001 From: Nadir Hamid Date: Thu, 16 Jul 2026 21:56:48 +0000 Subject: [PATCH 4/9] make SIP provider type values uppercase --- app/app/Http/Controllers/Admin/SIPProviderController.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/app/Http/Controllers/Admin/SIPProviderController.php b/app/app/Http/Controllers/Admin/SIPProviderController.php index b2349c741..0ba31f4ad 100755 --- a/app/app/Http/Controllers/Admin/SIPProviderController.php +++ b/app/app/Http/Controllers/Admin/SIPProviderController.php @@ -295,9 +295,9 @@ public function data() } public function providerTypes() { return [ - 'inbound' => 'inbound', - 'outbound' => 'outbound', - 'both' => 'both' + 'INBOUND' => 'INBOUND', + 'OUTBOUND' => 'OUTBOUND', + 'BOTH' => 'BOTH' ]; } } From 776aeaafe49c035ca884a9fd96f9808d7c0daca3 Mon Sep 17 00:00:00 2001 From: Nadir Hamid Date: Fri, 17 Jul 2026 21:03:48 +0000 Subject: [PATCH 5/9] filter by due date in invoicing APIs --- app/app/Helpers/BillingDataHelper.php | 1 + app/app/Http/Controllers/BillingController.php | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/app/Helpers/BillingDataHelper.php b/app/app/Helpers/BillingDataHelper.php index 7567a6561..ec5c3ad48 100755 --- a/app/app/Helpers/BillingDataHelper.php +++ b/app/app/Helpers/BillingDataHelper.php @@ -154,6 +154,7 @@ public static function getBillingInfo($user, $plan=NULL, $subscription=NULL, $wo $remainingBalance -= $debit->cents; } foreach ($invoices as $invoice) { + if ($invoice->status != PaymentStatus::PAID) { $accountBalance += $invoice->cents; } diff --git a/app/app/Http/Controllers/BillingController.php b/app/app/Http/Controllers/BillingController.php index daac42dc5..30a6e572e 100755 --- a/app/app/Http/Controllers/BillingController.php +++ b/app/app/Http/Controllers/BillingController.php @@ -301,6 +301,7 @@ public function getInvoices(Request $request) $invoices = $query->get()->map(function($item) { $item['amount_in_dollars'] = MainHelper::toDollars($item['cents']); $item['friendly_created_at'] = \Carbon\Carbon::parse($item['created_at'])->format('M d, Y'); + $item['friendly_due_date'] = \Carbon\Carbon::parse($item['due_date'])->format('M d, Y'); return $item; }); @@ -317,11 +318,14 @@ public function getOverdueInvoices(Request $request) $status = $request->query('status'); $query = UserInvoice::where('workspace_id', $workspace->id) - ->whereIn('status', [PaymentStatus::FAILED, PaymentStatus::PENDING]); + ->whereNotIn('status', [PaymentStatus::PAID, PaymentStatus::CANCELLED]) + ->orderBy('due_date', 'desc'); $invoices = $query->get()->map(function($item) { $item['amount_in_dollars'] = MainHelper::toDollars($item['cents']); $item['friendly_created_at'] = \Carbon\Carbon::parse($item['created_at'])->format('M d, Y'); + $item['friendly_due_date'] = \Carbon\Carbon::parse($item['due_date'])->format('M d, Y'); + return $item; }); From 12b9ccf4c127487d2e07f63ef00253f0ad13f13a Mon Sep 17 00:00:00 2001 From: Nadir Hamid Date: Thu, 23 Jul 2026 22:42:42 +0000 Subject: [PATCH 6/9] APIs to manage auto top up settings --- app/app/Helpers/RabbitMQHelper.php | 4 +- .../Admin/ServicePlanController.php | 1 - .../Api/Credit/CreditController.php | 107 ++++++++++-------- .../Api/DIDNumber/DIDNumberController.php | 2 +- app/app/Http/Controllers/MergedController.php | 36 ++++++ .../Http/Controllers/RegisterController.php | 26 +++-- app/app/Http/routes.php | 1 + app/app/ServicePlan.php | 1 + app/app/Subscription.php | 2 + app/app/User.php | 12 +- app/app/Workspace.php | 14 +++ ...llow_multiple_workspace_users_to_plans.php | 35 ++++++ ...026_07_23_212341_add_auto_topup_fields.php | 36 ++++++ app/resources/lang/en/admin/serviceplans.php | 6 +- .../admin/serviceplan/create_edit.blade.php | 105 ++++++++++++++--- 15 files changed, 304 insertions(+), 84 deletions(-) create mode 100644 app/database/migrations/2026_07_23_192424_add_allow_multiple_workspace_users_to_plans.php create mode 100644 app/database/migrations/2026_07_23_212341_add_auto_topup_fields.php diff --git a/app/app/Helpers/RabbitMQHelper.php b/app/app/Helpers/RabbitMQHelper.php index ccc3163b6..6da652c5c 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, $nextBillingDate) + public static function dispatchImmediateBilling($workspace, $subscription, $user, $servicePlan, $billingCycle, $amount, $nextBillingDate, $action='IMMEDIATE') { $payload = [ 'run_id' => 'signup_' . $user->id . '_' . time(), @@ -134,7 +134,7 @@ public static function dispatchImmediateBilling($workspace, $subscription, $user 'workspace_id' => (int) $workspace->id, 'subscription_id' => (int) $subscription->id, 'creator_id' => (int) $user->id, - 'action' => 'IMMEDIATE', + 'action' => $action, 'amount' => $amount, 'plan_to_bill' => (int) $servicePlan->id, 'next_billing_date' => $nextBillingDate diff --git a/app/app/Http/Controllers/Admin/ServicePlanController.php b/app/app/Http/Controllers/Admin/ServicePlanController.php index b7fe91af6..13701d185 100755 --- a/app/app/Http/Controllers/Admin/ServicePlanController.php +++ b/app/app/Http/Controllers/Admin/ServicePlanController.php @@ -201,7 +201,6 @@ private function getFeatureOptions() { $this->createFeatureOption('multiple_sip_domains'), $this->createFeatureOption('bring_carrier'), $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/Api/Credit/CreditController.php b/app/app/Http/Controllers/Api/Credit/CreditController.php index a973d7de6..cf77abd1d 100755 --- a/app/app/Http/Controllers/Api/Credit/CreditController.php +++ b/app/app/Http/Controllers/Api/Credit/CreditController.php @@ -17,6 +17,8 @@ use \App\Helpers\SIPRouterHelper; use \App\Helpers\StripeBillingHelper; use \App\Enums\PaymentStatus; +use \App\Subscription; +use \App\ServicePlan; use PayPal\Api\Amount; use PayPal\Api\Details; use PayPal\Api\Item; @@ -28,6 +30,7 @@ use PayPal\Api\PaymentExecution; use App\Helpers\EmailHelper; +use App\Helpers\RabbitMQHelper; use \Config; use \Exception; @@ -40,61 +43,70 @@ class CreditController extends HasStripeController { public function addCredit(Request $request) { $data = $request->all(); + + // Validate required fields + if (!isset($data['amount']) || !isset($data['card_id'])) { + return $this->response->errorBadRequest('Missing required fields: amount and card_id'); + } + + // Validate amount is positive + if (!is_numeric($data['amount']) || $data['amount'] <= 0) { + return $this->response->errorBadRequest('Amount must be a positive number'); + } + $amountInCents = MainHelper::toCents($data['amount']); $user = $this->getUser($request); $workspace = $this->getWorkspace($request); + if (!WorkspaceHelper::canPerformAction($user, $workspace, 'manage_billing')) { return $this->response->errorForbidden(); } + + // Verify card exists and belongs to workspace $card = UserCard::where('id', $data['card_id']) ->where('workspace_id', $workspace->id) - ->firstOrFail(); - $deduplicationKey = null; - if (!empty($data['deduplication_key'])) { - $deduplicationKey = $data['deduplication_key']; - } - if (!empty($deduplicationKey)) { - $existingCredit = UserCredit::where('workspace_id', $workspace->id) - ->where('deduplication_key', $deduplicationKey) - ->first(); - if ($existingCredit) { - return $this->response->noContent(); - } + ->first(); + + if (!$card) { + return $this->response->errorNotFound('Card not found'); } + try { - MainHelper::chargeCard($user, $card, $amountInCents); + $amountInDollars = $amountInCents / 100; + $subscription = Subscription::where('workspace_id', $workspace->id)->first(); + $servicePlan = null; + if ($subscription) { + $servicePlan = ServicePlan::find($subscription->current_plan_id); + } + $billingCycle = null; + if ($subscription) { + $billingCycle = $subscription->billing_cycle; + } + RabbitMQHelper::dispatchImmediateBilling( + $workspace, + $subscription, + $user, + $servicePlan, + $billingCycle, + $amountInDollars, + null, + 'ADD_CREDITS' + ); + Log::info("Add Credits Billing Queued: Workspace {$workspace->id}, Amount: {$amountInDollars}"); } catch (Exception $ex) { - \Log::error("error while charging stripe customer: " . $ex->getMessage()); - return $this->errorInternal($request, 'Error charging stripe user'); - } - $credit = [ - 'cents' => $amountInCents, - 'card_id' => $data['card_id'], - 'user_id' => $user->id, - 'workspace_id' => $workspace->id, - 'status' => PaymentStatus::APPROVED, - 'deduplication_key' => $deduplicationKey - ]; - UserCredit::create($credit); + \Log::error("error while dispatching billing for add credits: " . $ex->getMessage()); + \Log::error($ex->getTraceAsString()); + return $this->errorInternal($request, 'Error processing credit payment'); + } // TODO: make this a database option. when it's enabled // the user's workspace should be upgraded automatically. also, // they should get emailed that the plan was upgraded. + // NOTE: we may add a feature flag that moves the user off of free trial mode + // once a credit payment is made $autoUpgradesEnabled = FALSE; - if ($autoUpgradesEnabled && $user->trial_mode) { - $user->update([ - 'trial_mode' => FALSE, - 'plan' => 'standard' - ]); - $plans = Config::get("service_plans"); - $standard = $plans['standard']; - $upgraded = SIPRouterHelper::modifyUser($user, $standard['ports']); - if (!$upgraded) { - return FALSE; - } - } - + return $this->response->noContent(); } public function checkoutWithPayPal(Request $request) @@ -166,8 +178,7 @@ public function checkoutWithPayPal(Request $request) $deduplicationKey = null; if (!empty($data['deduplication_key'])) { $deduplicationKey = $data['deduplication_key']; - } - if (empty($deduplicationKey)) { + } else { $deduplicationKey = 'credit:paypal:' . $workspace->id . ':' . $paypalInvoiceNumber; } $credit = [ @@ -321,14 +332,14 @@ public function ipnNotification(Request $request) // Step 2: Post IPN data back to PayPal to validate $apiCredentials = ApiCredentialKVStore::getRecord(); $paypalMode = 'live'; - if ($apiCredentials && empty($apiCredentials['paypal_api_mode'])) { - $paypalMode = 'sandbox'; - } - if ($apiCredentials && $apiCredentials['paypal_api_mode'] === 'sandbox') { - $paypalMode = 'sandbox'; - } - if ($apiCredentials && $apiCredentials['paypal_api_mode'] === 'test') { - $paypalMode = 'sandbox'; + if ($apiCredentials) { + if (empty($apiCredentials['paypal_api_mode'])) { + $paypalMode = 'sandbox'; + } elseif ($apiCredentials['paypal_api_mode'] === 'sandbox') { + $paypalMode = 'sandbox'; + } elseif ($apiCredentials['paypal_api_mode'] === 'test') { + $paypalMode = 'sandbox'; + } } $paypalIpnUrl = 'https://ipnpb.paypal.com/cgi-bin/webscr'; if ($paypalMode === 'sandbox') { diff --git a/app/app/Http/Controllers/Api/DIDNumber/DIDNumberController.php b/app/app/Http/Controllers/Api/DIDNumber/DIDNumberController.php index d835e9a46..d2c0c8d89 100755 --- a/app/app/Http/Controllers/Api/DIDNumber/DIDNumberController.php +++ b/app/app/Http/Controllers/Api/DIDNumber/DIDNumberController.php @@ -133,7 +133,7 @@ public function saveNumber(Request $request) */ return $this->response->array(['success' => TRUE, 'number' => $number->toArray()])->withHeader('X-Number-ID', $number->public_id); } - return $this->errorInternal($request, 'DID register error'); + return $this->errorInternal($request, 'DID register error: Failed to register the DID number with the provider. Please reach out to support for assistance.'); } diff --git a/app/app/Http/Controllers/MergedController.php b/app/app/Http/Controllers/MergedController.php index e4fa3f716..56c0d96c4 100755 --- a/app/app/Http/Controllers/MergedController.php +++ b/app/app/Http/Controllers/MergedController.php @@ -1615,4 +1615,40 @@ public function verifyTurnstile(Request $request) { return $this->response->errorInternal('Turnstile verification failed'); } } + + public function saveAutoTopupSettings(Request $request) { + $data = $request->json()->all(); + + $workspace = $this->getWorkspace($request); + $subscription = Subscription::where('workspace_id', $workspace->id)->first(); + + if (!$subscription) { + return $this->response->errorNotFound('Subscription not found'); + } + + if (isset($data['auto_topup_enabled'])) { + $subscription->auto_topup_enabled = (bool) $data['auto_topup_enabled']; + } + + if (isset($data['auto_topup_threshold'])) { + $subscription->auto_topup_threshold = (int) $data['auto_topup_threshold']; + } + + if (isset($data['auto_topup_amount'])) { + $subscription->auto_topup_amount = (int) $data['auto_topup_amount']; + } + + $subscription->save(); + + return $this->response->array([ + 'success' => true, + 'message' => 'Auto topup settings saved successfully', + 'data' => [ + 'auto_topup_enabled' => $subscription->auto_topup_enabled, + 'auto_topup_threshold' => $subscription->auto_topup_threshold, + 'auto_topup_amount' => $subscription->auto_topup_amount + ] + ]); + } + } \ No newline at end of file diff --git a/app/app/Http/Controllers/RegisterController.php b/app/app/Http/Controllers/RegisterController.php index 4f445cb4e..08c08e7a2 100755 --- a/app/app/Http/Controllers/RegisterController.php +++ b/app/app/Http/Controllers/RegisterController.php @@ -373,21 +373,25 @@ public function userSpinup(Request $request) Log::info('updated DNS successfully.'); $registerCredits = 0; - if (!empty($customizations->register_credits)) { + if (!empty($customizations->register_credits) && $plan->pay_as_you_go) { $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 - ]; + if ($plan->pay_as_you_go) { + $amountInCents = $registerCredits*100; + $deduplicationKey = 'REGISTER_CREDITS_' . date('Y_m_d') . '_' . $workspace->id; + $credit = [ + 'cents' => $amountInCents, + 'card_id' => NULL, + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + 'status' => PaymentStatus::APPROVED, + 'source' => 'REGISTER_CREDITS', + 'deduplication_key' => $deduplicationKey + ]; - UserCredit::create($credit, $plan); + UserCredit::create($credit, $plan); + } $now = new \DateTime(); $user->update([ 'last_login' => $now diff --git a/app/app/Http/routes.php b/app/app/Http/routes.php index ac81307cb..37e85ce6a 100755 --- a/app/app/Http/routes.php +++ b/app/app/Http/routes.php @@ -521,6 +521,7 @@ $api->get('getServicePlans', '\App\Http\Controllers\MergedController@getServicePlans'); $api->get('getSIPCredentials', '\App\Http\Controllers\MergedController@getSIPCredentials'); $api->post('emailSIPCredentials', '\App\Http\Controllers\MergedController@emailSIPCredentials'); + $api->post('saveAutoTopupSettings', '\App\Http\Controllers\MergedController@saveAutoTopupSettings'); $api->group([ 'prefix' => 'paypal'], function($api) { diff --git a/app/app/ServicePlan.php b/app/app/ServicePlan.php index a964523f5..601900527 100755 --- a/app/app/ServicePlan.php +++ b/app/app/ServicePlan.php @@ -28,6 +28,7 @@ class ServicePlan extends Model { 'featured_plan' => 'boolean', 'pay_as_you_go' => 'boolean', 'registration_plan' => 'boolean', + 'is_free_trial_active' => 'boolean', 'include_in_pricing_pages' => 'boolean', 'free_trial_exempt' => 'boolean' ); diff --git a/app/app/Subscription.php b/app/app/Subscription.php index 45081006b..bb5ea269f 100755 --- a/app/app/Subscription.php +++ b/app/app/Subscription.php @@ -17,6 +17,8 @@ class Subscription extends Model { protected $casts = array( 'is_free_trial_active' => 'boolean', 'cancel_at_period_end' => 'boolean', + 'pay_as_you_go' => 'boolean', + 'auto_topup_enabled' => 'boolean', ); public function toArray() diff --git a/app/app/User.php b/app/app/User.php index 0289a558e..e6fdaa713 100755 --- a/app/app/User.php +++ b/app/app/User.php @@ -53,15 +53,23 @@ public function getSIPURL() { } public function canBuyNumber($workspace, $user, $number, $cost) { $limit = MainHelper::checkLimit($workspace, $user, "numbers"); + if ($limit) { - if ($workspace->trial_mode) { + $subscription = Subscription::where('workspace_id', $workspace->id)->first(); + if ($subscription && $subscription->is_free_trial_active) { return array(FALSE, "Trial accounts cannot buy more than 1 number"); } else { return array(FALSE, "Cannot purchase more numbers under this plan"); } } $balance = BillingDataHelper::getBillingInfo($this); - if ($balance['remainingBalance']<=$cost && $workspace->plan == 'pay-as-you-go') { + + $subscription = Subscription::select(array('service_plans.pay_as_you_go')) + ->join('service_plans', 'service_plans.id', '=', 'subscriptions.current_plan_id') + ->where('workspace_id', $workspace->id) + ->first(); + + if ($balance['remainingBalance']<=$cost && $subscription && $subscription->pay_as_you_go) { return array(FALSE, "Your remaining balance is below the number's monthly cost"); } return array(TRUE, ""); diff --git a/app/app/Workspace.php b/app/app/Workspace.php index 7e9ce1633..748b9a715 100755 --- a/app/app/Workspace.php +++ b/app/app/Workspace.php @@ -8,6 +8,7 @@ use App\User; use App\ServicePlan; use App\WorkspaceUser; +use App\Subscription; class Workspace extends Model { protected $dates = ['created_at', 'updated_at']; @@ -27,10 +28,23 @@ public function makeDomainName($region='') { return MainHelper::createSubdomain($this->name); } + // TODO: rework this and use SQL joins with subscriptions data to make + // things more performant. public function toArrayWithRoles(User $user) { $array = $this->toArray(); $workspaceUser = WorkspaceUser::where('user_id', '=', $user->id)->where('workspace_id', '=', $this->id)->first(); + $subscription = Subscription::select(array( + 'service_plans.key_name', + 'service_plans.pay_as_you_go', + 'subscriptions.*' + )); + + $subscription = $subscription->join('service_plans', 'service_plans.id', '=', 'subscriptions.current_plan_id') + ->where('subscriptions.workspace_id', '=', $this->id) + ->first(); + $array['user_info'] = $workspaceUser->toArray(); + $array['subscription_info'] = $subscription->toArray(); return $array; } public function provisionURL() { diff --git a/app/database/migrations/2026_07_23_192424_add_allow_multiple_workspace_users_to_plans.php b/app/database/migrations/2026_07_23_192424_add_allow_multiple_workspace_users_to_plans.php new file mode 100644 index 000000000..9784839e6 --- /dev/null +++ b/app/database/migrations/2026_07_23_192424_add_allow_multiple_workspace_users_to_plans.php @@ -0,0 +1,35 @@ +boolean('allow_multiple_workspace_users')->default(true); + $table->boolean('trial_ends_on_purchase')->default(true); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('service_plans', function (Blueprint $table) { + // + $table->dropColumn('allow_multiple_workspace_users'); + $table->dropColumn('trial_ends_on_purchase'); + }); + } +} diff --git a/app/database/migrations/2026_07_23_212341_add_auto_topup_fields.php b/app/database/migrations/2026_07_23_212341_add_auto_topup_fields.php new file mode 100644 index 000000000..28adbc488 --- /dev/null +++ b/app/database/migrations/2026_07_23_212341_add_auto_topup_fields.php @@ -0,0 +1,36 @@ +boolean('auto_topup_enabled')->default(FALSE); + $table->integer('auto_topup_threshold')->default(0); + $table->integer('auto_topup_amount')->default(0); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('subscriptions', function (Blueprint $table) { + $table->dropColumn('auto_topup_enabled'); + $table->dropColumn('auto_topup_threshold'); + $table->dropColumn('auto_topup_amount'); + }); + } +} diff --git a/app/resources/lang/en/admin/serviceplans.php b/app/resources/lang/en/admin/serviceplans.php index ec4e72965..213eae484 100755 --- a/app/resources/lang/en/admin/serviceplans.php +++ b/app/resources/lang/en/admin/serviceplans.php @@ -32,5 +32,9 @@ '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' + 'free_trial_exempt' => 'Free Trial Exempt', + 'allow_multiple_workspace_users' => 'Allow multiple workspace users', + 'trial_ends_on_purchase' => 'Trial ends on purchase', + 'save_first_to_migrate' => 'Save the plan first before you can migrate users' + ]; \ 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 b511bbed3..fc571c4ad 100755 --- a/app/resources/views/admin/serviceplan/create_edit.blade.php +++ b/app/resources/views/admin/serviceplan/create_edit.blade.php @@ -1,22 +1,36 @@ @extends('admin.layouts.modal') {{-- Content --}} @section('content') +
+
+

+ @if (isset($serviceplan)) + {{ trans("admin/modal.edit") }} + @else + {{ trans("admin/modal.create") }} + @endif +

+
+
- - -@if (isset($serviceplan)) -{!! Form::model($serviceplan, array('url' => url('admin/serviceplan') . '/' . $serviceplan->id, 'method' => 'put', 'class' => 'bf', 'files'=> true)) !!} -@else -{!! Form::open(array('url' => url('admin/serviceplan'), 'method' => 'post', 'class' => 'bf', 'files'=> true)) !!} -@endif + + + + @if (isset($serviceplan)) + {!! Form::model($serviceplan, array('url' => url('admin/serviceplan') . '/' . $serviceplan->id, 'method' => 'put', 'class' => 'bf', 'files'=> true)) !!} + @else + {!! Form::open(array('url' => url('admin/serviceplan'), 'method' => 'post', 'class' => 'bf', 'files'=> true)) !!} + @endif + -
+
@@ -89,16 +103,71 @@
+
+ {!! Form::label('pay_as_you_go', trans("admin/serviceplans.pay_as_you_go"), array('class' => 'control-label')) !!} +
+
+ + +
+ {{ $errors->first('pay_as_you_go', ':message') }} +
+
+ +
+ {!! Form::label('allow_multiple_workspace_users', trans("admin/serviceplans.allow_multiple_workspace_users"), array('class' => 'control-label')) !!} +
+
+ + +
+ {{ $errors->first('allow_multiple_workspace_users', ':message') }} +
+
+
+ {!! Form::label('trial_ends_on_purchase', trans("admin/serviceplans.trial_ends_on_purchase"), array('class' => 'control-label')) !!} +
+
+ + +
+ {{ $errors->first('trial_ends_on_purchase', ':message') }} +
+
@foreach ( $features as $feature )
{!! form::label($feature['key'], trans("admin/serviceplans." . $feature['key']), array('class' => 'control-label')) !!}
- {!! form::label($feature['key'], trans("admin/users.yes"), array('class' => 'control-label')) !!} - {!! form::radio($feature['key'], '1', @isset($serviceplan)? $serviceplan->{$feature['key']} : 'false') !!} - {!! form::label($feature['key'], trans("admin/users.no"), array('class' => 'control-label')) !!} - {!! form::radio($feature['key'], '0', @isset($serviceplan)? $serviceplan->{$feature['key']} : 'true') !!} +
+ + +
{{ $errors->first($feature['key'], ':message') }}
From 3a8e363e42a8857c598f23fd67352048e9c7b5a4 Mon Sep 17 00:00:00 2001 From: Nadir Hamid Date: Fri, 24 Jul 2026 20:19:57 +0000 Subject: [PATCH 7/9] integrate dispatchBalanceCheck for sending out balance check alerts --- app/app/Helpers/RabbitMQHelper.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/app/Helpers/RabbitMQHelper.php b/app/app/Helpers/RabbitMQHelper.php index 6da652c5c..19b95a544 100755 --- a/app/app/Helpers/RabbitMQHelper.php +++ b/app/app/Helpers/RabbitMQHelper.php @@ -309,5 +309,18 @@ public static function dispatchWorkspaceUpgrade( return self::publish('workspace_upgrades', $payload); } + public static function dispatchBalanceCheck( + $workspaceId, + $source, + $createdAt + ) { + $payload = [ + 'workspace_id' => (int) $workspaceId, + 'source' => (string) $source, + 'created_at' => (string) $createdAt, + ]; + + return self::publish('alerting_queue', $payload); + } } From cf02b96eea66a96de5e4fa17f2dbf981f7c529f9 Mon Sep 17 00:00:00 2001 From: Nadir Hamid Date: Fri, 24 Jul 2026 21:21:54 +0000 Subject: [PATCH 8/9] integrate workflows to send out balance alerts on purchases --- .../Http/Controllers/Api/DIDNumber/DIDNumberController.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/app/Http/Controllers/Api/DIDNumber/DIDNumberController.php b/app/app/Http/Controllers/Api/DIDNumber/DIDNumberController.php index d2c0c8d89..5589ba944 100755 --- a/app/app/Http/Controllers/Api/DIDNumber/DIDNumberController.php +++ b/app/app/Http/Controllers/Api/DIDNumber/DIDNumberController.php @@ -16,6 +16,7 @@ use \App\NumberService\NumberService; use \App\Helpers\MainHelper; use \App\Helpers\EmailHelper; +use \App\Helpers\RabbitMQHelper; use \App\Helpers\WorkflowTraits\DIDNumber\DIDNumberWorkflow; use \DB; use Mail; @@ -100,7 +101,7 @@ public function saveNumber(Request $request) 'workspace_id' => $workspace->id, 'status' => PaymentStatus::PENDING, 'module_id' => $number->id, - 'deduplication_key' => 'debit:number_rental:' . $workspace->id . ':' . $number->id + 'deduplication_key' => 'DEBIT:NUMBER_RENTAL:' . $workspace->id . ':' . $number->id ]); $flow = Flow::create([ @@ -123,6 +124,8 @@ public function saveNumber(Request $request) $subject = "DID Purchased"; $result = EmailHelper::sendEmail($subject, $user->email, 'did_purchased', $data); + RabbitMQHelper::dispatchBalanceCheck($workspace->id, 'DID_PURCHASE', date('c')); + /* Mail::send('emails.did_purchased', $data, function ($message) use ($user, $mail) { $message->to($user->email); From 6fa535c120d81a43a86b6721f7823ce4c48e34fe Mon Sep 17 00:00:00 2001 From: Nadir Hamid Date: Fri, 24 Jul 2026 21:54:06 +0000 Subject: [PATCH 9/9] code to handle balance low alerts emails --- .../Commands/RabbitMQEventConsumer.php | 80 ++++++++++++ .../views/emails/balance_low_alert.blade.php | 116 ++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100755 app/resources/views/emails/balance_low_alert.blade.php diff --git a/app/app/Console/Commands/RabbitMQEventConsumer.php b/app/app/Console/Commands/RabbitMQEventConsumer.php index 0c770c0fc..cc3065929 100644 --- a/app/app/Console/Commands/RabbitMQEventConsumer.php +++ b/app/app/Console/Commands/RabbitMQEventConsumer.php @@ -87,6 +87,7 @@ public function handle() $channel->basic_consume(RabbitMQHelper::INVOICE_QUEUE_ANNUAL, '', false, false, false, false, [$this, 'handleAnnualInvoiceTask']); $channel->basic_consume(RabbitMQHelper::WORKSPACE_SUSPENDED_QUEUE, '', false, false, false, false, [$this, 'handleWorkspaceSuspended']); $channel->basic_consume(RabbitMQHelper::WORKSPACE_SUSPENDED_LEGACY_QUEUE, '', false, false, false, false, [$this, 'handleWorkspaceSuspended']); + $channel->basic_consume(RabbitMQHelper::PAY_AS_YOU_GO_BALANCE_QUEUE, '', false, false, false, false, [$this, 'handlePayAsYouGoBalanceAlert']); // 3. Keep the process alive while (count($channel->callbacks)) { @@ -776,4 +777,83 @@ private function handleInvoiceTask($msg, $period) $this->error(sprintf(' [!] %s invoice failed for workspace #%d: %s', $period, $workspaceId, $e->getMessage())); } } + + public function handlePayAsYouGoBalanceAlert($msg) + { + $data = json_decode($msg->body, true); + $workspaceId = array_key_exists('workspace_id', $data) ? (int) $data['workspace_id'] : 0; + $this->info(sprintf(" [PAY_AS_YOU_GO_ALERT] Received balance alert for workspace #%d", $workspaceId)); + + $workspace = Workspace::find($workspaceId); + if (!$workspace) { + $this->error(sprintf('Workspace #%d not found. Acknowledging and skipping.', $workspaceId)); + $msg->delivery_info['channel']->basic_ack($msg->delivery_info['delivery_tag']); + return; + } + + $owner = $workspace->creatorUser()->first(); + $recipientEmails = $this->resolveWorkspaceSuspensionRecipients($workspace, $owner); + if (empty($recipientEmails)) { + $this->error(sprintf('No owner or admin email addresses found for workspace #%d. Acknowledging and skipping.', $workspaceId)); + $msg->delivery_info['channel']->basic_ack($msg->delivery_info['delivery_tag']); + return; + } + + try { + $subscription = $workspace->subscriptions()->first(); + $autoTopupEnabled = false; + $wasToppedup = false; + + if ($subscription) { + $autoTopupEnabled = (bool) $subscription->auto_topup_enabled; + $wasToppedup = array_key_exists('was_topped_up', $data) ? (bool) $data['was_topped_up'] : false; + } + + $currentBalance = array_key_exists('current_balance', $data) ? (float) $data['current_balance'] : 0; + $threshold = array_key_exists('threshold', $data) ? (float) $data['threshold'] : 0; + + $emailTemplate = 'balance_low_alert'; + $emailSubject = 'Workspace Balance Alert'; + $emailData = [ + 'workspace' => $workspace, + 'owner' => $owner, + 'user' => $owner, + 'current_balance' => $currentBalance, + 'threshold' => $threshold, + 'auto_topup_enabled' => $autoTopupEnabled, + 'was_topped_up' => $wasToppedup + ]; + + if ($autoTopupEnabled && $wasToppedup) { + $emailSubject = 'Workspace Balance Topped Up'; + $topupAmount = array_key_exists('topup_amount', $data) ? (float) $data['topup_amount'] : 0; + $emailData['topup_amount'] = $topupAmount; + } + + $failedRecipients = []; + foreach ($recipientEmails as $email) { + $result = EmailHelper::sendEmail($emailSubject, $email, $emailTemplate, $emailData); + + if ($result !== TRUE) { + $failedRecipients[$email] = $result; + } + } + + if (empty($failedRecipients)) { + $this->info(sprintf( + " [v] Balance alert email sent for workspace #%d (balance: %f, threshold: %f, auto_topup: %s)", + $workspaceId, + $currentBalance, + $threshold, + $autoTopupEnabled ? 'enabled' : 'disabled' + )); + $msg->delivery_info['channel']->basic_ack($msg->delivery_info['delivery_tag']); + } else { + $this->error(sprintf(' [!] Balance alert email failed for workspace #%d', $workspaceId)); + } + } catch (Exception $e) { + $this->error(sprintf(' [!] Pay-as-you-go balance alert failed for workspace #%d: %s', $workspaceId, $e->getMessage())); + } + } + } diff --git a/app/resources/views/emails/balance_low_alert.blade.php b/app/resources/views/emails/balance_low_alert.blade.php new file mode 100755 index 000000000..8dd2a24ff --- /dev/null +++ b/app/resources/views/emails/balance_low_alert.blade.php @@ -0,0 +1,116 @@ +@extends('emails.layouts.alert_email') +@section('title') +Workspace Balance Low +@endsection +@section('content') + + + + + + + + + + + + + + + + + + + + + + + + + +
  + + + + + + + + + + + + + + + + + + + + + + + +
 
+   +
 
+  
 
+ + + + + + + +
+
 
+  
+  
 
+ + +@endsection \ No newline at end of file