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.
// 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
Acme.php namespace WISECP\Modules\Fraud; class Acme extends FraudModule
config.php ['meta' => [...], 'status' => false, 'settings' => []]
lang/en.php
lang/tr.php
logo.png
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
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
- Place an order that should pass. It completes and the records table stays empty.
- Place one that should be blocked. The checkout shows your message; nothing is created.
- 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
// 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;
config['status'] is true, in registry order, skips any without check, returns on the first false. An empty string means proceed.
fraud_detected_records with your module name, the user id, your reason and the IP. Omit the IP and the request IP is used.
setConfigureTab() adds. Use the callback rather than overriding it.
website/checkout/error-fraud with your module name.
What You Declare
// 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
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.
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.
address, city, country_code, zipcode. The country code is resolved from the country id when the form sent only that.
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.
Money::Currency() when the provider wants the ISO code.
PayTR. MaxMind maps it to its own vocabulary through a payment-gateways table.
['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 |
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.
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;
}
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;
}
// 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
Require positive evidence: block on a confirmed mismatch, never on an absent value. A bug here shows up as orders never placed.
A timeout is not evidence of fraud. Let the exception escape, or return true; the gate logs a warning and the order proceeds.
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.
A rule keyed on user_data.id crashes, or silently never fires, when the attempt is a guest.
The records tab is the operator's only window into the gate. A block with no record is an order that vanished.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.