Solutech Engineering
Innovations/Add-on Services

Credit Management System

Core credit lifecycle management, allocation, and consumption processes

Credit Management System

The credit management system is the heart of the Add-on Services feature, handling the complete lifecycle from purchase to consumption.

System Architecture Overview

The Add-on Services system operates across two distinct environments:

Portal System (Central Management)

  • Product Item Creation: All product items and product item plans (pricing rates) are created and managed centrally in the portal system
  • Plan Configuration: Service pricing, credit allocations, and plan details are configured in the portal
  • Client Synchronization: Product items and plans are automatically synced from the portal to individual client servers
  • Centralized Control: Ensures consistency across all client environments

Client Environment (Local Operations)

  • Credit Processing: All credit-related operations (purchase, consumption, tracking) happen locally on the client's environment
  • Payment Processing: Invoice generation, payment verification, and credit issuance occur on client servers
  • Service Usage: Actual service consumption and credit deduction happen in the client environment
  • Real-time Operations: Ensures fast response times and reduced dependency on central systems

This distributed architecture allows for centralized planning and configuration while maintaining performance and reliability for day-to-day operations.

Core Credit Operations

1. Credit Purchase Flow

The credit purchase process involves multiple steps to ensure transaction integrity and proper credit allocation.

Controller: BillingAddOnsPaymentController

// Step 1: Invoice Generation (GenerateAddOnInvoiceService)
public function invoice(InvoiceRequest $request): JsonResponse
{
    try {
        $filters = (object) $request->all();
        $invoice = (new GenerateAddOnInvoiceService($request->addon_plan_id))->handle($filters);
    } catch (\Exception $e) {
        \Log::info($e);
        return $this->error($e->getMessage(), 500);
    }

    return $this->success($invoice, 'Invoice created successfully', 201);
}

// Step 2: Invoice Callback (from external payment gateway)
public function callback(CallbackInvoiceRequest $request): JsonResponse
{
    BillingAddOnsPayment::query()
        ->where('id', $request->addon_payment_id)
        ->update([
            'invoiced_at' => Carbon::now(),
            'invoice_number' => $request->invoice_number,
            'invoice_status' => $request->status,
            'invoice_document' => $request->invoice_doc
        ]);

    return $this->success([], 'Invoice created successfully', 201);
}

// Step 3: Payment Verification from Portal
public function verifyPaymentFromPortal(Request $request): JsonResponse
{
    $data = (object) $request->all();

    // Store payment data
    (new PaymentsController)->savePayments($request);

    // Update payment using invoice_number
    $service_payment = BillingAddOnsPayment::query()
        ->where('invoice_number', $data->BillRefNumber)
        ->where('invoice_status', '!=', 'Closed')
        ->where(function($q) {
            $q->whereNull('payment_reference')->orWhereNull('payment_id');
        })
        ->first();

    if (!$service_payment) throw new \Exception('Payment not found', 404);

    // Update payment details and close invoice
    $service_payment->update([
        'payment_reference' => $data->TransID,
        'payment_id' => Payment::where('transactionreference', $data->TransID)->first()->id,
        'invoice_status' => 'Closed'
    ]);

    return $this->success($service_payment, 'Payment verified successfully', 200);
}

Key Business Rules:

  • Payments are processed through external portal integration
  • Invoice numbers are used to match payments to plans
  • Payment verification triggers automatic credit issuance
  • Failed payments maintain 'Pending' status for manual review

2. Credit Issuance Process

The credit issuance process operates on product plans that have been synchronized from the portal system to the client environment. These plans contain the pricing rates and credit allocations configured centrally in the portal.

Service: AddCreditsToAddOnServiceService

public function handle(): array
{
    try {
        DB::beginTransaction();

        if (!$this->servicePaymentPlan) {
            throw new \Exception('Service or Pricing Plan not found');
        }

        // Step 1: Add credits to the billing plan
        BillingAddOnsPlan::query()
            ->where('id', $this->servicePaymentPlan->billing_add_ons_plans_id)
            ->increment('available_quantity', $this->servicePaymentPlan->credits);

        // Step 2: Log the credit issuance
        $log = $this->log($this->servicePaymentPlan);

        if (!$log['status']) {
            throw new \Exception($log['message']);
        }

        // Step 3: Reset threshold notifications
        $this->resetNotificationDefer();

        DB::commit();

        return $this->to_method_status('Credits added successfully', true, []);

    } catch (\Throwable $th) {
        Log::info($th->getMessage());
        DB::rollBack();

        return $this->to_method_status(
            'Error adding credits to service. Please try again or contact support.', 
            false, 
            ['error' => $th->getMessage()]
        );
    }
}

public function log(object $servicePaymentPlan)
{
    try {
        BillingCreditsIssuance::query()->create([
            'subscription_id' => $servicePaymentPlan->subscription_id,
            'product_item_plan_id' => $servicePaymentPlan->id,
            'quantity' => $servicePaymentPlan->credits,
            'amount' => $servicePaymentPlan->amount,
            'created_by' => Auth::id() ?? 0,
        ]);
    } catch (\Throwable $th) {
        Log::info($th);

        return $this->to_method_status(
            'Credit Issuance Error. Please try again or contact support.', 
            false, 
            ['error' => $th]
        );
    }

    return $this->to_method_status('Logging success!', true, []);
}

/**
 * Reset notification defer to allow new notifications after credit addition
 */
public function resetNotificationDefer(): void
{
    try {
        $subscription_id = BillingAddOnsPlan::query()
            ->where('id', $this->servicePaymentPlan->billing_add_ons_plans_id)
            ->first()
            ->subscription_id;

        $last_notification_sent = BillingCreditsThresholdNotification::query()
            ->where('subscription_id', $subscription_id)
            ->latest()
            ->first();

        if (!$last_notification_sent) return;

        // Mark notification as resolved
        $last_notification_sent->resolved = 1;
        $last_notification_sent->save();
    } catch (\Throwable $th) {
        Log::info($th->getMessage());
    }
}

3. Credit Consumption Process

Service: RemoveCreditsFromAddOnServiceService

public function handle()
{
    try {
        // Step 1: Check if service is disabled
        if ($this->disable()) {
            return $this->to_method_status(
                "{$this->addon_service_key} service is disabled", 
                true, 
                (object) ['credits' => 0]
            );
        }

        // Step 2: Get available payment plans (FIFO order)
        $servicePaymentPlan = $this->getServicePaymentPlan();

        // Step 3: Validate sufficient credits
        if ($this->noAvailableCredits($servicePaymentPlan)) {
            throw new \Exception('You have no available credits to consume.', 400);
        }

        DB::beginTransaction();

        // Step 4: Remove credits using FIFO approach
        $this->removeCredits($servicePaymentPlan);

        // Step 5: Check and alert threshold breach
        $this->alertThresholdBreach($servicePaymentPlan[0]);

        DB::commit();

        return $this->to_method_status('Credits removed successfully', true, []);

    } catch (\Throwable $th) {
        Log::info($th);
        DB::rollBack();

        return $this->to_method_status(
            'Error removing credits to service. Please try again or contact support.', 
            false, 
            ['error' => $th->getMessage()]
        );
    }
}

public function removeCredits(Collection $servicePaymentPlan): void
{
    $total_credits = $this->credits_to_remove;

    $servicePaymentPlan->each(function($service) use (&$total_credits) {
        $credits_to_remove = $total_credits;

        // Check if this pricing plan has enough credits
        if ($service->available_quantity < $total_credits) {
            $credits_to_remove = $service->available_quantity;
        }

        // Decrement available quantity if not holding
        if (!$this->hold) {
            BillingAddOnsPlan::query()
                ->where('id', $service->id)
                ->decrement('available_quantity', $credits_to_remove);
        }

        // Log the credit usage
        $log = $this->log($service, $credits_to_remove);

        if (!$log['status']) {
            throw new \Exception($log['message']);
        }

        // Decrease total credits to be removed
        $total_credits -= $credits_to_remove;

        // Stop if all credits consumed
        if ($total_credits === 0) return false;

        // Update subscription total
        $this->subscription->decrement('available_quantity', $this->credits_to_remove);
    });
}

/**
 * Get service payment plans in FIFO order (First In, First Out)
 */
public function getServicePaymentPlan(): Collection
{
    return BillingAddOnsPlan::query()
        ->join('billing_subscriptions', 'billing_subscriptions.id', '=', 'billing_add_ons_plans.subscription_id')
        ->where('billing_subscriptions.id', $this->subscription->id)
        ->where('billing_subscriptions.status_name', 'Active')
        ->where('billing_add_ons_plans.status_name', 'Active')
        ->whereNull('billing_add_ons_plans.deleted_at')
        ->whereNull('billing_subscriptions.deleted_at')
        ->where('billing_add_ons_plans.available_quantity', '>', 0)
        ->selectRaw('
            billing_add_ons_plans.id,
            billing_add_ons_plans.subscription_id,
            billing_add_ons_plans.name as plan_name,
            billing_add_ons_plans.available_quantity,
            billing_add_ons_plans.id as plan_id,
            billing_subscriptions.product_name
        ')
        ->groupBy('billing_add_ons_plans.id')
        ->get();
}

4. Credit Hold & Release Mechanism

For services requiring confirmation (like SMS delivery or document processing), the system implements a hold mechanism:

Holding Credits:

public function log(object $servicePaymentPlan, float $credits_to_remove): array
{
    try {
        BillingCreditsUsage::query()->create([
            'subscription_id' => $servicePaymentPlan->subscription_id,
            'product_item_plan_id' => $servicePaymentPlan->id,
            'quantity' => $credits_to_remove,
            'hold' => $this->hold, // Boolean flag for held credits
            'created_by' => Auth::id() ?? 0
        ]);
    } catch (\Throwable $th) {
        Log::info($th);

        return $this->to_method_status(
            'Credit Usage Error. Please try again or contact support.', 
            false, 
            ['error' => $th]
        );
    }

    return $this->to_method_status('Logging success!', true, []);
}

Releasing Held Credits:

public function release()
{
    try {
        if ($this->disable()) {
            return $this->to_method_status(
                "{$this->addon_service_key} service is disabled", 
                true, 
                (object) ['credits' => 0]
            );
        }

        $servicePaymentPlan = $this->getServicePaymentPlan();

        if (empty($servicePaymentPlan)) {
            return $this->to_method_status('No service payment plan found', false, []);
        }

        DB::beginTransaction();

        // Restore credits to the plan
        BillingAddOnsPlan::query()
            ->where('id', $servicePaymentPlan->id)
            ->decrement('available_quantity', $this->credits_to_remove);

        // Update held credits to permanent usage
        BillingCreditsUsage::query()
            ->where('subscription_id', $servicePaymentPlan->subscription_id)
            ->where('product_item_plan_id', $servicePaymentPlan->id)
            ->where('quantity', $this->credits_to_remove)
            ->where('hold', 1)
            ->update(['hold' => 0]);
                
        DB::commit();
    } catch (\Exception $e) {
        DB::rollBack();
    }
}

Credit Validation Service

Service: CheckCreditsAddOnServiceService

public function handle(): array
{
    try {
        // Check if service is disabled
        if ($this->disable()) {
            return $this->to_method_status(
                "{$this->addon_service_key} service is disabled", 
                true, 
                (object) ['credits' => 0]
            );
        }

        // Get service payment plans
        $servicePaymentPlan = $this->getServicePaymentPlan();

        // Calculate available credits
        $available_credits = $servicePaymentPlan->sum('available_quantity');

        // Validate sufficient credits
        if (!$servicePaymentPlan || $available_credits === 0 || $available_credits < $this->credits_to_remove) {
            throw new \Exception('You have no available credits to consume.', 400);
        }

        return $this->to_method_status('Credits checked successfully', true, (object) [
            'credits' => $available_credits
        ]);

    } catch (\Exception $e) {
        return $this->to_method_status($e->getMessage(), false, [
            'error' => $e
        ]);
    }
}

/**
 * Check if service is disabled via configuration
 */
public function disable(): bool
{
    $service_key = str_replace(' ', '_', strtolower($this->addon_service_key));
    $general_setting = config("settings.disable_addons_for_{$service_key}");

    return !is_null($general_setting) && $general_setting == 'yes';
}

Threshold Management System

Threshold Monitoring

public function alertThresholdBreach(object $billingSubscription): array
{
    try {
        // Get current service plan with threshold settings
        $service_plan = BillingAddOnsPlan::query()
            ->join('billing_subscriptions', 'billing_subscriptions.id', '=', 'billing_add_ons_plans.subscription_id')
            ->join('billing_subscription_threshold_settings as sts', 'billing_subscriptions.id', '=', 'sts.subscription_id')
            ->where('billing_subscriptions.id', $billingSubscription->subscription_id)
            ->selectRaw("
                SUM(billing_add_ons_plans.available_quantity) as available_quantity,
                sts.threshold
            ")
            ->where('billing_subscriptions.status_name', 'Active')
            ->where('sts.status', 'Active')
            ->first();

        // Calculate threshold level
        $service_plan->threshold_level = (new GetBillingSubcriptionsService)
            ->setThresholdLevel($service_plan->available_quantity, $service_plan->threshold);

        // Check if threshold breached
        if ($service_plan->threshold_level === 'healthy') {
            return $this->to_method_status('Threshold not breached', true, []);
        }

        // Send threshold breach notification
        $options = (object) [
            'activity' => BillingCreditsThresholdNotification::ACTIVITY['REMOVE_CREDITS'],
            'subscription_id' => $billingSubscription->subscription_id,
        ];

        SubscriptionThresholdNotificationsJob::dispatch($options);

    } catch (\Throwable $th) {
        Log::info($th);

        return $this->to_method_status(
            'Error alerting threshold breach. Please try again or contact support.', 
            false, 
            ['error' => $th]
        );
    }

    return $this->to_method_status('Threshold alert sent successfully', true, []);
}

Notification Management

Service: SubscriptionThresholdNotificationsService

public function deferNotifications(object $sub): bool
{
    $frequency_in_hours = $this->setNotificationDelay();

    // Check if user already received notification for this service
    $if_notification_sent = BillingCreditsThresholdNotification::where([
        ['user_id', $this->user->id],
        ['subscription_id', $sub->id],
        ['threshold_level', $sub->threshold_level],
        ['subscription_threshold_id', $sub->threshold_id],
        ['activity', $this->activity],
        ['created_at', '>=', Carbon::now()->subHours($frequency_in_hours)->format('Y-m-d H:i:s')]
    ])->first();

    // Return true to defer if notification already sent and not resolved
    return !empty($if_notification_sent) && !$if_notification_sent->resolved;
}

public function setNotificationDelay()
{
    $frequency_in_hours = 6; // Default delay

    if ($this->activity == BillingCreditsThresholdNotification::ACTIVITY['CRON']) {
        $frequency_in_hours = 24; // Daily for cron jobs
    }

    return $frequency_in_hours;
}

Database Models & Structure

The database models work with data that originates from two sources:

  • Portal-Synced Data: Product items and plans (BillingAddOnsPlan) are created in the portal and synchronized to client databases
  • Client-Generated Data: Credit operations, payments, and usage tracking (BillingCreditsUsage, BillingCreditsIssuance) are generated locally on client systems

BillingCreditsUsage Model

class BillingCreditsUsage extends Model
{
    protected $fillable = [
        'subscription_id',
        'product_item_plan_id',
        'quantity',
        'hold',           // Boolean: whether credits are held (pending confirmation)
        'created_by'
    ];

    public function scopeFilter(Builder $query, array $filters): Builder
    {
        $dates = (new GetStartEndDates($filters))->getStartEndDates();

        return $query
            ->when(isset($filters['start_date']) && isset($filters['end_date']), 
                function($query) use ($dates) {
                    $query->whereBetween('billing_credits_usages.created_at', $dates);
                })
            ->when(isset($filters['rep_id']), function ($q) use ($filters) {
                $q->whereIn('billing_credits_usages.created_by', $filters['rep_id']);
            });
    }
}

BillingCreditsIssuance Model

class BillingCreditsIssuance extends Model
{
    protected $fillable = [
        'subscription_id',      // Links to billing subscription
        'product_item_plan_id', // Links to specific plan that received credits
        'quantity',             // Number of credits issued
        'amount',              // Monetary value of credits
        'created_by'           // User who initiated the issuance
    ];

    public function scopeFilter(Builder $query, array $filters): Builder
    {
        $dates = (new GetStartEndDates($filters))->getStartEndDates();

        return $query
            ->when(isset($filters['start_date']) && isset($filters['end_date']), 
                function($query) use ($dates) {
                    $query->whereBetween('billing_credits_issuances.created_at', $dates);
                });
    }
}

BillingAddOnsPlan Model (Portal-Synchronized)

class BillingAddOnsPlan extends Model
{
    protected $fillable = [
        'subscription_id',       // Links to billing subscription
        'name',                 // Plan name (synced from portal)
        'price',               // Plan price (synced from portal)
        'credits',            // Credit quantity included in plan (synced from portal)
        'available_quantity', // Current available credits (updated locally)
        'status_name',       // Plan status (Active/Inactive)
        'created_at',
        'updated_at'
    ];

    /**
     * Note: The core plan details (name, price, credits) are created and managed 
     * in the portal system, then synchronized to client environments.
     * Only the available_quantity is modified locally during credit operations.
     */

    public function subscription()
    {
        return $this->belongsTo(BillingSubscription::class, 'subscription_id');
    }

    public function creditUsages()
    {
        return $this->hasMany(BillingCreditsUsage::class, 'product_item_plan_id');
    }

    public function creditIssuances()
    {
        return $this->hasMany(BillingCreditsIssuance::class, 'product_item_plan_id');
    }
}

BillingCreditsThresholdNotification Model

class BillingCreditsThresholdNotification extends Model
{
    const ACTIVITY = [
        'CRON' => 'Cron',                    // Scheduled threshold checks
        'REMOVE_CREDITS' => 'Remove Credits', // Real-time threshold breach
    ];

    protected $fillable = [
        'user_id',
        'subscription_id',
        'subscription_threshold_id',
        'threshold_level',      // 'healthy', 'low', 'depleted'
        'credits',             // Current available credits
        'activity',            // Type of activity that triggered notification
        'resolved'             // Boolean: whether issue has been resolved
    ];
}

Credit Analytics & Reporting

Usage Reports

Controller: BillingCreditsUsageController

public function index(Request $request): JsonResponse
{
    $data = BillingCreditsUsage::query()
        ->join('billing_add_ons_plans', 'billing_credits_usages.product_item_plan_id', 'billing_add_ons_plans.id')
        ->join('billing_subscriptions', 'billing_credits_usages.subscription_id', 'billing_subscriptions.id')
        ->leftJoin('tbl_users', 'billing_credits_usages.created_by', 'tbl_users.id')
        ->filter($request->all())
        ->search($request->all())
        ->select(
            'billing_credits_usages.id',
            'billing_subscriptions.product_name',
            'billing_add_ons_plans.name as plan_name',
            DB::raw('FORMAT(billing_credits_usages.quantity, 2) as quantity'),
            'billing_credits_usages.created_at as sent_at',
            'tbl_users.name as purchased_by'
        )
        ->orderBy('billing_credits_usages.created_at', 'desc')
        ->paginate($request->per_page ?? 50);

    return $this->dashboard_success($data, [
        'columns' => ['id', 'product_name', 'plan_name', 'quantity', 'sent_at', 'purchased_by']
    ]);
}

Issuance Reports

Controller: BillingCreditsIssuanceController

public function index(Request $request): JsonResponse
{
    $data = BillingCreditsIssuance::query()
        ->join('billing_add_ons_plans', 'billing_credits_issuances.product_item_plan_id', 'billing_add_ons_plans.id')
        ->join('billing_subscriptions', 'billing_add_ons_plans.subscription_id', 'billing_subscriptions.id')
        ->leftJoin('tbl_users', 'billing_credits_issuances.created_by', 'tbl_users.id')
        ->filter($request->all())
        ->search($request->all())
        ->select(
            'billing_credits_issuances.id',
            'billing_subscriptions.product_name',
            'billing_add_ons_plans.name as plan_name',
            DB::raw('FORMAT(billing_credits_issuances.quantity, 2) as quantity'),
            'billing_credits_issuances.created_at as sent_at',
            'tbl_users.name as purchased_by'
        )
        ->orderBy('billing_credits_issuances.created_at', 'desc')
        ->paginate($request->per_page ?? 50);

    return $this->dashboard_success($data, [
        'columns' => ['id', 'product_name', 'plan_name', 'quantity', 'sent_at', 'purchased_by']
    ]);
}

Credit Balance Display

Frontend Integration

The credit management system provides real-time balance information to the frontend through dedicated API endpoints and Vue.js components.

Balance API Endpoint:

// Route: /api/billing/subscriptions/add-ons/available-balance
public function getAvailableBalance(Request $request): JsonResponse
{
    $subscription = BillingSubscription::where('id', $request->subscription_id)
        ->where('status_name', 'Active')
        ->first();

    if (!$subscription) {
        return $this->error('Subscription not found or inactive', 404);
    }

    // Get total available credits across all active plans
    $total_credits = BillingAddOnsPlan::where('subscription_id', $subscription->id)
        ->where('status_name', 'Active')
        ->whereNull('deleted_at')
        ->sum('available_quantity');

    // Get breakdown by service type
    $service_breakdown = BillingAddOnsPlan::select(
            'product_name',
            DB::raw('SUM(available_quantity) as available_credits'),
            DB::raw('COUNT(*) as active_plans')
        )
        ->join('billing_subscriptions', 'billing_subscriptions.id', '=', 'billing_add_ons_plans.subscription_id')
        ->where('billing_add_ons_plans.subscription_id', $subscription->id)
        ->where('billing_add_ons_plans.status_name', 'Active')
        ->whereNull('billing_add_ons_plans.deleted_at')
        ->groupBy('product_name')
        ->get();

    return $this->success([
        'total_credits' => $total_credits,
        'service_breakdown' => $service_breakdown,
        'subscription_details' => [
            'id' => $subscription->id,
            'product_name' => $subscription->product_name,
            'status' => $subscription->status_name
        ]
    ]);
}

Key Features Summary

  1. Hold Mechanism: Credits can be held for confirmation before permanent consumption
  2. Threshold Monitoring: Automatic notifications when credits fall below configured thresholds
  3. Service Disabling: Individual services can be disabled via configuration
  4. Comprehensive Logging: All credit operations are logged for audit and reporting
  5. Portal Integration: Seamless integration with external payment portals
  6. Real-time Updates: Both plan and subscription totals are updated in real-time
  7. Notification Deferral: Prevents spam by deferring repeat notifications within time windows

This comprehensive credit management system ensures reliable, scalable, and auditable handling of credits throughout their entire lifecycle while providing robust error handling and recovery mechanisms.