Skip to content
80 changes: 80 additions & 0 deletions app/app/Console/Commands/RabbitMQEventConsumer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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()));
}
}

}
24 changes: 24 additions & 0 deletions app/app/Enums/SubscriptionStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace App\Enums;

/**
* Subscription Statuses
*/
abstract class SubscriptionStatus
{
public const ACTIVE = 'ACTIVE';
public const INACTIVE = 'INACTIVE';
public const CANCELLED = 'CANCELLED';
/**
* Optional: Helper to get all values for validation
*/
public static function all(): array
{
return [
self::ACTIVE,
self::INACTIVE,
self::CANCELLED,
];
}
}
1 change: 1 addition & 0 deletions app/app/Helpers/BillingDataHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
17 changes: 15 additions & 2 deletions app/app/Helpers/RabbitMQHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,15 @@ 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(),
'billing_type' => $billingCycle, // Already 'MONTHLY' or 'ANNUAL'
'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
Expand Down Expand Up @@ -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);
}

}
6 changes: 3 additions & 3 deletions app/app/Http/Controllers/Admin/SIPProviderController.php
Original file line number Diff line number Diff line change
Expand Up @@ -295,9 +295,9 @@ public function data()
}
public function providerTypes() {
return [
'inbound' => 'inbound',
'outbound' => 'outbound',
'both' => 'both'
'INBOUND' => 'INBOUND',
'OUTBOUND' => 'OUTBOUND',
'BOTH' => 'BOTH'
];
}
}
1 change: 0 additions & 1 deletion app/app/Http/Controllers/Admin/ServicePlanController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
];
Expand Down
107 changes: 59 additions & 48 deletions app/app/Http/Controllers/Api/Credit/CreditController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,6 +30,7 @@
use PayPal\Api\PaymentExecution;

use App\Helpers\EmailHelper;
use App\Helpers\RabbitMQHelper;

use \Config;
use \Exception;
Expand All @@ -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)
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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([
Expand All @@ -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);
Expand All @@ -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.');
}


Expand Down
Loading
Loading