Writing a Fraud Module

5 views Markdown

A Fraud module inspects an order attempt before anything is written and answers one question: may it proceed.

Overview

A fraud module extends FraudModule and lives in coremio/modules/Fraud/{Name}/{Name}.php. Two ship, one per shape: WFraud scores locally, MaxMind forwards the attempt to a service.

The gate runs once, from the checkout, before an order, invoice or service exists.

coremio/operations/ClientCheckout.php
// Nothing has been persisted at this point: a blocked attempt leaves no order,
// no invoice and no service behind.
if ($fraudError = \FraudModule::run_checks($this->fraud_payload($pricing, $pmethod, (float) $taxCalc['total'], $member)))
    throw new \Exception($fraudError);

Two properties of that gate shape your module:

  • A throwing module counts as a pass, logged as a warning.
  • Blocking is a returned false plus a message. No score, no review state, no queue.

Prerequisites

  • Know what the payload contains; anything else you fetch yourself.
  • A provider account if you score remotely; nothing extra if you score locally.
  • The module skeleton: see Module Anatomy.
  • A test order you can place repeatedly. A rule that blocks too much shows only in the records table.

Structure

coremio/modules/Fraud/Acme/
Acme.php        namespace WISECP\Modules\Fraud; class Acme extends FraudModule
config.php      ['meta' => [...], 'status' => false, 'settings' => []]
lang/en.php
lang/tr.php
logo.png
status is a top level key, not part of meta

run_checks() skips a module unless $row['config']['status'] is true, and the settings screen writes it there. Under meta it saves cleanly and is never called.

You do not build the settings screen; the base assembles it. Fields are disabled while the status switch is off.

Walkthrough

Declare the Class

Acme.php
<?php
namespace WISECP\Modules\Fraud;

// The file is namespaced, so every global class must be imported or prefixed
// with a backslash. An unqualified name resolves inside this namespace and
// fatals at runtime, which the linter will not catch.
use FraudModule;
use Language;
use User;
use UserManager;

class Acme extends FraudModule
{
    public function check($params = []): bool
    {
        return true;
    }
}

Declare the Settings Fields

fields() returns field descriptors. Keys: type, name, description, value, plus checked for a switch and options for a dropdown. Current values come from $this->config['settings'].

Reject Bad Credentials at Save Time

Declare save_fields() and the save runs it before persisting. Declare activate() and deactivate() if switching on has to do real work.

Write the Rule

Read the payload and decide. On a block, in this order: set $this->error to a translated sentence, call insert_record(), return false.

Verify Both Directions

  1. Place an order that should pass. It completes and the records table stays empty.
  2. Place one that should be blocked. The checkout shows your message; nothing is created.
  3. Break the provider with a wrong key. The order still completes and the log carries a warning naming your module.

Reference

What the Base Class Gives You

FraudModule signatures
// The gate. Called by the checkout, never by a module.
public static function run_checks(array $params = []): string;   // '' = proceed, else the message

// Records
public function insert_record($user_id = 0, $message = '', $ip = '');
public function records($rCount = false, $filters = [], $orders = [], $start = 0, $end = -1);
public function record_table(): WISECP\Components\Table|string;

// Settings screen, assembled for you
public function page_settings(): string;
public function save_config($data = []): int|bool;

// Admin controllers, reached as operation=module_controller&controller={name}
public function controller_records(): string;
public function controller_save_settings(): array;
public function controller_clear_records(): array;

// Public state
public ?string $error;     // the message the client sees when you return false
public ?array  $config;    // ['status' => bool, 'settings' => [...], 'meta' => [...]]
public ?array  $lang;
public ?array  $user;      // the logged-in member, resolved in the constructor
public ?array  $admin;
public ?string $dir;
public string  $url;       // note: not nullable, unlike its neighbours
public ?string $area_link;
run_checks() Walks every module whose config['status'] is true, in registry order, skips any without check, returns on the first false. An empty string means proceed.
insert_record() Writes one row to fraud_detected_records with your module name, the user id, your reason and the IP. Omit the IP and the request IP is used.
records() Reads them back, scoped to your module and joined to the customer.
page_settings() Builds the settings screen: status switch, your fields, the records tab, plus the tabs setConfigureTab() adds. Use the callback rather than overriding it.
$this->error Blocking with an empty error still blocks: the gate falls back to website/checkout/error-fraud with your module name.

What You Declare

one required, five optional
// REQUIRED. true = let it through, false = block (with $this->error set).
public function check($params = []): bool;

// OPTIONAL
public function fields(): array;                              // settings form descriptors
public function save_fields($fields = []): array;             // validate before persisting
public function activate(): bool;                             // switching the module on
public function deactivate(): bool;                           // switching it off
public function setConfigureTab(\WISECP\Components\Tab $obj): void;   // extra settings tabs
save_fields returns the fields, or an error envelope

On success return the (possibly cleaned) field array; it becomes config['settings']. On failure return ['status' => 'error', 'message' => '...'] and the save throws.

What Is Inside $params

Built by the checkout immediately before the gate; it is the complete input.

user_data id, email, name, surname, full_name, phone, country, blacklist, company_name, identity, gsm_cc, gsm_number, ip, user_agent, plus address. The mobile number is gsm_number, not gsm.
user_data.address address, city, country_code, zipcode. The country code is resolved from the country id when the form sent only that.
a guest attempt user_data.id is 0 and the identity comes from the posted form, so only email, name, surname and full_name are filled. Profile fields are absent, not empty.
currency The currency id. Resolve it with Money::Currency() when the provider wants the ISO code.
total Float, in that currency, tax and fees included.
pmethod The payment module name, for example PayTR. MaxMind maps it to its own vocabulary through a payment-gateways table.
items The cart rows, as priced.
discounts ['items' => ['coupon' => [['name' => 'CODE'], ...]]]. Nested three deep, absent when no coupon was used.

The Two Shapes

AspectLocal scoringRemote scoring
ReferenceWFraudMaxMind
RulesBlacklist, IP country against billing country, proxy or VPNOne provider score against a threshold
SettingsA switch per ruleCredentials, service tier, risk score, provider toggles
CredentialsNoneRequired, validated in save_fields()
Cost per orderZeroOne billable API call
action:module.fraud_settings_saved Runs after a fraud module's settings are written, with the module name and the config.

Example

A rule with a switch, a threshold and a record. The field key is the contract: fields() declares it, save_fields() validates it, check() reads it.

Acme.php, the settings
public function fields(): array
{
    $settings = $this->config['settings'] ?? [];

    return [
        'api-key' => [
            'wrap_width' => 100,
            'type'       => 'text',
            'name'       => $this->lang['api-key'] ?? 'API Key',
            'value'      => $settings['api-key'] ?? '',
        ],
        'risk-score' => [
            'wrap_width'  => 100,
            'width'       => 15,
            'type'        => 'number',
            'name'        => $this->lang['risk-score'] ?? 'Risk Score',
            'description' => $this->lang['risk-score-desc'] ?? '',
            'value'       => $settings['risk-score'] ?? '20',
        ],
        'block-country-mismatch' => [
            'wrap_width' => 100,
            'type'       => 'approval',      // a switch: 'value' is what a ticked box submits
            'name'       => $this->lang['country-mismatch'] ?? 'Block country mismatch',
            'value'      => 1,
            'checked'    => $settings['block-country-mismatch'] ?? false,
        ],
    ];
}

public function save_fields($fields = []): array
{
    // Refusing here is how the module declines to be switched on half-configured.
    if (\Validation::isEmpty($fields['api-key'] ?? ''))
        return ['status' => "error", 'message' => $this->lang['error-is-empty'] ?? 'API key is required.'];

    return $fields;
}
Acme.php, the verdict
public function check($params = []): bool
{
    $settings = $this->config['settings'] ?? [];
    $user     = (array) ($params['user_data'] ?? $this->user ?? []);
    $ip       = (string) ($user['ip'] ?? '');
    $uid      = (int) ($user['id'] ?? 0);          // 0 on a guest attempt

    // Each rule is independently switchable, so read the flag, not the value.
    if ((int) ($settings['block-country-mismatch'] ?? 0) === 1) {
        $billing = strtoupper((string) ($user['address']['country_code'] ?? ''));
        $origin  = strtoupper((string) (UserManager::ip_info($ip)['countryCode'] ?? ''));

        // Only a CONFIDENT mismatch blocks. An unresolved IP or a missing
        // billing country would otherwise turn every thin profile into a
        // false positive, and a false positive here is a lost sale.
        if ($billing !== '' && $origin !== '' && $billing !== $origin) {
            // A core string exists for this one; WFraud uses the same key.
            $this->error = Language::gc("website/checkout/error-fraud-country");
            $this->insert_record($uid, "IP country ({$origin}) does not match billing country ({$billing})", $ip);

            return false;
        }
    }

    // A remote score. Any failure here must NOT block: the gate treats a thrown
    // exception as a pass, and this early return does the same for a bad answer.
    $score = $this->remote_score($params);
    if ($score === null) return true;

    if ($score >= (int) ($settings['risk-score'] ?? 20)) {
        // Your own wording belongs in YOUR lang file. Language::gc() answers false
        // for a key the core does not have, which would leave $error empty and
        // fall back to the generic "did not pass our security checks" sentence.
        $this->error = $this->lang['error-risk-score'] ?? 'Your order could not be approved automatically.';
        $this->insert_record($uid, "Risk score {$score} reached the configured threshold", $ip);

        return false;
    }

    return true;
}
what the gate does with a false
// coremio/classes/FraudModule.php, run_checks()
foreach ($modules as $name => $row) {
    if (!(bool) ($row['config']['status'] ?? false)) continue;      // inactive: skipped

    try {
        $module = Modules::getInstance("Fraud", (string) $name);
        if (!$module || !method_exists($module, 'check')) continue;
        if ($module->check($params) !== false) continue;            // passed: next module

        $message = trim((string) ($module->error ?? ''));

        return $message !== ''
            ? $message
            : Language::gc("website/checkout/error-fraud", ['{module}' => (string) $name]);
    }
    catch (\Throwable $e) {
        // A provider being down must not take checkout down with it.
        Logger::warning("Fraud module '{$name}' check failed: " . $e->getMessage());
    }
}

return '';   // every active module passed

The returned string becomes the exception the checkout throws, so $this->error is what the customer reads. Keep detail in the record.

Pitfalls

A false positive is a refused customer

Require positive evidence: block on a confirmed mismatch, never on an absent value. A bug here shows up as orders never placed.

Never fail closed on a provider error

A timeout is not evidence of fraud. Let the exception escape, or return true; the gate logs a warning and the order proceeds.

Import your global classes

Fraud modules are namespaced, so a bare UserManager::ip_info() resolves to WISECP\Modules\Fraud\UserManager and fatals. Import global classes, or prefix with a backslash.

Handle the guest attempt

A rule keyed on user_data.id crashes, or silently never fires, when the attempt is a guest.

Record every block, with the reason

The records tab is the operator's only window into the gate. A block with no record is an order that vanished.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.