Templates SQL anti-fraude¶
Tous les templates utilisent une CTE fraud_ids pour la liste des fraudeurs. Remplacer $FRAUD_IDS par le array SQL depuis known-fraudsters.md, ou fournir une nouvelle liste.
CTE commune : liste des fraudeurs¶
A. Vue d'ensemble (populations)¶
SELECT
COUNT(DISTINCT "customerId") AS total_withdrawers,
COUNT(*) AS total_withdrawals,
SUM(value) AS total_amount
FROM wallet_transactions
WHERE kind = 'withdrawal' AND status = 'confirmed'
AND "createdAt" >= '$START_DATE' AND "createdAt" < '$END_DATE'
B. Profil par fraudeur¶
Metriques completes pour chaque fraudeur : age du compte, topups, retraits, delais, IBANs.
WITH fraud_ids AS (
SELECT unnest($FRAUD_IDS::uuid[]) AS id
),
topups AS (
SELECT t."customerId",
COUNT(*) FILTER (WHERE t.status = 'confirmed') AS confirmed,
COUNT(*) FILTER (WHERE t.status = 'declined') AS declined,
COUNT(*) AS total_attempts
FROM wallet_transactions t
WHERE t.kind IN ('topup_card','topup_checkout')
AND t."customerId" IN (SELECT id FROM fraud_ids)
GROUP BY t."customerId"
),
max_burst AS (
SELECT t."customerId", MAX(cnt) AS max_day_attempts
FROM (
SELECT "customerId", "createdAt"::date, COUNT(*) AS cnt
FROM wallet_transactions
WHERE kind IN ('topup_card','topup_checkout')
AND "customerId" IN (SELECT id FROM fraud_ids)
GROUP BY "customerId", "createdAt"::date
) t
GROUP BY t."customerId"
),
wd_delay AS (
SELECT DISTINCT ON (w.id)
w."customerId",
EXTRACT(EPOCH FROM (w."createdAt" - t."createdAt")) / 3600 AS delay_h
FROM wallet_transactions w
INNER JOIN wallet_transactions t
ON t."customerId" = w."customerId"
AND t.kind IN ('topup_card','topup_checkout')
AND t."createdAt" < w."createdAt"
WHERE w.kind = 'withdrawal' AND w.status = 'confirmed'
AND w."customerId" IN (SELECT id FROM fraud_ids)
ORDER BY w.id, t."createdAt" DESC
),
wd_agg AS (
SELECT "customerId", MIN(delay_h) AS min_delay_h FROM wd_delay GROUP BY "customerId"
),
iban_delay AS (
SELECT w."customerId",
MIN(EXTRACT(EPOCH FROM (w."createdAt" - pm."createdAt")) / 3600) AS min_iban_delay_h,
COUNT(DISTINCT pm.id) AS nb_ibans
FROM wallet_transactions w
JOIN payment_methods pm ON pm.id = w."paymentMethodId"
WHERE w.kind = 'withdrawal' AND w.status = 'confirmed'
AND w."customerId" IN (SELECT id FROM fraud_ids)
GROUP BY w."customerId"
)
SELECT
c.id, c.email,
SPLIT_PART(c.email, '@', 2) AS domain,
c."createdAt"::date AS account_created,
tp.confirmed AS topup_confirmed,
tp.declined AS topup_declined,
mb.max_day_attempts,
ROUND(wa.min_delay_h::numeric, 1) AS min_topup_wd_hours,
ROUND(id2.min_iban_delay_h::numeric, 1) AS min_iban_wd_hours,
id2.nb_ibans
FROM fraud_ids f
JOIN customers c ON c.id = f.id
LEFT JOIN topups tp ON tp."customerId" = f.id
LEFT JOIN max_burst mb ON mb."customerId" = f.id
LEFT JOIN wd_agg wa ON wa."customerId" = f.id
LEFT JOIN iban_delay id2 ON id2."customerId" = f.id
ORDER BY c."createdAt"
C. Regle cooldown post-topup (flat)¶
Nb fraudeurs et legit catches par seuil de cooldown.
Cote fraudeurs¶
WITH fraud_ids AS (
SELECT unnest($FRAUD_IDS::uuid[]) AS id
),
withdrawals AS (
SELECT DISTINCT ON (w.id)
w."customerId",
EXTRACT(EPOCH FROM (w."createdAt" - t."createdAt")) / 3600 AS delay_h
FROM wallet_transactions w
INNER JOIN wallet_transactions t
ON t."customerId" = w."customerId"
AND t.kind IN ('topup_card','topup_checkout')
AND t."createdAt" < w."createdAt"
WHERE w.kind = 'withdrawal' AND w.status = 'confirmed'
AND w."customerId" IN (SELECT id FROM fraud_ids)
ORDER BY w.id, t."createdAt" DESC
),
per_customer AS (
SELECT "customerId", MIN(delay_h) AS min_delay_h FROM withdrawals GROUP BY "customerId"
)
SELECT
COUNT(*) FILTER (WHERE min_delay_h < 2) AS caught_2h,
COUNT(*) FILTER (WHERE min_delay_h < 6) AS caught_6h,
COUNT(*) FILTER (WHERE min_delay_h < 12) AS caught_12h,
COUNT(*) FILTER (WHERE min_delay_h < 24) AS caught_24h,
COUNT(*) FILTER (WHERE min_delay_h < 48) AS caught_48h,
COUNT(*) AS total
FROM per_customer
Cote legit¶
Meme query mais avec AND w."customerId" NOT IN (SELECT id FROM fraud_ids) et filtre sur la periode AND w."createdAt" >= '$START_DATE' AND w."createdAt" < '$END_DATE'.
D. Regle cooldown tiered (age compte + investissements)¶
Cote fraudeurs¶
WITH fraud_ids AS (
SELECT unnest($FRAUD_IDS::uuid[]) AS id
),
first_wd AS (
SELECT DISTINCT ON ("customerId")
"customerId", "createdAt" AS wd_date
FROM wallet_transactions
WHERE kind = 'withdrawal' AND status = 'confirmed'
AND "customerId" IN (SELECT id FROM fraud_ids)
ORDER BY "customerId", "createdAt" ASC
),
investments AS (
SELECT fw."customerId", COUNT(pp.*)::int AS cnt
FROM first_wd fw
LEFT JOIN primary_purchase pp
ON (pp.json->>'investorId')::uuid = fw."customerId"
AND pp."status_view" = 'confirmed'
AND pp."createdAt_view" < fw.wd_date
GROUP BY fw."customerId"
),
withdrawals AS (
SELECT DISTINCT ON (w.id)
w."customerId",
EXTRACT(EPOCH FROM (w."createdAt" - t."createdAt")) / 3600 AS delay_h,
EXTRACT(DAY FROM w."createdAt" - c."createdAt") AS account_age
FROM wallet_transactions w
JOIN customers c ON c.id = w."customerId"
INNER JOIN wallet_transactions t
ON t."customerId" = w."customerId"
AND t.kind IN ('topup_card','topup_checkout')
AND t."createdAt" < w."createdAt"
WHERE w.kind = 'withdrawal' AND w.status = 'confirmed'
AND w."customerId" IN (SELECT id FROM fraud_ids)
ORDER BY w.id, t."createdAt" DESC
),
per_customer AS (
SELECT w."customerId",
MIN(w.delay_h) AS min_delay_h,
MIN(w.account_age) AS min_account_age,
COALESCE(i.cnt, 0) AS investment_count
FROM withdrawals w
LEFT JOIN investments i ON i."customerId" = w."customerId"
GROUP BY w."customerId", i.cnt
)
SELECT
-- Mature = age >= 60j OU >= 3 investissements
COUNT(*) FILTER (WHERE (min_account_age >= 60 OR investment_count >= 3) AND min_delay_h < 48) AS mature_caught_48h,
COUNT(*) FILTER (WHERE (min_account_age >= 60 OR investment_count >= 3)) AS mature_total,
-- Jeune = age < 60j ET < 3 investissements
COUNT(*) FILTER (WHERE (min_account_age < 60 AND investment_count < 3) AND min_delay_h < 168) AS young_caught_7d,
COUNT(*) FILTER (WHERE (min_account_age < 60 AND investment_count < 3)) AS young_total,
-- Combine
COUNT(*) FILTER (WHERE
((min_account_age >= 60 OR investment_count >= 3) AND min_delay_h < 48) OR
((min_account_age < 60 AND investment_count < 3) AND min_delay_h < 168)
) AS tiered_caught,
COUNT(*) AS total
FROM per_customer
E. Regle velocite topup (tentatives par jour)¶
Cote fraudeurs¶
WITH fraud_ids AS (
SELECT unnest($FRAUD_IDS::uuid[]) AS id
),
topup_bursts AS (
SELECT "customerId", "createdAt"::date AS topup_day,
COUNT(*) AS total_attempts,
COUNT(*) FILTER (WHERE status = 'declined') AS declined
FROM wallet_transactions
WHERE kind IN ('topup_card','topup_checkout')
AND "customerId" IN (SELECT id FROM fraud_ids)
GROUP BY "customerId", "createdAt"::date
),
per_customer AS (
SELECT "customerId",
MAX(total_attempts) AS max_attempts_day,
MAX(declined) AS max_declined_day
FROM topup_bursts
GROUP BY "customerId"
)
SELECT
COUNT(*) FILTER (WHERE max_attempts_day >= 3) AS caught_3_day,
COUNT(*) FILTER (WHERE max_attempts_day >= 5) AS caught_5_day,
COUNT(*) FILTER (WHERE max_attempts_day >= 10) AS caught_10_day,
COUNT(*) FILTER (WHERE max_declined_day >= 3) AS caught_3_declined_day,
COUNT(*) FILTER (WHERE max_declined_day >= 5) AS caught_5_declined_day,
COUNT(*) FILTER (WHERE max_declined_day >= 10) AS caught_10_declined_day,
COUNT(*) AS total
FROM per_customer
Cote legit (percentiles)¶
WITH customer_stats AS (
SELECT "customerId",
COUNT(*) FILTER (WHERE status = 'confirmed') AS confirmed,
COUNT(*) FILTER (WHERE status = 'declined') AS declined,
COUNT(*) AS total
FROM wallet_transactions
WHERE kind IN ('topup_card','topup_checkout')
AND "createdAt" >= '$START_DATE' AND "createdAt" < '$END_DATE'
AND "customerId" NOT IN (SELECT unnest($FRAUD_IDS::uuid[]))
GROUP BY "customerId"
HAVING COUNT(*) FILTER (WHERE status = 'declined') > 0
)
SELECT
COUNT(*) AS nb_customers_with_declines,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY declined) AS median_declined,
PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY declined) AS p90_declined,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY declined) AS p95_declined,
PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY declined) AS p99_declined,
MAX(declined) AS max_declined
FROM customer_stats
F. Regle cooldown IBAN¶
Delai entre ajout de l'IBAN et retrait vers cet IBAN. Utilise le lien exact via paymentMethodId (renseigne a 100% sur les withdrawals).
Cote fraudeurs¶
WITH fraud_ids AS (
SELECT unnest($FRAUD_IDS::uuid[]) AS id
),
wd_with_iban AS (
SELECT w."customerId",
EXTRACT(EPOCH FROM (w."createdAt" - pm."createdAt")) / 3600 AS hours_iban_to_wd
FROM wallet_transactions w
JOIN payment_methods pm ON pm.id = w."paymentMethodId"
WHERE w.kind = 'withdrawal' AND w.status = 'confirmed'
AND w."customerId" IN (SELECT id FROM fraud_ids)
),
per_customer AS (
SELECT "customerId", MIN(hours_iban_to_wd) AS min_h FROM wd_with_iban GROUP BY "customerId"
)
SELECT
COUNT(*) FILTER (WHERE min_h < 12) AS caught_12h,
COUNT(*) FILTER (WHERE min_h < 24) AS caught_24h,
COUNT(*) FILTER (WHERE min_h < 48) AS caught_48h,
COUNT(*) FILTER (WHERE min_h < 72) AS caught_72h,
COUNT(*) FILTER (WHERE min_h < 168) AS caught_7d,
COUNT(*) AS total
FROM per_customer
Cote legit¶
Meme query avec NOT IN (SELECT id FROM fraud_ids) et filtre periode sur w."createdAt".
G. Regle multi-IBAN / BICs¶
BICs utilises par les fraudeurs¶
WITH fraud_ids AS (
SELECT unnest($FRAUD_IDS::uuid[]) AS id
)
SELECT pm.bic, COUNT(DISTINCT pm."customerId") AS nb_fraudsters
FROM fraud_ids f
JOIN payment_methods pm ON pm."customerId" = f.id
WHERE pm.kind = 'iban_withdraw'
GROUP BY pm.bic
ORDER BY nb_fraudsters DESC
IBANs hors-FR¶
WITH fraud_ids AS (
SELECT unnest($FRAUD_IDS::uuid[]) AS id
)
SELECT
COUNT(DISTINCT pm."customerId") FILTER (WHERE LEFT(pm.iban, 2) != 'FR') AS nb_with_non_fr_iban,
COUNT(DISTINCT pm."customerId") AS total_with_iban
FROM fraud_ids f
JOIN payment_methods pm ON pm."customerId" = f.id
WHERE pm.kind = 'iban_withdraw'
H. Matrice de couverture combinee¶
Combine toutes les regles et compte les combinaisons.
WITH fraud_ids AS (
SELECT unnest($FRAUD_IDS::uuid[]) AS id
),
r1 AS (
SELECT DISTINCT ON (w.id) w."customerId",
EXTRACT(EPOCH FROM (w."createdAt" - t."createdAt")) / 3600 AS delay_h
FROM wallet_transactions w
INNER JOIN wallet_transactions t ON t."customerId" = w."customerId"
AND t.kind IN ('topup_card','topup_checkout') AND t."createdAt" < w."createdAt"
WHERE w.kind = 'withdrawal' AND w.status = 'confirmed'
AND w."customerId" IN (SELECT id FROM fraud_ids)
ORDER BY w.id, t."createdAt" DESC
),
r1_agg AS (SELECT "customerId", MIN(delay_h) AS min_r1 FROM r1 GROUP BY "customerId"),
r3 AS (
SELECT "customerId", MAX(cnt) AS max_day
FROM (
SELECT "customerId", "createdAt"::date, COUNT(*) AS cnt
FROM wallet_transactions WHERE kind IN ('topup_card','topup_checkout')
AND "customerId" IN (SELECT id FROM fraud_ids)
GROUP BY "customerId", "createdAt"::date
) sub GROUP BY "customerId"
),
r4 AS (
SELECT w."customerId",
MIN(EXTRACT(EPOCH FROM (w."createdAt" - pm."createdAt")) / 3600) AS min_iban_h
FROM wallet_transactions w
JOIN payment_methods pm ON pm.id = w."paymentMethodId"
WHERE w.kind = 'withdrawal' AND w.status = 'confirmed'
AND w."customerId" IN (SELECT id FROM fraud_ids)
GROUP BY w."customerId"
),
combined AS (
SELECT f.id,
COALESCE(r1a.min_r1 < 48, false) AS hit_cooldown_48h,
COALESCE(r3.max_day >= 5, false) AS hit_velocity_5day,
COALESCE(r4.min_iban_h < 24, false) AS hit_iban_24h
FROM fraud_ids f
LEFT JOIN r1_agg r1a ON r1a."customerId" = f.id
LEFT JOIN r3 ON r3."customerId" = f.id
LEFT JOIN r4 ON r4."customerId" = f.id
)
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE hit_cooldown_48h) AS cooldown_only,
COUNT(*) FILTER (WHERE hit_velocity_5day) AS velocity_only,
COUNT(*) FILTER (WHERE hit_iban_24h) AS iban_only,
COUNT(*) FILTER (WHERE hit_cooldown_48h OR hit_velocity_5day) AS cooldown_or_velocity,
COUNT(*) FILTER (WHERE hit_cooldown_48h OR hit_iban_24h) AS cooldown_or_iban,
COUNT(*) FILTER (WHERE hit_cooldown_48h OR hit_velocity_5day OR hit_iban_24h) AS all_three,
COUNT(*) FILTER (WHERE NOT hit_cooldown_48h AND NOT hit_velocity_5day AND NOT hit_iban_24h) AS missed_all
FROM combined
Adapter les seuils dans les COALESCE(...) selon les regles testees. Ajouter/retirer des CTEs selon les regles evaluees.