# Writing a Fraud Module

https://dev.wisecp.com/es/writing-a-fraud-module

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.

```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](https://dev.wisecp.com/en/module-anatomy).
- A test order you can place repeatedly. A rule that blocks too much shows only in the records table.

## Structure

```text
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

```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

```php
// 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

```php
// 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

| Aspect | Local scoring | Remote scoring |
| --- | --- | --- |
| Reference | `WFraud` | `MaxMind` |
| Rules | Blacklist, IP country against billing country, proxy or VPN | One provider score against a threshold |
| Settings | A switch per rule | Credentials, service tier, risk score, provider toggles |
| Credentials | None | Required, validated in `save_fields()` |
| Cost per order | Zero | One 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.

```php
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;
}
```

```php
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;
}
```

```php
// 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.

## Related Articles

- [Module Anatomy](https://dev.wisecp.com/en/module-anatomy)
- [Module Configuration](https://dev.wisecp.com/en/module-configuration)
- [Writing a Payment Gateway](https://dev.wisecp.com/en/writing-a-payment-gateway)
- [Securing Theme Forms](https://dev.wisecp.com/en/securing-theme-forms)
- [Cart and Checkout](https://dev.wisecp.com/en/cart-and-checkout)
- [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener)
