Jump to content

Force customers to upgrade a product instead of buying a new ?


Recommended Posts

I offer a free DNS product that all new users get by default.

I also sell paid DNS packages with more features.

I want to prevent users from ordering a paid DNS package as a separate product while keeping the free one.

Instead, they should only be able to upgrade their existing free DNS product to one of the paid packages.

Is there a way to force users to upgrade (rather than letting them buy the paid DNS package separately)?

Link to comment
Share on other sites

7 hours ago, Mytihost said:

Hello,

 

1. Edit your free DNS product → open the “Upgrades” tab

2. Select your paid DNS product  (this makes them upgrade options).

3. Edit your paid DNS product → check “Hidden” 

Now the paid dns service is hidden and its only available as an upgrade 

Except, this will literally hide the paid DNS service from everyone. This also doesn't actually stop people from ordering the paid product. It just hides it from the website.

The correct approach is to check the cart on checkout. If the paid product exists, then remove it and inform the customer why.
 

Link to comment
Share on other sites

Hello, 

I had a shot at giving this a try. Ive tested on 8.13 seems to be okay. If any one can make improvements then please do feel free to do so 

Description:
This hook enforces an upgrade-only flow between two WHMCS products by PID. It prevents customers from ordering the Paid DNS product (PID 4) directly, allowing access only as an upgrade from the Free DNS product (PID 3). If a user tries to add the paid product to their cart, it’s automatically removed and a popup explains what to do next. Logged-in clients with Free DNS see direct upgrade links, while guests are prompted to log in and order Free DNS first.

How it works:
The hook checks product IDs during cart validation and checkout. If a disallowed PID (e.g. 4) is added outside the official upgrade process, it’s removed and a modal displays the correct next steps.

Change the product ID to match your free dns and your paid dns

 

<?php
/**
 * BEGIN FILE: /includes/hooks/dns-upgrade-enforce-serveronly.php
 *
 * DNS policy with modal + direct Upgrade links (no domain status filtering):
 *  - Blocks purchasing Paid DNS (PID 4) as a NEW order.
 *  - Required flow: Free DNS (PID 3) -> Upgrade/Downgrade -> Paid DNS.
 *  - Removes disallowed items from cart and shows a modal on cart.php.
 *  - If client owns Free DNS, modal lists direct links: upgrade.php?type=package&id={SERVICEID}.
 *  - Blocks again at product-config and checkout.
 *
 * Config:
 *   Free PID       = 3
 *   Paid PIDs      = [4]
 *   Verbose logging= true (set to false after testing)
 */

use WHMCS\Database\Capsule;

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

/* ===== CONFIG ===== */
const DNS_FREE_PID  = 3;
const DNS_PAID_PIDS = [4];
const DNS_VERBOSE   = true; // set to false after testing

/* ===== HELPERS ===== */
function dns_log($msg) {
    if (!DNS_VERBOSE) return;
    try { logActivity('[dns-policy] ' . $msg); } catch (\Throwable $e) {}
}

/** Detect official Upgrade/Downgrade flow */
function dns_cartIsUpgradeFlow(): bool {
    $cart = ($_SESSION['cart'] ?? []);
    $up   = $cart['upgrades'] ?? [];
    if (!is_array($up)) return false;
    foreach ($up as $u) {
        if (!empty($u)) return true;
    }
    return false;
}

/** Does client own Free DNS? (no status filtering) */
function dns_clientOwnsFree(int $uid): bool {
    if ($uid <= 0 || DNS_FREE_PID <= 0) return false;
    $count = Capsule::table('tblhosting')
        ->where('userid', $uid)
        ->where('packageid', DNS_FREE_PID)
        ->count();
    return $count > 0;
}

/** Get the client's Free DNS services (for direct Upgrade links) — no status filtering */
function dns_getFreeDnsServices(int $uid): array {
    if ($uid <= 0 || DNS_FREE_PID <= 0) return [];
    $rows = Capsule::table('tblhosting')
        ->select('id', 'domain', 'domainstatus')
        ->where('userid', $uid)
        ->where('packageid', DNS_FREE_PID)
        ->orderBy('id', 'asc')
        ->get();
    $out = [];
    foreach ($rows as $r) {
        $out[] = [
            'id'     => (int)$r->id,
            'domain' => (string)$r->domain,
            'status' => (string)$r->domainstatus,
        ];
    }
    return $out;
}

/** HTML escape */
function dns_e(string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }

/** Queue modal messages for cart.php */
function dns_queue_modal(string $msg): void {
    if (!isset($_SESSION['dns_policy_modal']) || !is_array($_SESSION['dns_policy_modal'])) {
        $_SESSION['dns_policy_modal'] = [];
    }
    $_SESSION['dns_policy_modal'][] = $msg;
}

/** Remove disallowed Paid DNS items and queue tailored modal content */
function dns_purge_disallowed_from_cart(int $uid): array {
    $removed   = [];
    $isUpgrade = dns_cartIsUpgradeFlow();
    $ownsFree  = $uid > 0 && dns_clientOwnsFree($uid);

    $cart = &$_SESSION['cart'];
    if (!isset($cart['products']) || !is_array($cart['products'])) {
        return $removed;
    }

    foreach ($cart['products'] as $index => $line) {
        $pid = isset($line['pid']) ? (int)$line['pid'] : 0;
        if (!$pid || !in_array($pid, DNS_PAID_PIDS, true)) continue;

        // Allow only if in legit upgrade flow AND client owns Free DNS
        $allowed = ($isUpgrade && $ownsFree);
        if (!$allowed) {
            $removed[] = $pid;
            unset($cart['products'][$index]);
        }
    }

    if ($removed) {
        $cart['products'] = array_values($cart['products']);

        if ($uid <= 0) {
            dns_queue_modal(
                'We removed a Paid DNS item from your cart.<br>'
                . 'Paid DNS cannot be purchased as a new order. '
                . 'Please <a href="clientarea.php"><strong>log in</strong></a>, order <em>Free DNS</em> if needed, then use <a href="upgrade.php"><strong>Upgrade/Downgrade</strong></a>.'
            );
        } elseif ($ownsFree) {
            $services = dns_getFreeDnsServices($uid);
            if (!empty($services)) {
                if (count($services) === 1) {
                    $sid  = $services[0]['id'];
                    $dom  = $services[0]['domain'] !== '' ? ' for <em>'.dns_e($services[0]['domain']).'</em>' : '';
                    $link = 'upgrade.php?type=package&id=' . urlencode((string)$sid);
                    dns_queue_modal(
                        'We removed a Paid DNS item from your cart.<br>'
                        . 'You already have <strong>Free DNS</strong>. '
                        . 'Please use <a href="'.dns_e($link).'"><strong>Upgrade/Downgrade</strong></a>'.$dom.'.'
                    );
                } else {
                    $list = '<ul style="margin:8px 0 0 18px;">';
                    foreach ($services as $s) {
                        $sid  = $s['id'];
                        $dom  = $s['domain'] !== '' ? dns_e($s['domain']) : 'Service #'.(int)$sid;
                        $link = 'upgrade.php?type=package&id=' . urlencode((string)$sid);
                        $list .= '<li><a href="'.dns_e($link).'"><strong>Upgrade</strong></a> for <em>'.dns_e($dom).'</em></li>';
                    }
                    $list .= '</ul>';
                    dns_queue_modal(
                        'We removed a Paid DNS item from your cart.<br>'
                        . 'You already have <strong>Free DNS</strong>. Choose a service to upgrade:'
                        . $list
                    );
                }
            } else {
                // Fallback (should be rare if $ownsFree is true)
                dns_queue_modal(
                    'We removed a Paid DNS item from your cart.<br>'
                    . 'You already have <strong>Free DNS</strong>. '
                    . 'Please use <a href="upgrade.php"><strong>Upgrade/Downgrade</strong></a> from your existing service.'
                );
            }
        } else {
            $orderFree = 'cart.php?a=add&pid=' . urlencode((string)DNS_FREE_PID);
            dns_queue_modal(
                'We removed a Paid DNS item from your cart.<br>'
                . 'Paid DNS isn’t available as a new order. '
                . 'Please <a href="'.$orderFree.'"><strong>order Free DNS</strong></a> first, then use <a href="upgrade.php"><strong>Upgrade/Downgrade</strong></a>.'
            );
        }
    }

    return $removed;
}

/** Return WHMCS-compatible validation error array */
function dns_error(string $message): array {
    return [$message, 'errormessages' => [$message]];
}

/* ========= LAYER 1 — PreCalculateCartTotals: clean cart + queue modal ========= */
add_hook('PreCalculateCartTotals', 1, function($vars) {
    if (DNS_FREE_PID <= 0 || empty(DNS_PAID_PIDS)) return;
    $uid = isset($_SESSION['uid']) ? (int)$_SESSION['uid'] : 0;
    $removed = dns_purge_disallowed_from_cart($uid);
    if ($removed) dns_log('PreCalculateCartTotals: removed paid PIDs '.implode(',', $removed).' [uid='.$uid.']');
});

/* ========= LAYER 1b — Render modal on cart.php if queued ========= */
add_hook('ClientAreaFooterOutput', 1, function() {
    $scriptName = strtolower($_SERVER['SCRIPT_NAME'] ?? '');
    if (strpos($scriptName, 'cart.php') === false) return '';
    if (empty($_SESSION['dns_policy_modal']) || !is_array($_SESSION['dns_policy_modal'])) return '';

    $items = $_SESSION['dns_policy_modal'];
    unset($_SESSION['dns_policy_modal']);

    $content = '';
    foreach ($items as $msg) { $content .= '<p style="margin-bottom:10px;">' . $msg . '</p>'; }

    $html = <<<HTML
<div id="dnsPolicyModal" style="position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;z-index:99999;">
  <div role="dialog" aria-modal="true" aria-labelledby="dnsPolicyTitle" style="background:#fff;max-width:560px;width:92%;border-radius:6px;box-shadow:0 10px 30px rgba(0,0,0,.2);">
    <div style="padding:16px 20px;border-bottom:1px solid #eee;">
      <h4 id="dnsPolicyTitle" style="margin:0;font-size:18px;">Action needed to get Paid DNS</h4>
    </div>
    <div style="padding:18px 20px;line-height:1.5;">{$content}</div>
    <div style="padding:12px 20px;border-top:1px solid #eee;display:flex;gap:8px;justify-content:flex-end;">
      <button id="dnsPolicyClose" type="button" class="btn btn-primary">OK</button>
    </div>
  </div>
</div>
<script>
(function(){
  var modal = document.getElementById('dnsPolicyModal');
  function close(){ if(modal){ modal.remove(); } }
  var btn = document.getElementById('dnsPolicyClose');
  if (btn) btn.addEventListener('click', close);
  if (modal) {
    modal.addEventListener('click', function(e){ if(e.target === modal) close(); });
  }
  document.addEventListener('keydown', function(e){ if(e.key === 'Escape') close(); });
})();
</script>
HTML;

    return $html;
});

/* ========= LAYER 2 — ShoppingCartValidateProductConfig: stop add/config ========= */
add_hook('ShoppingCartValidateProductConfig', 1, function($vars){
    if (DNS_FREE_PID <= 0 || empty(DNS_PAID_PIDS)) return [];

    $pid = isset($vars['pid']) ? (int)$vars['pid'] : 0;
    if (!$pid || !in_array($pid, DNS_PAID_PIDS, true)) return [];

    $uid = isset($_SESSION['uid']) ? (int)$_SESSION['uid'] : 0;

    if (!dns_cartIsUpgradeFlow()) {
        if ($uid <= 0) {
            dns_log("ValidateProductConfig: guest tried to add paid PID {$pid}");
            return dns_error('Paid DNS cannot be purchased as a new order. Please log in, order Free DNS first if needed, then use Upgrade/Downgrade.');
        }
        if (dns_clientOwnsFree($uid)) {
            dns_log("ValidateProductConfig: uid {$uid} owns Free DNS; new paid {$pid} blocked.");
            return dns_error('You already have Free DNS. Please use Upgrade/Downgrade from your existing service.');
        }
        dns_log("ValidateProductConfig: uid {$uid} lacks Free DNS; new paid {$pid} blocked.");
        return dns_error('This product isn’t available as a new order. Please order Free DNS first, then use Upgrade/Downgrade.');
    }

    // In upgrade flow: must own Free DNS
    if ($uid <= 0 || !dns_clientOwnsFree($uid)) {
        dns_log("ValidateProductConfig: upgrade flow without Free DNS (uid={$uid}).");
        return dns_error('To upgrade to this tier, first order Free DNS, then return to Upgrade/Downgrade.');
    }

    dns_log("ValidateProductConfig: allowed (upgrade flow, uid={$uid}).");
    return [];
});

/* ========= LAYER 3 — ShoppingCartValidateCheckout: final hard gate ========= */
add_hook('ShoppingCartValidateCheckout', 1, function($vars){
    if (DNS_FREE_PID <= 0 || empty(DNS_PAID_PIDS)) return [];

    $uid = isset($_SESSION['uid']) ? (int)$_SESSION['uid'] : 0;

    // Allow legit upgrade flow only if user owns Free DNS
    if (dns_cartIsUpgradeFlow()) {
        if ($uid <= 0 || !dns_clientOwnsFree($uid)) {
            dns_log('ValidateCheckout: blocked (upgrade flow without Free DNS/uid).');
            return dns_error('To upgrade to a paid DNS tier, first order Free DNS, then return to Upgrade/Downgrade.');
        }
        dns_log('ValidateCheckout: allowed (upgrade flow + owns Free).');
        return [];
    }

    // Not upgrade: purge and block
    $removed = dns_purge_disallowed_from_cart($uid);
    if (empty($removed)) {
        dns_log('ValidateCheckout: no paid PIDs present.');
        return [];
    }

    if ($uid <= 0) {
        dns_log('ValidateCheckout: guest blocked with paid PIDs.');
        return dns_error('Paid DNS cannot be purchased as a new order. Please log in, order Free DNS first if needed, then use Upgrade/Downgrade.');
    }

    if (dns_clientOwnsFree($uid)) {
        dns_log('ValidateCheckout: uid '.$uid.' owns Free DNS; new paid blocked.');
        return dns_error('You already have Free DNS. Please use Upgrade/Downgrade from your existing service.');
    }

    dns_log('ValidateCheckout: uid '.$uid.' lacks Free DNS; new paid blocked.');
    return dns_error('This product isn’t available as a new order. Please order Free DNS first, then use Upgrade/Downgrade.');
});
/** END FILE */

 

Link to comment
Share on other sites

Join the conversation

You can post now and register later. If you have an account, sign in now to post with your account.

Guest
Reply to this topic...

×   Pasted as rich text.   Paste as plain text instead

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   Your previous content has been restored.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.

  • Recently Browsing   0 members

    • No registered users viewing this page.
×
×
  • Create New...

Important Information

By using this site, you agree to our Terms of Use & Guidelines and understand your posts will initially be pre-moderated