Kemp.rest / Examples / Code / WHMCS module

KEMP.REST / EXAMPLES / CODE

WHMCS module: accounts, SSO, and city billing

Written by Kevin Kemp in PHP and Laravel for AnswerPins. WHMCS, MySQL, cURL, and JSON. The shared API key stays in WHMCS and in the app environment, not in this code.

View the code

WHMCS takes the payment. AnswerPins has to exist the moment that order is active, with the right plan, the cities the customer paid for, and a way into the dashboard that does not ask for a second password. This is a WHMCS server module plus the Laravel API it calls. Buying, suspending, changing the plan, and opening the app are the same service id on both sides.

Type of code

A WHMCS provisioning module and addon, written in PHP for WHMCS, and a Laravel API that receives it. The module runs inside WHMCS. The app runs on its own host. They share a bearer key stored in WHMCS as the server access hash.

Technology

WHMCS module hooks (CreateAccount, ChangePackage, ServiceSingleSignOn), cURL JSON calls, MySQL through WHMCS Capsule against the configurable-option tables, Laravel routes behind hash_equals on the bearer token, a database transaction for the account and user, and Laravel temporary signed URLs. The API key is never written in these files.

Timing

What it does

Problem it solved

An order in WHMCS became a live AnswerPins account, with the cities that were billed, and both the customer and the staff could open the right account without a separate password.

Code

Back to summary · WHMCS server module, client hook, Laravel provisioning API, one-time sign-in, admin addon

// —— whmcs/modules/servers/answerpins/answerpins.php ——

<?php
/**
 * AnswerPins provisioning module for WHMCS.
 *
 * Server setup (Setup > Servers): Hostname = app host (e.g. app.answerpins.com),
 * Access Hash = the app's WHMCS_API_KEY, Secure = on.
 * Product setup: Module = AnswerPins, set the plan and limits below, and add a
 * hidden product custom field named "Onboarding token".
 */

if (!defined('WHMCS')) {
    die('This file cannot be accessed directly');
}

function answerpins_MetaData()
{
    return [
        'DisplayName' => 'AnswerPins',
        'APIVersion' => '1.1',
        'RequiresServer' => true,
        'ServiceSingleSignOnLabel' => 'Open dashboard',
    ];
}

function answerpins_ConfigOptions()
{
    return [
        // Self Managed and Pro Managed limits are set by the app. The fields below are only used for Custom plans.
        'Plan' => ['Type' => 'dropdown', 'Options' => 'Self Managed,Pro Managed,Custom', 'Default' => 'Self Managed'],
        'Custom: locations' => ['Type' => 'text', 'Size' => '5', 'Default' => '', 'Description' => 'Custom plans only'],
        'Custom: services' => ['Type' => 'text', 'Size' => '5', 'Default' => '', 'Description' => 'Custom plans only'],
        'Custom: towns' => ['Type' => 'text', 'Size' => '5', 'Default' => '', 'Description' => 'Custom plans only'],
    ];
}

function answerpins_call(array $params, $action, array $body)
{
    $scheme = !empty($params['serversecure']) ? 'https' : 'http';
    $url = $scheme . '://' . $params['serverhostname'] . '/api/whmcs/' . $action;
    $body['service_id'] = (int) $params['serviceid'];

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => json_encode($body),
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . trim($params['serveraccesshash']),
            'Content-Type: application/json',
            'Accept: application/json',
        ],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 30,
    ]);
    $raw = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $error = curl_error($ch);
    curl_close($ch);

    logModuleCall('answerpins', $action, $body, $raw, null, [$params['serveraccesshash']]);

    if ($raw === false) {
        return ['error' => 'Connection failed: ' . $error];
    }
    $data = json_decode($raw, true);
    if ($status >= 400 || !is_array($data) || ($data['result'] ?? '') !== 'success') {
        return ['error' => 'HTTP ' . $status . ': ' . ($data['message'] ?? $data['error'] ?? substr((string) $raw, 0, 200))];
    }

    return $data;
}

/** Plan names shown in WHMCS mapped to the app's plan keys. The $1 trial product is Self Managed too. */
function answerpins_plan(array $params)
{
    $map = ['self managed' => 'owner', 'pro managed' => 'managed', 'custom' => 'custom', 'owner' => 'owner', 'managed' => 'managed'];

    return $map[strtolower(trim((string) $params['configoption1']))] ?? 'owner';
}

/**
 * Custom plans send their limits; Self Managed and Pro Managed use the app's own limits for that plan.
 * Cities come from the configurable option (dropdown or quantity). The option name can be "Cities"
 * or "Add a city", including the "|invoice text" WHMCS appends. "2" and "2 cities" both count as 2.
 */
function answerpins_limits(array $params)
{
    $limits = [];
    if (answerpins_plan($params) === 'custom') {
        $limits = array_filter([
            'locations' => (int) $params['configoption2'],
            'services' => (int) $params['configoption3'],
            'towns' => (int) $params['configoption4'],
        ]);
    }
    $cities = answerpins_cities_in($params['configoptions'] ?? []);
    if ($cities === null) {
        $cities = answerpins_cities_on_service((int) ($params['serviceid'] ?? 0));
    }
    if ($cities !== null) {
        $limits['cities'] = $cities;
    }

    return $limits === [] ? null : $limits;
}

/** The chosen city count, from an option whose name mentions a city. Null when that option is not on the product. */
function answerpins_cities_in(array $options): ?int
{
    foreach ($options as $name => $value) {
        $short = trim(explode('|', (string) $name, 2)[0]);
        if (!preg_match('/\bcit(?:y|ies)\b/i', $short)) {
            continue;
        }
        $chosen = trim(explode('|', (string) $value, 2)[0]);
        if (preg_match('/\d+/', $chosen, $m)) {
            return (int) $m[0];
        }
        if (preg_match('/^(yes|on|true)$/i', $chosen)) {
            return 1;
        }

        return 0;
    }

    return null;
}

/**
 * The city count stored on the service. Used when the module params omit the option name.
 * Quantity options store the count in qty. Dropdown options store it in the choice name ("2" or "2 cities").
 */
function answerpins_cities_on_service(int $serviceId): ?int
{
    if ($serviceId < 1 || !class_exists('WHMCS\\Database\\Capsule')) {
        return null;
    }
    try {
        $rows = \WHMCS\Database\Capsule::table('tblhostingconfigoptions')
            ->join('tblproductconfigoptions', 'tblproductconfigoptions.id', '=', 'tblhostingconfigoptions.configid')
            ->leftJoin('tblproductconfigoptionssub', 'tblproductconfigoptionssub.id', '=', 'tblhostingconfigoptions.optionid')
            ->where('tblhostingconfigoptions.relid', $serviceId)
            ->get(['tblproductconfigoptions.optionname', 'tblproductconfigoptions.optiontype', 'tblhostingconfigoptions.qty', 'tblproductconfigoptionssub.optionname as choice']);
    } catch (\Throwable $e) {
        return null;
    }
    foreach ($rows as $row) {
        $parsed = answerpins_cities_in([(string) $row->optionname => (int) $row->optiontype === 4 ? (string) $row->qty : (string) $row->choice]);
        if ($parsed !== null) {
            return $parsed;
        }
    }

    return null;
}

function answerpins_result($data)
{
    return isset($data['error']) ? $data['error'] : 'success';
}

function answerpins_CreateAccount(array $params)
{
    $client = $params['clientsdetails'];

    return answerpins_result(answerpins_call($params, 'create', [
        'client_id' => (int) $params['userid'],
        'email' => $client['email'],
        'name' => trim($client['firstname'] . ' ' . $client['lastname']),
        'plan' => answerpins_plan($params),
        'limits' => answerpins_limits($params),
        'onboarding_token' => $params['customfields']['Onboarding token'] ?? null ?: null,
    ]));
}

function answerpins_SuspendAccount(array $params)
{
    return answerpins_result(answerpins_call($params, 'suspend', ['reason' => $params['suspendreason'] ?? null]));
}

function answerpins_UnsuspendAccount(array $params)
{
    return answerpins_result(answerpins_call($params, 'unsuspend', []));
}

function answerpins_TerminateAccount(array $params)
{
    return answerpins_result(answerpins_call($params, 'terminate', []));
}

function answerpins_ChangePackage(array $params)
{
    return answerpins_result(answerpins_call($params, 'change-plan', [
        'plan' => answerpins_plan($params),
        'limits' => answerpins_limits($params),
    ]));
}

/** The service's page in the client area: one big button into the dashboard. */
function answerpins_ClientArea(array $params)
{
    return [
        'templatefile' => 'clientarea',
        'vars' => ['ssoUrl' => 'clientarea.php?action=productdetails&id=' . (int) $params['serviceid'] . '&dosinglesignon=1'],
    ];
}

function answerpins_ServiceSingleSignOn(array $params)
{
    $data = answerpins_call($params, 'sso', ['client_id' => (int) $params['userid']]);
    if (isset($data['error'])) {
        return ['success' => false, 'errorMsg' => $data['error']];
    }

    return ['success' => true, 'redirectTo' => $data['redirect_url']];
}


// —— whmcs/modules/servers/answerpins/hooks.php ——

<?php
/**
 * AnswerPins in the WHMCS client area: a panel at the top of the client's home page with a button that signs
 * them straight into their AnswerPins dashboard, so the order never ends in a dead end.
 * WHMCS loads this file by itself from the module folder; nothing to switch on.
 */

use WHMCS\Database\Capsule;

if (!defined('WHMCS')) {
    die('This file cannot be accessed directly');
}

/** The client's active AnswerPins services, newest first. */
function answerpins_client_services($clientId)
{
    return Capsule::table('tblhosting')
        ->join('tblproducts', 'tblproducts.id', '=', 'tblhosting.packageid')
        ->where('tblhosting.userid', $clientId)
        ->where('tblproducts.servertype', 'answerpins')
        ->whereIn('tblhosting.domainstatus', ['Active', 'Pending'])
        ->orderBy('tblhosting.id', 'desc')
        ->get(['tblhosting.id', 'tblhosting.domainstatus', 'tblproducts.name']);
}

add_hook('ClientAreaHomepagePanels', 1, function ($panels) {
    $client = Menu::context('client');
    if (!$client) {
        return;
    }
    $services = answerpins_client_services($client->id);
    if (!count($services)) {
        return;
    }
    $open = $services[0];
    $url = 'clientarea.php?action=productdetails&id=' . (int) $open->id . '&dosinglesignon=1';
    $body = $open->domainstatus === 'Active'
        ? '<p style="margin:0 0 12px;font-size:16px">Your dashboard shows where you rank on Google Maps, in Google results and in AI answers, and what to fix next.</p>'
        : '<p style="margin:0 0 12px;font-size:16px">Your AnswerPins account is being set up. It\'s ready in a minute or two.</p>';
    $body .= '<p style="margin:0"><a class="btn btn-primary btn-lg" href="' . htmlspecialchars($url) . '">Open my AnswerPins dashboard &rarr;</a></p>';

    $panels->addChild('answerpins', [
        'label' => 'AnswerPins',
        'icon' => 'fa-map-marker-alt',
        'order' => 1,
        'bodyHtml' => '<div style="padding:16px">' . $body . '</div>',
    ]);
});


// —— whmcs/modules/servers/answerpins/clientarea.tpl ——

<div style="text-align:center;padding:24px 16px">
    <p style="font-size:18px;margin:0 0 16px">Everything for this plan is in your AnswerPins dashboard.</p>
    <a class="btn btn-primary btn-lg" href="{$ssoUrl}">Open my AnswerPins dashboard &rarr;</a>
</div>


// —— routes/api.php ——

<?php

use App\Http\Controllers\Whmcs\AdminSsoController;
use App\Http\Controllers\Whmcs\ProvisioningController;
use App\Http\Middleware\VerifyWhmcsKey;
use Illuminate\Support\Facades\Route;

Route::middleware(VerifyWhmcsKey::class)->prefix('whmcs')->controller(ProvisioningController::class)->group(function () {
    Route::post('create', 'create');
    Route::post('suspend', 'suspend');
    Route::post('unsuspend', 'unsuspend');
    Route::post('terminate', 'terminate');
    Route::post('change-plan', 'changePlan');
    Route::post('sso', 'sso');
});
Route::post('whmcs/admin-sso', AdminSsoController::class)->middleware(VerifyWhmcsKey::class);


// —— app/Http/Middleware/VerifyWhmcsKey.php ——

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class VerifyWhmcsKey
{
    public function handle(Request $request, Closure $next): Response
    {
        $key = (string) config('answerpins.whmcs.api_key');

        if ($key === '' || ! hash_equals($key, (string) $request->bearerToken())) {
            return response()->json(['error' => 'Unauthorized'], 401);
        }

        return $next($request);
    }
}


// —— app/Http/Controllers/Whmcs/ProvisioningController.php ——

<?php

namespace App\Http\Controllers\Whmcs;

use App\Http\Controllers\Controller;
use App\Jobs\StartTrialClock;
use App\Mail\NewSignupAlert;
use App\Models\Account;
use App\Models\Business;
use App\Models\User;
use App\Services\ProfileAudit;
use App\Services\QuestionGenerator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;

class ProvisioningController extends Controller
{
    /** WHMCS may send the plan by its shown name; the app stores its own keys. */
    private const PLAN_KEYS = ['self managed' => 'owner', 'pro managed' => 'managed', 'custom' => 'custom'];

    private function planKey(string $plan): string
    {
        return self::PLAN_KEYS[strtolower(trim($plan))] ?? $plan;
    }

    public function create(Request $request): JsonResponse
    {
        $data = $request->validate([
            'service_id' => 'required|integer',
            'client_id' => 'required|integer',
            'email' => 'required|email',
            'name' => 'required|string',
            'plan' => 'required|string',
            'limits' => 'nullable|array',
            'onboarding_token' => 'nullable|uuid',
        ]);

        $account = DB::transaction(function () use ($data) {
            $plan = $this->planKey($data['plan']);
            $existing = Account::where('whmcs_service_id', $data['service_id'])->first();
            $account = Account::updateOrCreate(
                ['whmcs_service_id' => $data['service_id']],
                [
                    'whmcs_client_id' => $data['client_id'],
                    'plan' => $plan,
                    'limits' => $this->storedLimits($plan, $existing?->limits, $data['limits'] ?? null),
                    'status' => 'active',
                    'suspend_reason' => null,
                ],
            );

            $user = User::where('whmcs_client_id', $data['client_id'])->first()
                ?? User::where('email', $data['email'])->first()
                ?? new User;
            // A second service for this client gets its own account. Buying it must not move the login off the one they already use.
            $keep = $user->exists
                && $user->account_id
                && (int) $user->account_id !== (int) $account->id
                && (int) $user->whmcs_client_id === (int) $data['client_id'];
            $user->fill([
                'name' => $data['name'],
                'email' => $data['email'],
                'whmcs_client_id' => $data['client_id'],
                'account_id' => $keep ? $user->account_id : $account->id,
            ])->save();

            if (! empty($data['onboarding_token'])) {
                Business::where('onboarding_token', $data['onboarding_token'])
                    ->whereNull('account_id')
                    ->update(['account_id' => $account->id]);
            }

            return $account;
        });

        foreach ($account->businesses as $business) {
            app(QuestionGenerator::class)->sync($business);
            app(ProfileAudit::class)->run($business);
        }
        if ($account->wasRecentlyCreated) {
            $this->alertStaff($account, $data);
            StartTrialClock::dispatch($data['service_id']); // $1 trial: bill the monthly price on day 3
        }

        return response()->json(['result' => 'success', 'account_id' => $account->id]);
    }

    public function suspend(Request $request): JsonResponse
    {
        $account = $this->account($request);
        $account->update(['status' => 'suspended', 'suspend_reason' => $request->input('reason')]);

        return response()->json(['result' => 'success']);
    }

    public function unsuspend(Request $request): JsonResponse
    {
        $account = $this->account($request);
        $account->update(['status' => 'active', 'suspend_reason' => null]);

        return response()->json(['result' => 'success']);
    }

    public function terminate(Request $request): JsonResponse
    {
        $account = $this->account($request);
        $account->update(['status' => 'terminated']);

        return response()->json(['result' => 'success']);
    }

    public function changePlan(Request $request): JsonResponse
    {
        $data = $request->validate(['plan' => 'required|string', 'limits' => 'nullable|array']);
        $account = $this->account($request);
        $data['plan'] = $this->planKey($data['plan']);
        // A city purchase sends only the cities count. Keep the services and towns already on the account.
        $account->update(['plan' => $data['plan'], 'limits' => $this->storedLimits($data['plan'], $account->limits, $data['limits'] ?? null)]);

        // An upgrade switches on the services and towns that were saved but not tracked; a downgrade trims to the new limits.
        $limits = ($account->limits ?? []) + (config('answerpins.plans.'.$data['plan'].'.limits') ?? []);
        foreach ($account->businesses as $business) {
            foreach (['services', 'towns'] as $kind) {
                if (! isset($limits[$kind])) {
                    continue;
                }
                $keep = $business->$kind()->orderByDesc('tracked')->orderBy('id')->limit($limits[$kind])->pluck('id');
                $business->$kind()->update(['tracked' => false]);
                $business->$kind()->whereIn('id', $keep)->update(['tracked' => true]);
            }
            app(QuestionGenerator::class)->sync($business->fresh());
        }

        return response()->json(['result' => 'success']);
    }

    /**
     * Returns a one-time sign-in link, valid for 60 seconds, for "Open dashboard" in WHMCS.
     */
    public function sso(Request $request): JsonResponse
    {
        $account = $this->account($request);
        $clientId = $request->integer('client_id') ?: (int) $account->whmcs_client_id;
        $user = $account->users()->when($request->filled('client_id'), fn ($q) => $q->where('whmcs_client_id', $request->integer('client_id')))->first()
            ?? User::where('whmcs_client_id', $clientId)->first()
            ?? $account->users()->firstOrFail();
        // "Open dashboard" for this service opens this service. The purchase itself does not move the login.
        if ((int) $user->whmcs_client_id === (int) $account->whmcs_client_id && (int) $user->account_id !== (int) $account->id) {
            $user->update(['account_id' => $account->id]);
        }

        $url = URL::temporarySignedRoute('sso.login', now()->addSeconds(60), [
            'user' => $user->id,
            'nonce' => bin2hex(random_bytes(16)),
        ]);

        return response()->json(['result' => 'success', 'redirect_url' => $url]);
    }

    /**
     * Emails PressCare about the new signup. A mail failure must not fail the WHMCS order, so it is only logged.
     */
    private function alertStaff(Account $account, array $data): void
    {
        $to = config('answerpins.alerts_email');
        if (! $to) {
            return;
        }
        try {
            $business = $account->businesses()->with('services', 'towns', 'facts')->first();
            Mail::to($to)->send(new NewSignupAlert($account, $business, $data['name'], $data['email']));
        } catch (\Throwable $e) {
            Log::error('Signup alert email failed', ['account_id' => $account->id, 'error' => $e->getMessage()]);
        }
    }

    /**
     * Plan services and towns fill in anything the payload leaves out. Keys the payload does send, including cities: 0, win.
     * A later create or plan change therefore cannot wipe a city the customer already bought.
     */
    private function storedLimits(string $plan, ?array $current, ?array $incoming): array
    {
        $planLimits = config('answerpins.plans.'.$plan.'.limits') ?? [];
        $base = array_merge($current ?? [], $planLimits);
        if (is_array($incoming)) {
            $base = array_merge($base, $incoming);
        }

        return $base !== [] ? $base : $planLimits;
    }

    private function account(Request $request): Account
    {
        $request->validate(['service_id' => 'required|integer']);

        return Account::where('whmcs_service_id', $request->integer('service_id'))->firstOrFail();
    }
}


// —— app/Jobs/StartTrialClock.php ——

<?php

namespace App\Jobs;

use App\Services\Whmcs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Carbon;

/**
 * The $1 trial: WHMCS has no trial period, so a new service would next bill a month out. For the trial product,
 * move the next due date to 3 days after sign-up, so the full monthly price is charged on day 3.
 */
class StartTrialClock implements ShouldQueue
{
    use Queueable;

    public const DAYS = 3;

    public int $tries = 5;

    public array $backoff = [60, 300, 900, 3600];

    public function __construct(public int $serviceId) {}

    public function handle(Whmcs $whmcs): void
    {
        $trialPid = (int) config('answerpins.plans.owner.trial_pid');
        if (! $trialPid || ! $whmcs->configured()) {
            return;
        }
        $service = Whmcs::items($whmcs->call('GetClientsProducts', ['serviceid' => $this->serviceId]), 'products', 'product')[0] ?? null;
        if (! $service || (int) $service['pid'] !== $trialPid) {
            return;
        }
        $due = Carbon::parse($service['regdate'])->addDays(self::DAYS)->toDateString();
        if (($service['nextduedate'] ?? null) !== $due) {
            $whmcs->call('UpdateClientProduct', ['serviceid' => $this->serviceId, 'nextduedate' => $due]);
        }
    }
}


// —— app/Http/Controllers/Auth/WhmcsLoginController.php — the one-time sign-in ——

<?php

namespace App\Http\Controllers\Auth;

use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;

class WhmcsLoginController extends Controller
{
    /** "Open dashboard" link from WHMCS: signed, expires in 60 seconds, usable once. */
    public function sso(Request $request, User $user, string $nonce): RedirectResponse
    {
        if (! Cache::add('sso-nonce:'.$nonce, true, 600)) {
            return redirect()->route('login'); // link already used: sign in the normal way instead
        }

        Auth::login($user);
        $request->session()->regenerate();
        $request->session()->put('auth_via', 'sso');

        return redirect()->route($user->isAdmin() ? 'admin' : 'dashboard');
    }
}


// —— whmcs/modules/addons/answerpins_admin/answerpins_admin.php ——

<?php
/**
 * AnswerPins Admin: signs a logged-in WHMCS admin straight into AnswerPins Admin.
 *
 * This file must live on the WHMCS server (not only in the AnswerPins git app folder):
 *   modules/addons/answerpins_admin/answerpins_admin.php
 * Then: System Settings → Addon Modules → activate, allow the admin role, Save.
 * It uses the AnswerPins server hostname + Access Hash when set (same as provisioning).
 */

use WHMCS\Database\Capsule;

if (!defined('WHMCS')) {
    die('This file cannot be accessed directly');
}

function answerpins_admin_config()
{
    return [
        'name' => 'AnswerPins Admin',
        'description' => 'Opens AnswerPins Admin, signed in as the WHMCS admin who clicks it.',
        'version' => '1.2',
        'author' => 'PressCare',
        'fields' => [
            'app_url' => ['FriendlyName' => 'AnswerPins address', 'Type' => 'text', 'Size' => '60', 'Default' => 'https://app.answerpins.com', 'Description' => 'Optional if an AnswerPins server already exists.'],
            'api_key' => ['FriendlyName' => 'API key', 'Type' => 'password', 'Size' => '60', 'Description' => 'Optional if the AnswerPins server Access Hash is set. Same value as WHMCS_API_KEY.'],
        ],
    ];
}

function answerpins_admin_output($vars)
{
    $admin = answerpins_admin_who();
    if (!$admin || trim((string) ($admin->username ?? '')) === '') {
        echo '<div class="alert alert-danger">Sign in to WHMCS admin first.</div>';
        return;
    }

    $server = Capsule::table('tblservers')->where('type', 'answerpins')->where('disabled', 0)->orderBy('id')->first();
    $addonUrl = rtrim((string) ($vars['app_url'] ?? ''), '/');
    $addonKey = trim((string) ($vars['api_key'] ?? ''));
    $serverUrl = '';
    $serverKey = '';
    if ($server) {
        $secure = (string) ($server->secure ?? '');
        $scheme = ($secure === '' || $secure === 'off' || $secure === '0') ? 'http' : 'https';
        $serverUrl = $scheme . '://' . $server->hostname;
        $serverKey = trim(preg_replace('/\s+/', '', (string) $server->accesshash));
    }
    // Prefer the server module credentials: those already provision accounts. Addon password fields
    // are often left blank or stored encrypted, which used to send a bad Bearer token.
    $appUrl = $serverUrl !== '' ? $serverUrl : $addonUrl;
    $apiKey = $serverKey !== '' ? $serverKey : $addonKey;
    if ($appUrl === '' || $apiKey === '') {
        echo '<div class="alert alert-danger">AnswerPins Admin: add an AnswerPins server (Setup → Products/Services → Servers) with hostname app.answerpins.com and the Access Hash, or fill the address and API key on this addon and Save.</div>';
        return;
    }

    $payload = [
        'username' => $admin->username,
        'name' => trim(($admin->firstname ?? '') . ' ' . ($admin->lastname ?? '')),
        'email' => ($admin->email ?? '') !== '' ? $admin->email : null,
    ];

    $attempt = answerpins_admin_sso($appUrl, $apiKey, $payload);
    if (($attempt['status'] ?? 0) === 401 && $addonKey !== '' && $addonKey !== $apiKey) {
        $attempt = answerpins_admin_sso($appUrl, $addonKey, $payload);
    }
    if (empty($attempt['redirect']) && $addonUrl !== '' && $addonUrl !== $appUrl) {
        $retryKey = $serverKey !== '' ? $serverKey : $addonKey;
        $retry = answerpins_admin_sso($addonUrl, $retryKey, $payload);
        if (!empty($retry['redirect']) || (($retry['status'] ?? 0) >= 400 && ($attempt['status'] ?? 0) < 400)) {
            $attempt = $retry;
        }
    }

    if (!empty($attempt['redirect'])) {
        $url = htmlspecialchars($attempt['redirect'], ENT_QUOTES);
        echo '<p>Opening AnswerPins Admin…</p><script>window.location.href = ' . json_encode($attempt['redirect']) . ';</script>'
            . '<p><a class="btn btn-primary" href="' . $url . '">Open AnswerPins Admin</a></p>';
        return;
    }

    echo '<div class="alert alert-danger">AnswerPins Admin: ' . htmlspecialchars((string) $attempt['error'], ENT_QUOTES)
        . '</div><p class="text-muted">WHMCS user: ' . htmlspecialchars((string) $admin->username, ENT_QUOTES) . '</p>';
}

function answerpins_admin_who()
{
    $id = 0;
    if (class_exists('\WHMCS\Authentication\CurrentUser')) {
        $admin = (new \WHMCS\Authentication\CurrentUser)->admin();
        $id = $admin ? (int) $admin->id : 0;
    }
    if (!$id) {
        $id = (int) ($_SESSION['adminid'] ?? 0);
    }
    if (!$id && class_exists('\WHMCS\Session')) {
        $id = (int) \WHMCS\Session::get('adminid');
    }

    return $id ? Capsule::table('tbladmins')->where('id', $id)->first() : null;
}

function answerpins_admin_sso($appUrl, $apiKey, array $payload)
{
    $url = rtrim($appUrl, '/') . '/api/whmcs/admin-sso';
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 20,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/json',
            'Accept: application/json',
            'Authorization: Bearer ' . $apiKey,
        ],
        CURLOPT_POSTFIELDS => json_encode($payload),
    ]);
    $raw = curl_exec($ch);
    $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $error = curl_error($ch);
    curl_close($ch);
    $data = json_decode((string) $raw, true);

    if (function_exists('logModuleCall')) {
        logModuleCall('answerpins_admin', 'admin-sso', ['url' => $url, 'username' => $payload['username'] ?? ''], $raw !== false ? $raw : $error, null, [$apiKey]);
    }

    if (!empty($data['redirect_url'])) {
        return ['redirect' => $data['redirect_url'], 'status' => $status, 'curl' => '', 'error' => ''];
    }

    $message = $data['error'] ?? $data['message'] ?? ($error ?: ($raw === false ? 'Could not reach AnswerPins at ' . $url : substr((string) $raw, 0, 300)));
    if ($status > 0 && $status !== 200) {
        $message .= ' (HTTP ' . $status . ')';
    }

    return ['redirect' => '', 'status' => $status, 'curl' => $error, 'error' => $message];
}


// —— app/Http/Controllers/Whmcs/AdminSsoController.php ——

<?php

namespace App\Http\Controllers\Whmcs;

use App\Http\Controllers\Controller;
use App\Models\AdminMember;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;

/**
 * The AnswerPins Admin link in WHMCS admin: WHMCS vouches for the signed-in admin (with the shared API key),
 * and gets back a one-time sign-in link, valid for 60 seconds. Only usernames in config answerpins.admins get one.
 */
class AdminSsoController extends Controller
{
    public function __invoke(Request $request): JsonResponse
    {
        if ($request->input('email') === '') {
            $request->merge(['email' => null]);
        }

        $data = $request->validate(['username' => 'required|string|max:100', 'name' => 'nullable|string|max:200', 'email' => 'nullable|email|max:190']);
        $username = strtolower(trim($data['username']));
        $email = isset($data['email']) ? strtolower(trim($data['email'])) : null;
        // WHMCS already decided this admin may open the addon (API key + addon access). Don't 403
        // just because ANSWERPINS_ADMIN_OWNERS was never set on the server.
        if (! User::roleFor($username) && ! User::roleForEmail($email)) {
            AdminMember::firstOrCreate(['username' => $username], ['role' => 'owner']);
        }

        $user = $this->userFor($username, $email, $data['name'] ?? null);
        $user->update(['admin_seen_at' => now(), 'name' => ($data['name'] ?? '') !== '' ? $data['name'] : $user->name]);

        return response()->json(['result' => 'success', 'redirect_url' => URL::temporarySignedRoute('sso.login', now()->addMinutes(5),
            ['user' => $user->id, 'nonce' => bin2hex(random_bytes(16))])]);
    }

    /** Prefer the WHMCS username row; if the admin's real email already has an app login, use that instead of a duplicate. */
    private function userFor(string $username, ?string $email, ?string $name): User
    {
        $byUser = User::firstWhere('whmcs_admin', $username);
        $byEmail = $email ? User::where('email', $email)->first() : null;

        if ($byUser && $byEmail && $byUser->id !== $byEmail->id) {
            $byUser->update(['whmcs_admin' => null]);
            $byEmail->update(['whmcs_admin' => $username]);

            return $byEmail;
        }
        if ($byUser) {
            if ($email && $byUser->email !== $email && ! User::where('email', $email)->exists()) {
                $byUser->update(['email' => $email]);
            }

            return $byUser;
        }
        if ($byEmail) {
            $byEmail->update(['whmcs_admin' => $username]);

            return $byEmail;
        }

        return User::create([
            'name' => $name ?: $username,
            'email' => $email ?: $username.'@whmcs-admin.answerpins',
            'password' => Str::random(40),
            'whmcs_admin' => $username,
        ]);
    }
}