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/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 @@
+cents;
}
foreach ($invoices as $invoice) {
+
if ($invoice->status != PaymentStatus::PAID) {
$accountBalance += $invoice->cents;
}
diff --git a/app/app/Helpers/RabbitMQHelper.php b/app/app/Helpers/RabbitMQHelper.php
index ccc3163b6..19b95a544 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
@@ -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);
+ }
}
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'
];
}
}
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..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);
@@ -133,7 +136,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/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;
});
diff --git a/app/app/Http/Controllers/MergedController.php b/app/app/Http/Controllers/MergedController.php
index ff9bdb9a3..56c0d96c4 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;
@@ -398,7 +399,8 @@ public function dashboard(Request $request)
$checklist,
$plan,
$workspace->toArrayWithRoles($user),
- $metrics
+ $metrics,
+ $subscription->toArray()
]);
}
@@ -1092,11 +1094,16 @@ public function search(Request $request) {
}
public function billingDiscontinue(Request $request) {
- // downgrade plan to pay as you go
+ // set cancel_at_period_end on the subscription
$workspace = $this->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"
@@ -1104,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;
@@ -1508,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 a9d4c9f8c..08c08e7a2 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,
@@ -371,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 c24ec2a54..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) {
@@ -563,6 +564,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/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 76d615226..bb5ea269f 100755
--- a/app/app/Subscription.php
+++ b/app/app/Subscription.php
@@ -16,6 +16,9 @@ class Subscription extends Model {
protected $table = "subscriptions";
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_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');
+ });
+ }
+}
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
+
-
+
+
+
+
+
@foreach ( $features as $feature )
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')
+
+
+
+
+
+
+
+ | |
+
+
+
+
+
+ | |
+
+
+
+ |
+
+ |
+
+
+ | |
+
+
+ |
+ |
+
+
+ | |
+
+
+
+
+
+
+
+ @if ($auto_topup_enabled && $was_topped_up)
+
+
+ Your workspace balance has been automatically topped up.
+
+
+
+
+ Your account has been automatically credited with ${{ number_format($topup_amount, 2) }}.
+
+
+ Current Balance: ${{ number_format($current_balance, 2) }}
+
+ @else
+
+
+ Your workspace balance is low and needs to be topped up.
+
+
+
+
+ Current Balance: ${{ number_format($current_balance, 2) }}
+ Threshold: ${{ number_format($threshold, 2) }}
+
+ @if (!$auto_topup_enabled)
+
+ Please top up your account to continue using our services.
+
+ @endif
+ @endif
+
+ |
+
+
+
+
+ |
+
+
+
+ |
+
+
+ | |
+
+
+ |
+ |
+
+
+
+ |
+ |
+
+
+ | |
+
+
+
+
+ |
+
+@endsection
\ No newline at end of file