SereneDB

Finance data platform: fraud rules, AML screening and analytics in one SQL engine

A finance data platform holds a bank's or fintech's payments, customers and watchlists and answers fraud, anti-money-laundering and reporting questions over them. SereneDB is its storage and query layer: fraud rules run as SQL over live payments, names are screened despite typos, payments are compared with reported fraud by vector distance, and reports read the same tables, in one Postgres-compatible engine.

curl https://install.serenedb.com | sh
card payments · 300 cards · 2,421 rowsflaggedquery
An animation in three scenes over one invented day of card payments, 2026-11-06, 2,421 payments on 300 cards. First, tok_0217 pays seven times from 02:14 to 02:19, between 1.00 and 2.50 each, two of them declined; a 10-minute window counts the payments, and from the fifth, at 02:17:02 (Swift Rides), the count reaches 5 and the rule fires, up to the 1,249.00 purchase at Voltix Electronics with 8 payments and 1,259.99 in the window. Second, every customer's name is compared with 8 watchlist names after folding case and accents, and three customers score 0.85 or more: Joaquin Ferrandis Ocana at 0.983 and Victor Kesling at 0.904, both with the listed birth date, and Maria Lindqvist at 0.866 with another one. Third, every payment's feature vector is compared with that of the reported 1,249.00 purchase: the nearest, at 0.058, is tok_0261's 980.00 purchase, and every other payment is 1.045 or more away.
What is a finance data platform

What does a finance data platform do? Five questions about one day of card payments

A finance data platform holds a bank's or fintech's payments, customers and watchlists and answers the questions fraud, AML and finance teams ask of them: is this card being tested, can it be in two countries at once, is this new customer on a list, which payments look like a reported fraud, what did the day add up to. Below is one invented day at one card issuer, 300 cards and 2,421 payments. Pick a question to run it; each is one SQL statement.

fraud lab · one day of card payments
2026-11-06 · 00:00–24:002,421 payments · 3 new customers · 5 alerts
00:0003:0006:0009:0012:0015:0018:0021:0024:00payments / 15 minthree cardsnew customersalertstok_0217tok_0261tok_01928 payments in 7 min5 in 27 minVictor KeslingMaria LindqvistJoaquin Ferrandis Ocana
Velocity
sql
SELECT ts::TIME AS t, card_id, merchant, amount, n_10m, usd_10m
FROM (
    SELECT *,
           count(*) OVER w AS n_10m,
           sum(amount) OVER w AS usd_10m
    FROM transactions
    WINDOW w AS (PARTITION BY card_id ORDER BY ts
                 RANGE BETWEEN INTERVAL '10 minutes' PRECEDING AND CURRENT ROW)
)
WHERE n_10m >= 5
ORDER BY ts;
result · 4 rows
tcard_idmerchantamountn_10musd_10m
02:17:02tok_0217Swift Rides1.5057.99
02:18:15tok_0217Parcelo Market2.0069.99
02:19:40tok_0217Pixelplay Games1.00710.99
02:21:12tok_0217Voltix Electronics1249.0081259.99
tok_0217 pays seven times in five and a half minutes, 1.00 to 2.50 each, two of them declined, then spends 1,249.00 on electronics. The 10-minute window holds five payments at 02:17:02, so the rule flags that one and the three after it. No other card has even three payments in any 10 minutes.
Travel
sql
SELECT card_id, prev_country || ' → ' || country AS route,
       prev_ts::TIME AS before, ts::TIME AS after, ts - prev_ts AS gap, amount
FROM (
    SELECT *,
           lag(country) OVER w AS prev_country,
           lag(ts) OVER w AS prev_ts
    FROM transactions
    WHERE channel IN ('pos', 'atm')
    WINDOW w AS (PARTITION BY card_id ORDER BY ts)
)
WHERE country <> prev_country AND ts - prev_ts < INTERVAL '2 hours';
result · 1 row
card_idroutebeforeaftergapamount
tok_0192US → GB12:26:4613:18:3000:51:44890.00
lag() gives each card-present payment the one before it on the same card. tok_0192 paid at a transit gate in the US at 12:26:46 and 890.00 in a UK store 51 minutes later. Online payments are left out: the card is not present.
Screening
sql
SELECT c.full_name, w.name AS listed_as, w.list,
       round(jaro_winkler_similarity(strip_accents(lower(c.full_name)),
                                     strip_accents(lower(w.name))), 3) AS score,
       c.birth_date = w.birth_date AS same_birth_date
FROM customers c
JOIN watchlist w
  ON jaro_winkler_similarity(strip_accents(lower(c.full_name)),
                             strip_accents(lower(w.name))) >= 0.85
ORDER BY score DESC;
result · 3 rows
full_namelisted_aslistscoresame_birth_date
Joaquin Ferrandis OcanaJoaquín Ferrándiz Ocañasanctions0.983t
Victor KeslingViktor Kesslingsanctions0.904t
Maria LindqvistMarja Lindkvistsanctions0.866f
Every customer against every list name, case and accents folded. Joaquin Ferrandis Ocana scores 0.983 and Victor Kesling 0.904, both with the listed birth date. Maria Lindqvist scores 0.866 with another birth date: the second key tells a near miss from a match. The watchlist is invented.
Similar
sql
WITH nearest AS (
    SELECT tx_id, round(features <-> [0.891, 0.098, 1, 0, 1, 1]::FLOAT[6], 3) AS distance
    FROM tx_features_idx
    ORDER BY features <-> [0.891, 0.098, 1, 0, 1, 1]::FLOAT[6]
    LIMIT 4
)
SELECT n.tx_id, t.ts::TIME AS t, t.card_id, t.amount, n.distance,
       r.tx_id IS NOT NULL AS reported
FROM nearest n
JOIN transactions t USING (tx_id)
LEFT JOIN fraud_reports r USING (tx_id)
ORDER BY n.distance, n.tx_id;
result · 4 rows
tx_idtcard_idamountdistancereported
2702:21:12tok_02171249.000t
5103:33:05tok_0261980.000.058f
37008:36:06tok_0112351.001.045f
34008:22:39tok_0073296.401.046f
The reported purchase's vector: log amount, time of day, online, abroad, small online payments in the hour before, electronics. tok_0261's 980.00 at 03:33 is 0.058 away and not yet reported; the velocity rule missed it, since its five payments spread over 27 minutes. The next payments are 1.045 away.
Report
sql
SELECT coalesce(t.channel, 'all') AS channel,
       count(*) AS transactions,
       sum(t.amount) FILTER (WHERE t.status = 'approved') AS approved_usd,
       round(100.0 * count(*) FILTER (WHERE t.status = 'declined') / count(*), 1) AS declined_pct,
       count(a.tx_id) AS flagged,
       coalesce(sum(t.amount) FILTER (WHERE r.tx_id IS NOT NULL), 0) AS reported_usd
FROM transactions t
LEFT JOIN alerts a ON a.tx_id = t.tx_id
LEFT JOIN fraud_reports r ON r.tx_id = t.tx_id
GROUP BY ROLLUP (t.channel)
ORDER BY t.channel NULLS LAST;
result · 4 rows
channeltransactionsapproved_usddeclined_pctflaggedreported_usd
atm17921200.000.600.00
ecom77468312.461.441249.00
pos146851323.211.11890.00
all2421140835.671.252139.00
The day per channel, with ROLLUP's total row: 2,421 payments, 140,835.67 approved, 1.2 % declined. The same statement counts the rules' five alerts and the 2,139.00 of reported fraud from the alerts and fraud_reports tables.

Checklist

What a finance data platform needs from its database, and how SereneDB does it in SQL

The database under a finance data platform takes in payments as they happen, runs rules over each card's recent history, screens names that arrive spelled several ways, compares payments with known fraud, searches what customers write, reports on the same tables and controls who reads what. Lists of financial data platforms compare products; these are the jobs, each with the SereneDB feature that does it and its docs.

NeedHow SereneDB does itLooks like
Take in payments as they happenThe payment service writes each authorization with INSERT over the PostgreSQL protocol, and batch files arrive with COPY. Inverted indexes see new rows within a second. Docs →INSERT INTO transactions VALUES (…)
Rules over each card's recent historyWindow functions with RANGE frames count and sum a card's payments in the last 10 minutes; lag() compares a payment with the one before it. Docs →RANGE BETWEEN INTERVAL '10 minutes' PRECEDING AND CURRENT ROW
Screen names spelled several waysjaro_winkler_similarity and damerau_levenshtein compare whole names after strip_accents and lower, with the birth date as a second key. Docs →jaro_winkler_similarity(strip_accents(lower(c.full_name)), …)
Check one applicant at sign-upAn inverted index over the watchlist matches each name token within an edit distance, with case and accents folded by its dictionary. Docs →name @@ ts_levenshtein('kesling', 1)
Find payments like a known fraudOne feature vector per payment in an HNSW index; ordering by the distance to a reported payment returns its nearest neighbours. Docs →ORDER BY features <-> [0.891, 0.098, 1, 0, 1, 1]::FLOAT[6]
Search payment referencesPhrase search and BM25 ranking over the text customers type into transfers, in the same SQL as amounts and dates. Docs →reference @@ ts_phrase('as instructed')
Report on the same tablesFILTER, ROLLUP and joins to alerts and fraud reports in one statement, read by any PostgreSQL client. Docs →GROUP BY ROLLUP (t.channel)
Run the rules on a scheduleA job inside the server runs the rules every 5 minutes and writes what they flag to an alerts table. Docs →CREATE JOB fraud_rules EVERY 5 MINUTES AS $$ … $$
Limit who reads whatRoles and column grants: an analyst role reads amounts and merchants, not card tokens or customer ids. Docs →GRANT SELECT (tx_id, ts, …, amount) ON transactions TO fraud_analyst
Keep the history as filesCOPY writes past days to Parquet files partitioned by day, and read_parquet queries them in place, on disk or in S3. Docs →(FORMAT parquet, PARTITION_BY (day), APPEND)

Architecture

Fraud and AML architecture: payments, customers and watchlists in one engine

Authorizations and batches arrive over the PostgreSQL protocol, each payment gets a feature vector, and transfer references and watchlist names get inverted indexes. Every question is a SELECT: a window, a name score, a vector distance or a search. A job writes alerts, and grants decide who reads which columns. Hover a stage, or a lane inside the index.

01 · ingest
INSERT
authorizations PostgreSQL :7890
01 · ingest
INSERT · COPY
customers · transfers watchlists
02 · store
transactions
one row per payment
02 · store
customers · watchlist
names · birth dates
02 · store
transfers
wires · cash deposits
03 · indexes
inverted · hnsw
payment featuresvector
watchlist namefuzzy
transfer referencetext
04 · rules
OVER w
RANGE 10 min · lag()
04 · similar
<->
L2 distance · HNSW
04 · screen
names
Jaro-Winkler · edits
04 · search
@@
ts_phrase · BM25
05 · alerts
CREATE JOB
every 5 minutes
06 · access
GRANT
column privileges
06 · history
Parquet
COPY … TO
> ingest · authorizations

A payment is a row the moment it is approved

The payment service writes each authorization with INSERT from any PostgreSQL client library. Before it approves the next one, it can ask what the card did in the last 10 minutes: here, just before tok_0217's 1,249.00 purchase.

sql
SELECT count(*) AS payments, sum(amount) AS usd
FROM transactions
WHERE card_id = 'tok_0217'
AND ts >= TIMESTAMP '2026-11-06 02:21:12' - INTERVAL '10 minutes'
AND ts < TIMESTAMP '2026-11-06 02:21:12';
-- 7 payments, 10.99 USD: the card was tested seven times before the 1,249.00 purchase
> ingest · customers, transfers, lists

Customers, transfers and watchlists in batches

Core banking sends customers and transfers, and list providers send their entries, with INSERT or COPY. A system that keeps its own PostgreSQL database can stay where it is: ATTACH reads it in place, as on the zero-ETL search page.

sql
-- An invented watchlist: the names and dates are made up for this page.
INSERT INTO watchlist VALUES
(1, 'Viktor Kessling', '1971-03-14', 'sanctions'),
(2, 'Marja Lindkvist', '1984-07-02', 'sanctions'),
(3, 'Joaquín Ferrándiz Ocaña', '1966-11-30', 'sanctions'),
(4, 'Oskar Brandvold', '1979-05-21', 'pep'),
(5, 'Leilani Akana-Moss', '1990-01-09', 'pep'),
(6, 'Ferenc Halmosi', '1958-09-17', 'sanctions'),
(7, 'Priya Raghunathan-Cole', '1987-12-03', 'internal'),
(8, 'Corwin Ashgrove', '1975-06-28', 'internal');
> stage 02 · transactions

A payment is a row, and a vector

Each payment also gets a feature vector in tx_features, computed in SQL from its amount, time of day, channel, country, the card's small online payments in the hour before and its category. The reported fraud's vector is the one the lab's Similar question searches with.

sql
-- One feature vector per transaction: amount, time of day, online, abroad,
-- small online payments on the card in the hour before, high-risk category.
INSERT INTO tx_features
SELECT tx_id,
[round(ln(1 + amount) / 8, 3),
round(extract(epoch FROM ts::TIME) / 86400, 3),
CASE WHEN channel = 'ecom' THEN 1 ELSE 0 END,
CASE WHEN country <> 'US' THEN 1 ELSE 0 END,
least(1, count(*) FILTER (WHERE amount < 5 AND channel = 'ecom') OVER (
PARTITION BY card_id ORDER BY ts
RANGE BETWEEN INTERVAL '60 minutes' PRECEDING
AND INTERVAL '1 second' PRECEDING) / 4.0),
CASE WHEN category IN ('electronics', 'jewelry') THEN 1 ELSE 0 END
]::FLOAT[6]
FROM transactions;
SELECT f.tx_id, t.card_id, t.amount, f.features
FROM fraud_reports r
JOIN tx_features f USING (tx_id)
JOIN transactions t USING (tx_id)
WHERE t.card_id = 'tok_0217';
-- 27 | tok_0217 | 1249.00 | {0.891,0.098,1,0,1,1}
> stage 02 · transfers

A week of wires and cash deposits

Salaries in, payments out, cash at the branch, each with the reference the customer typed. The AML example below counts cash deposits in a band per customer; the investigations example searches the references.

sql
SELECT kind, count(*) AS transfers, sum(amount) AS usd
FROM transfers
GROUP BY kind ORDER BY kind;
-- cash_deposit 33 (102,176.00) · wire_in 150 (602,450.00) · wire_out 103 (139,750.00)
> stage 02 · customers and watchlist

Names, the way people write them

Names arrive with and without accents, in any case. The person_names dictionary lowercases them and folds the accents before indexing, so Joaquín Ferrándiz Ocaña is stored as plain words.

sql
SELECT ts_lexize('person_names', 'Joaquín Ferrándiz Ocaña');
-- {joaquin,ferrandiz,ocana}
> stage 03 · indexes

What is searched is indexed

One inverted index per searched table: the watchlist's names for fuzzy lookups, transfer references with word frequencies for BM25, and the feature vectors in an HNSW graph. New rows are searchable at the next refresh, every second by default, or at once after VACUUM (REFRESH_TABLE).

sql
CREATE TEXT SEARCH DICTIONARY person_names AS
split_text(case := 'lower') | normalize_tokens('en_US.UTF-8', accent := false);
CREATE TEXT SEARCH DICTIONARY payment_text AS
split_text(case := 'lower') | normalize_tokens('en_US.UTF-8', accent := false)
WITH (frequency, position, norm);
CREATE INDEX watchlist_idx ON watchlist
USING inverted (entry_id, name person_names, birth_date, list);
CREATE INDEX transfers_idx ON transfers
USING inverted (transfer_id, ts, customer_id, kind, reference payment_text)
INCLUDE (amount);
CREATE INDEX tx_features_idx ON tx_features
USING inverted (tx_id, features hnsw (metric = 'l2'));
> stage 04 · rules

Fraud rules are windows over a card's rows

A velocity rule counts a card's payments in a RANGE frame of 10 minutes; a travel rule compares each card-present payment with the one before it through lag(). Both write what they flag to the alerts table.

sql
INSERT INTO alerts
SELECT 'velocity', tx_id, card_id, ts, n_10m || ' payments in 10 minutes'
FROM (
SELECT tx_id, card_id, ts, count(*) OVER w AS n_10m
FROM transactions
WINDOW w AS (PARTITION BY card_id ORDER BY ts
RANGE BETWEEN INTERVAL '10 minutes' PRECEDING AND CURRENT ROW)
)
WHERE n_10m >= 5;
INSERT INTO alerts
SELECT 'travel', tx_id, card_id, ts, prev_country || ' → ' || country || ' in ' || (ts - prev_ts)
FROM (
SELECT tx_id, card_id, ts, country,
lag(country) OVER w AS prev_country,
lag(ts) OVER w AS prev_ts
FROM transactions
WHERE channel IN ('pos', 'atm')
WINDOW w AS (PARTITION BY card_id ORDER BY ts)
)
WHERE country <> prev_country AND ts - prev_ts < INTERVAL '2 hours';
SELECT rule, count(*) AS alerts FROM alerts GROUP BY rule ORDER BY rule;
-- travel 1 · velocity 4
> stage 04 · similar

Payments nearest a reported fraud

Ordering by <->, the L2 distance, against an indexed vector column returns the nearest payments from the HNSW index. The second row is the purchase the rules missed.

sql
WITH nearest AS (
SELECT tx_id, round(features <-> [0.891, 0.098, 1, 0, 1, 1]::FLOAT[6], 3) AS distance
FROM tx_features_idx
ORDER BY features <-> [0.891, 0.098, 1, 0, 1, 1]::FLOAT[6]
LIMIT 4
)
SELECT n.tx_id, t.ts::TIME AS t, t.card_id, t.amount, n.distance,
r.tx_id IS NOT NULL AS reported
FROM nearest n
JOIN transactions t USING (tx_id)
LEFT JOIN fraud_reports r USING (tx_id)
ORDER BY n.distance, n.tx_id;
> stage 04 · screen

One applicant, checked through the index

A batch screen scores whole names with jaro_winkler_similarity, as in the lab. A single applicant is checked through the watchlist's index: each word of the name within one edit with ts_levenshtein.

sql
SELECT entry_id, name, birth_date, list
FROM watchlist_idx
WHERE name @@ ts_levenshtein('victor', 1)
AND name @@ ts_levenshtein('kesling', 1);
-- 1 | Viktor Kessling | 1971-03-14 | sanctions
> stage 04 · search

Search transfer references, ranked

Words in a query match any reference that has them, and BM25 ranks the ones that fit best first. ts_phrase matches words in order, as in the investigations example.

sql
SELECT transfer_id, customer_id, reference,
round(BM25(transfers_idx.tableoid), 3) AS score
FROM transfers_idx
WHERE reference @@ 'crypto safe account'
ORDER BY score DESC, transfer_id
LIMIT 3;
-- 211 · Urgent transfer to safe account as instructed by ban… · 2.093
-- 244 · Crypto wallet top up as instructed · 1.488
> stage 05 · alerts

The rules run every 5 minutes, inside the server

A job runs the velocity rule over the last 70 minutes of payments every 5 minutes and adds what it flags to alerts, once per payment. EXECUTE JOB runs it on demand.

sql
CREATE JOB fraud_rules EVERY 5 MINUTES AS $$
INSERT INTO alerts
SELECT 'velocity', tx_id, card_id, ts, n_10m || ' payments in 10 minutes'
FROM (
SELECT tx_id, card_id, ts, count(*) OVER w AS n_10m
FROM transactions
WHERE ts >= now() - INTERVAL '70 minutes'
WINDOW w AS (PARTITION BY card_id ORDER BY ts
RANGE BETWEEN INTERVAL '10 minutes' PRECEDING AND CURRENT ROW)
) v
WHERE n_10m >= 5
AND NOT EXISTS (SELECT 1 FROM alerts a
WHERE a.tx_id = v.tx_id AND a.rule = 'velocity');
$$;
EXECUTE JOB fraud_rules;
SELECT job_name, schedule, last_status FROM sdb_jobs() WHERE job_name = 'fraud_rules';
-- fraud_rules | EVERY INTERVAL '00:05:00' | success
> stage 06 · access

Who reads which columns

Roles and grants work as in PostgreSQL. A column grant gives the analyst role the amounts, merchants and times but not the card token or the customer id; asking for card_id fails.

sql
CREATE ROLE fraud_analyst;
GRANT SELECT (tx_id, ts, merchant, category, channel, country, amount, status)
ON transactions TO fraud_analyst;
GRANT SELECT ON alerts TO fraud_analyst;
SET ROLE fraud_analyst;
SELECT channel, count(*) AS payments FROM transactions GROUP BY channel ORDER BY channel;
-- atm 179 · ecom 774 · pos 1468
SET ROLE fraud_analyst;
SELECT card_id, amount FROM transactions LIMIT 1;
RESET ROLE;
-- ERROR: permission denied for table transactions
> stage 06 · history

Past days to Parquet

COPY … TO with PARTITION_BY writes one folder of Parquet files per day, and read_parquet queries them in place. With an S3 secret the same COPY writes to a bucket.

sql
COPY (SELECT *, ts::DATE AS day FROM transactions)
TO '/tmp/transactions_history' (FORMAT parquet, PARTITION_BY (day), APPEND);
SELECT day, count(*) AS payments, sum(amount) AS usd
FROM read_parquet('/tmp/transactions_history/*/*.parquet', hive_partitioning = true)
GROUP BY day ORDER BY day;
-- COPY 2421, then 2026-11-06 | 2421 | 141957.54
A diagram of six numbered stages. Card authorizations arrive with INSERT over the PostgreSQL protocol on port 7890; customers, transfers and watchlist entries arrive in batches with INSERT or COPY. They are stored in tables: transactions with one row per card payment, transfers with wires and cash deposits, and customers next to the watchlist. Three indexes serve the searches: an HNSW graph over each payment's feature vector, an inverted index over transfer references for phrase search and BM25, and one over watchlist names for fuzzy lookups. Queries run fraud rules as window functions over each card's payments, find the payments nearest a reported fraud by L2 distance, screen names with Jaro-Winkler scores and edit distances, and search references. A job writes the rules' alerts every 5 minutes, column grants decide which columns of the transactions table an analyst role reads, and COPY writes past days of transactions to Parquet files.

What fraud and AML work needs from one engine

transactions · transfers · watchlist
rules
Rules are SQL

A velocity check is a window over the card's rows; a new threshold is a new number in a WHERE.

count(*) OVER w … WHERE n_10m >= 5
names
Names match with typos

Case and accents folded, then Jaro-Winkler: Victor Kesling scores 0.904 against Viktor Kessling.

jaro_winkler_similarity(…) >= 0.85
similar
Payments compare as vectors

The reported fraud's nearest payment, 0.058 away, is one the velocity rule missed.

features <-> [0.891, 0.098, 1, 0, 1, 1]
searchbench
A billion rows

SearchBench, 92 queries over 1B OpenTelemetry log rows: all 92 under the 60 s cap, median 31.0 ms.

otel_logs_1b · 26.09.1
SearchBench on otel_logs_1b →

Build it

Tutorial: fraud rules, AML name screening and similar-payment search in SQL, in five steps

From an empty server to the day's report: start SereneDB, create the tables, load a day of card payments and a week of transfers, run the fraud rules, then screen the customers, find the payments nearest the reported fraud and report the day.

step 01 / 05 · shell
docker run -d --name serenedb -p 7890:7890 \
  -e POSTGRES_PASSWORD=<password> serenedb/serenedb:26.11.2

psql -h localhost -p 7890 -U postgres
Any PostgreSQL client connects the same way, on port 7890.
step 02 / 05 · sql
CREATE TABLE customers (
    customer_id INTEGER,
    full_name   VARCHAR,
    birth_date  DATE,
    opened_at   TIMESTAMP
);

CREATE TABLE transactions (
    tx_id       INTEGER,
    ts          TIMESTAMP,
    card_id     VARCHAR,          -- a card token, never the card number
    customer_id INTEGER,
    merchant    VARCHAR,
    category    VARCHAR,
    channel     VARCHAR,          -- pos, ecom or atm
    country     VARCHAR,          -- where the merchant is
    amount      DECIMAL(12, 2),   -- USD
    status      VARCHAR           -- approved or declined
);

CREATE TABLE transfers (
    transfer_id INTEGER,
    ts          TIMESTAMP,
    customer_id INTEGER,
    kind        VARCHAR,          -- cash_deposit, wire_in or wire_out
    amount      DECIMAL(12, 2),
    reference   VARCHAR
);

CREATE TABLE watchlist (
    entry_id   INTEGER,
    name       VARCHAR,
    birth_date DATE,
    list       VARCHAR
);

CREATE TABLE fraud_reports (
    tx_id       INTEGER,
    reported_at TIMESTAMP,
    reason      VARCHAR
);

CREATE TABLE alerts (
    rule    VARCHAR,
    tx_id   INTEGER,
    card_id VARCHAR,
    ts      TIMESTAMP,
    detail  VARCHAR
);

CREATE TABLE tx_features (
    tx_id    INTEGER,
    features FLOAT[6]
);

CREATE TEXT SEARCH DICTIONARY person_names AS
    split_text(case := 'lower') | normalize_tokens('en_US.UTF-8', accent := false);

CREATE TEXT SEARCH DICTIONARY payment_text AS
    split_text(case := 'lower') | normalize_tokens('en_US.UTF-8', accent := false)
    WITH (frequency, position, norm);

CREATE INDEX watchlist_idx ON watchlist
    USING inverted (entry_id, name person_names, birth_date, list);

CREATE INDEX transfers_idx ON transfers
    USING inverted (transfer_id, ts, customer_id, kind, reference payment_text)
    INCLUDE (amount);

CREATE INDEX tx_features_idx ON tx_features
    USING inverted (tx_id, features hnsw (metric = 'l2'));
Seven tables, two dictionaries and three indexes: names fold case and accents, payment references keep word frequencies for BM25, and the feature vectors get an HNSW graph.
step 03 / 05 · sql
-- 300 cardholders with invented names, then three customers who opened
-- an account on 2026-11-06.
INSERT INTO customers
SELECT i, f.name || ' ' || l.name,
       DATE '1950-01-01' + CAST(i * 2731 % 18000 AS INTEGER),
       TIMESTAMP '2018-01-08 09:00:00' + INTERVAL '1 day' * (i * 7177 % 3000)
FROM generate_series(1, 300) AS g(i)
JOIN (VALUES (0, 'James'), (1, 'Maria'), (2, 'Robert'), (3, 'Linda'), (4, 'Michael'),
             (5, 'Sofia'), (6, 'David'), (7, 'Emily'), (8, 'Daniel'), (9, 'Grace'),
             (10, 'Kevin'), (11, 'Olivia'), (12, 'Brian'), (13, 'Hannah'), (14, 'Jason'),
             (15, 'Chloe'), (16, 'Eric'), (17, 'Nora'), (18, 'Adam'), (19, 'Leah'),
             (20, 'Ryan'), (21, 'Iris'), (22, 'Samuel'), (23, 'Ruth'), (24, 'Paul'))
     AS f(k, name) ON f.k = i * 7 % 25
JOIN (VALUES (0, 'Carter'), (1, 'Nguyen'), (2, 'Brooks'), (3, 'Patel'), (4, 'Hughes'),
             (5, 'Garcia'), (6, 'Foster'), (7, 'Kim'), (8, 'Russo'), (9, 'Ward'),
             (10, 'Okafor'), (11, 'Bennett'), (12, 'Silva'), (13, 'Morgan'), (14, 'Cohen'),
             (15, 'Reyes'), (16, 'Hayes'), (17, 'Novak'), (18, 'Price'), (19, 'Tanaka'),
             (20, 'Ellis'), (21, 'Mendez'), (22, 'Sharma'), (23, 'Doyle'))
     AS l(k, name) ON l.k = i * 11 % 24;

INSERT INTO customers VALUES
(301, 'Victor Kesling',          '1971-03-14', '2026-11-06 10:42:00'),
(302, 'Maria Lindqvist',         '1991-04-18', '2026-11-06 11:15:00'),
(303, 'Joaquin Ferrandis Ocana', '1966-11-30', '2026-11-06 15:05:00');

-- An invented watchlist: the names and dates are made up for this page.
INSERT INTO watchlist VALUES
(1, 'Viktor Kessling',         '1971-03-14', 'sanctions'),
(2, 'Marja Lindkvist',         '1984-07-02', 'sanctions'),
(3, 'Joaquín Ferrándiz Ocaña', '1966-11-30', 'sanctions'),
(4, 'Oskar Brandvold',         '1979-05-21', 'pep'),
(5, 'Leilani Akana-Moss',      '1990-01-09', 'pep'),
(6, 'Ferenc Halmosi',          '1958-09-17', 'sanctions'),
(7, 'Priya Raghunathan-Cole',  '1987-12-03', 'internal'),
(8, 'Corwin Ashgrove',         '1975-06-28', 'internal');

-- One day of card payments, 2026-11-06, for the 300 cards, with three
-- patterns in it: tok_0217 is tested with small online payments from 02:14
-- and then spends 1,249.00; tok_0261 is tested more slowly from 03:05 and
-- spends 980.00; tok_0192 pays in a UK store 51 minutes after its last
-- card payment in the US. Cards 120 to 126 are abroad all day.
INSERT INTO transactions
SELECT row_number() OVER (ORDER BY ts, card_id) AS tx_id, *
FROM (
    SELECT CASE WHEN (c * 31 + s) % 40 = 0
                THEN TIMESTAMP '2026-11-06 00:00:00' + INTERVAL '1 second' * ((c * 6113 + s * 3571) % 23400)
                ELSE TIMESTAMP '2026-11-06 06:30:00' + INTERVAL '1 second' * ((c * 6113 + s * 3571) % 59400)
           END AS ts,
           'tok_' || lpad(c::VARCHAR, 4, '0') AS card_id, c AS customer_id,
           m.merchant, m.category, m.channel,
           CASE WHEN m.channel <> 'ecom' AND c BETWEEN 120 AND 123 THEN 'GB'
                WHEN m.channel <> 'ecom' AND c BETWEEN 124 AND 126 THEN 'MX'
                ELSE 'US' END AS country,
           CASE WHEN m.channel = 'atm' THEN 20.00 * (2 + (c + s) % 9)
                WHEN m.category = 'streaming' THEN 15.99
                ELSE round(m.base * (0.5 + (c * 389 + s * 1237) % 101 / 100.0), 2)
           END::DECIMAL(12, 2) AS amount,
           CASE WHEN (c * 17 + s * 29) % 97 = 0 THEN 'declined' ELSE 'approved' END AS status
    FROM generate_series(1, 300) AS a(c),
         generate_series(1, 10) AS b(s),
         (VALUES (0, 'Aurora Cafe', 'coffee', 'pos', 6.40),
                 (1, 'Greenleaf Grocers', 'grocery', 'pos', 58.00),
                 (2, 'Northgate Fuel', 'fuel', 'pos', 46.00),
                 (3, 'Metro Transit', 'transit', 'pos', 2.90),
                 (4, 'Lumen Stream', 'streaming', 'ecom', 15.99),
                 (5, 'Parcelo Market', 'marketplace', 'ecom', 42.00),
                 (6, 'Voltix Electronics', 'electronics', 'ecom', 260.00),
                 (7, 'Juniper Pharmacy', 'pharmacy', 'pos', 21.00),
                 (8, 'Harbor Bistro', 'restaurant', 'pos', 48.00),
                 (9, 'Swift Rides', 'rides', 'ecom', 17.50),
                 (10, 'Cedar Books', 'books', 'pos', 24.00),
                 (11, 'Atlas Hardware', 'home', 'pos', 71.00),
                 (12, 'Cash machine', 'cash', 'atm', 0.00))
         AS m(k, merchant, category, channel, base)
    WHERE m.k = (c * 5 + s * 7) % 13
      AND (c * 7919 + s * 104729) % 1000 < 800
    UNION ALL
    SELECT ts::TIMESTAMP, card_id, customer_id, merchant, category, channel, country,
           amount::DECIMAL(12, 2), status
    FROM (VALUES
        ('2026-11-06 02:14:05', 'tok_0217', 217, 'Parcelo Market',     'marketplace', 'ecom', 'US',    1.00, 'approved'),
        ('2026-11-06 02:14:51', 'tok_0217', 217, 'Lumen Stream',       'streaming',   'ecom', 'US',    1.99, 'declined'),
        ('2026-11-06 02:15:38', 'tok_0217', 217, 'Pixelplay Games',    'gaming',      'ecom', 'US',    2.50, 'approved'),
        ('2026-11-06 02:16:20', 'tok_0217', 217, 'Nimbus Gifts',       'gifts',       'ecom', 'US',    1.00, 'declined'),
        ('2026-11-06 02:17:02', 'tok_0217', 217, 'Swift Rides',        'rides',       'ecom', 'US',    1.50, 'approved'),
        ('2026-11-06 02:18:15', 'tok_0217', 217, 'Parcelo Market',     'marketplace', 'ecom', 'US',    2.00, 'approved'),
        ('2026-11-06 02:19:40', 'tok_0217', 217, 'Pixelplay Games',    'gaming',      'ecom', 'US',    1.00, 'approved'),
        ('2026-11-06 02:21:12', 'tok_0217', 217, 'Voltix Electronics', 'electronics', 'ecom', 'US', 1249.00, 'approved'),
        ('2026-11-06 03:05:40', 'tok_0261', 261, 'Nimbus Gifts',       'gifts',       'ecom', 'US',    1.00, 'approved'),
        ('2026-11-06 03:12:15', 'tok_0261', 261, 'Pixelplay Games',    'gaming',      'ecom', 'US',    1.50, 'declined'),
        ('2026-11-06 03:19:50', 'tok_0261', 261, 'Parcelo Market',     'marketplace', 'ecom', 'US',    2.00, 'approved'),
        ('2026-11-06 03:26:30', 'tok_0261', 261, 'Swift Rides',        'rides',       'ecom', 'US',    1.00, 'approved'),
        ('2026-11-06 03:33:05', 'tok_0261', 261, 'Voltix Electronics', 'electronics', 'ecom', 'US',  980.00, 'approved'),
        ('2026-11-06 13:18:30', 'tok_0192', 192, 'Mayfair Jewellers',  'jewelry',     'pos',  'GB',  890.00, 'approved')
    ) AS f(ts, card_id, customer_id, merchant, category, channel, country, amount, status)
);

SELECT count(*) AS transactions, count(DISTINCT card_id) AS cards FROM transactions;

-- A week of account transfers, 2026-10-31 to 2026-11-06: salaries in,
-- payments out, cash deposits. Customer 144 deposits cash four times just
-- under 10,000; three customers send a payment "as instructed".
INSERT INTO transfers
SELECT row_number() OVER (ORDER BY ts, customer_id) AS transfer_id, *
FROM (
    SELECT TIMESTAMP '2026-10-31 08:00:00' + INTERVAL '1 minute' * (c * 37 % 600) AS ts,
           c AS customer_id, 'wire_in' AS kind,
           (2000 + c * 173 % 4000)::DECIMAL(12, 2) AS amount, 'Salary October' AS reference
    FROM generate_series(2, 300, 2) AS g(c)
    UNION ALL
    SELECT TIMESTAMP '2026-10-31 09:00:00' + INTERVAL '1 day' * (c % 7) + INTERVAL '1 minute' * (c * 53 % 540),
           c, 'wire_out', (150 + c * 211 % 2350)::DECIMAL(12, 2), r.reference
    FROM generate_series(3, 300, 3) AS g(c)
    JOIN (VALUES (0, 'Rent November'), (1, 'Invoice 2219 design services'),
                 (2, 'Loan repayment'), (3, 'Savings transfer'), (4, 'Gift for Anna'),
                 (5, 'Car repair invoice 5530'), (6, 'Tuition fall term'),
                 (7, 'Utilities October'), (8, 'Contractor payment kitchen'),
                 (9, 'Holiday deposit'))
         AS r(k, reference) ON r.k = c % 10
    UNION ALL
    SELECT TIMESTAMP '2026-10-31 10:00:00' + INTERVAL '1 day' * (c % 7) + INTERVAL '1 minute' * (c * 29 % 420),
           c, 'cash_deposit', (200 + c * 97 % 2800)::DECIMAL(12, 2), 'Branch cash deposit'
    FROM generate_series(11, 300, 11) AS g(c)
    UNION ALL
    SELECT ts::TIMESTAMP, customer_id, kind, amount::DECIMAL(12, 2), reference
    FROM (VALUES
        ('2026-10-31 10:12:00', 144, 'cash_deposit',  9400.00, 'Branch cash deposit'),
        ('2026-11-02 14:40:00', 144, 'cash_deposit',  9850.00, 'Branch cash deposit'),
        ('2026-11-04 09:55:00', 144, 'cash_deposit',  9600.00, 'Branch cash deposit'),
        ('2026-11-05 16:20:00', 144, 'cash_deposit',  9900.00, 'Branch cash deposit'),
        ('2026-11-01 11:05:00',  56, 'cash_deposit',  9500.00, 'Branch cash deposit'),
        ('2026-11-03 12:30:00',  56, 'cash_deposit', 12000.00, 'Branch cash deposit'),
        ('2026-11-03 11:20:00', 233, 'wire_out',      4800.00, 'Urgent transfer to safe account as instructed by bank security'),
        ('2026-11-04 16:05:00',  78, 'wire_out',      2250.00, 'Crypto wallet top up as instructed'),
        ('2026-11-05 10:30:00',  12, 'wire_out',      1900.00, 'Tax office payment as instructed by phone')
    ) AS x(ts, customer_id, kind, amount, reference)
);

-- One feature vector per transaction: amount, time of day, online, abroad,
-- small online payments on the card in the hour before, high-risk category.
INSERT INTO tx_features
SELECT tx_id,
       [round(ln(1 + amount) / 8, 3),
        round(extract(epoch FROM ts::TIME) / 86400, 3),
        CASE WHEN channel = 'ecom' THEN 1 ELSE 0 END,
        CASE WHEN country <> 'US' THEN 1 ELSE 0 END,
        least(1, count(*) FILTER (WHERE amount < 5 AND channel = 'ecom') OVER (
                     PARTITION BY card_id ORDER BY ts
                     RANGE BETWEEN INTERVAL '60 minutes' PRECEDING
                               AND INTERVAL '1 second' PRECEDING) / 4.0),
        CASE WHEN category IN ('electronics', 'jewelry') THEN 1 ELSE 0 END
       ]::FLOAT[6]
FROM transactions;

-- The two purchases cardholders have disputed so far.
INSERT INTO fraud_reports
SELECT tx_id, TIMESTAMP '2026-11-06 09:30:00', 'Cardholder did not make the purchase'
FROM transactions WHERE card_id = 'tok_0217' AND amount = 1249.00
UNION ALL
SELECT tx_id, TIMESTAMP '2026-11-06 15:10:00', 'Cardholder was at home in the US'
FROM transactions WHERE card_id = 'tok_0192' AND country = 'GB';

VACUUM (REFRESH_TABLE) watchlist;
VACUUM (REFRESH_TABLE) transfers;
VACUUM (REFRESH_TABLE) tx_features;

SELECT (SELECT count(*) FROM customers) AS customers,
       (SELECT count(*) FROM transactions) AS transactions,
       (SELECT count(*) FROM transfers) AS transfers,
       (SELECT count(*) FROM watchlist) AS watchlist,
       (SELECT count(*) FROM fraud_reports) AS fraud_reports;
303 customers, 2,421 card payments, 286 transfers, 8 watchlist entries and 2 fraud reports. VACUUM (REFRESH_TABLE) makes the new rows searchable at once; the indexes otherwise refresh every second.
step 04 / 05 · sql
INSERT INTO alerts
SELECT 'velocity', tx_id, card_id, ts, n_10m || ' payments in 10 minutes'
FROM (
    SELECT tx_id, card_id, ts, count(*) OVER w AS n_10m
    FROM transactions
    WINDOW w AS (PARTITION BY card_id ORDER BY ts
                 RANGE BETWEEN INTERVAL '10 minutes' PRECEDING AND CURRENT ROW)
)
WHERE n_10m >= 5;

INSERT INTO alerts
SELECT 'travel', tx_id, card_id, ts, prev_country || ' → ' || country || ' in ' || (ts - prev_ts)
FROM (
    SELECT tx_id, card_id, ts, country,
           lag(country) OVER w AS prev_country,
           lag(ts) OVER w AS prev_ts
    FROM transactions
    WHERE channel IN ('pos', 'atm')
    WINDOW w AS (PARTITION BY card_id ORDER BY ts)
)
WHERE country <> prev_country AND ts - prev_ts < INTERVAL '2 hours';

SELECT rule, count(*) AS alerts FROM alerts GROUP BY rule ORDER BY rule;

SELECT rule, ts::TIME AS t, card_id, detail
FROM alerts
ORDER BY ts;
Four velocity alerts on tok_0217, from its fifth payment in ten minutes, and one travel alert on tok_0192: a payment in the UK 51 minutes after its last card payment in the US.
step 05 / 05 · sql
SELECT c.full_name, w.name AS listed_as, w.list,
       round(jaro_winkler_similarity(strip_accents(lower(c.full_name)),
                                     strip_accents(lower(w.name))), 3) AS score,
       c.birth_date = w.birth_date AS same_birth_date
FROM customers c
JOIN watchlist w
  ON jaro_winkler_similarity(strip_accents(lower(c.full_name)),
                             strip_accents(lower(w.name))) >= 0.85
ORDER BY score DESC;

WITH nearest AS (
    SELECT tx_id, round(features <-> [0.891, 0.098, 1, 0, 1, 1]::FLOAT[6], 3) AS distance
    FROM tx_features_idx
    ORDER BY features <-> [0.891, 0.098, 1, 0, 1, 1]::FLOAT[6]
    LIMIT 4
)
SELECT n.tx_id, t.ts::TIME AS t, t.card_id, t.amount, n.distance,
       r.tx_id IS NOT NULL AS reported
FROM nearest n
JOIN transactions t USING (tx_id)
LEFT JOIN fraud_reports r USING (tx_id)
ORDER BY n.distance, n.tx_id;

SELECT coalesce(t.channel, 'all') AS channel,
       count(*) AS transactions,
       sum(t.amount) FILTER (WHERE t.status = 'approved') AS approved_usd,
       round(100.0 * count(*) FILTER (WHERE t.status = 'declined') / count(*), 1) AS declined_pct,
       count(a.tx_id) AS flagged,
       coalesce(sum(t.amount) FILTER (WHERE r.tx_id IS NOT NULL), 0) AS reported_usd
FROM transactions t
LEFT JOIN alerts a ON a.tx_id = t.tx_id
LEFT JOIN fraud_reports r ON r.tx_id = t.tx_id
GROUP BY ROLLUP (t.channel)
ORDER BY t.channel NULLS LAST;
Two new customers match a listed name and its birth date; Maria Lindqvist scores 0.866 against a different birth date. tok_0261's 980.00 purchase sits 0.058 from the reported fraud; the next two, ordinary electronics purchases, are 1.045 and 1.046 away. The report counts the five alerts and 2,139.00 of reported fraud next to each channel's payments.

Use cases

What fraud, AML and risk teams build on SereneDB

AML: cash deposits kept just under a reporting threshold

Structuring splits cash into deposits that each stay under the amount that would be reported, 10,000 in this example. In SQL it is a band, a window and a count per customer: customer 144 deposited cash four times in six days, from 9,400.00 to 9,900.00. The band, the seven days and the count of three are yours to set.

built with
amount band7 daysHAVING count(*) >= 3
Real-time analytics →
example · cash deposits from 9,000 to 9,999.99 in 7 days
SELECT customer_id, count(*) AS deposits, sum(amount) AS total_usd,
       min(ts)::DATE AS first, max(ts)::DATE AS last
FROM transfers
WHERE kind = 'cash_deposit'
  AND amount >= 9000 AND amount < 10000
  AND ts >= TIMESTAMP '2026-11-07 00:00:00' - INTERVAL '7 days'
GROUP BY customer_id
HAVING count(*) >= 3;
result
customer_iddepositstotal_usdfirstlast
144438750.002026-10-312026-11-05
Customer 144 deposited 38,750.00 in four deposits. Customer 56's two deposits, 9,500.00 and 12,000.00, put only one in the band.

KYC: check an applicant against the list as they sign up

At sign-up there is one name to check, so the inverted index on the watchlist does the work: each word of the applicant's name matches within one edit, and the birth date confirms. Victor Kesling, two letters off, finds Viktor Kessling, listed with the same birth date, 1971-03-14.

built with
ts_levenshteinperson_namesbirth_date
Full-text search →
example · the applicant Victor Kesling, born 1971-03-14
SELECT entry_id, name, birth_date, list
FROM watchlist_idx
WHERE name @@ ts_levenshtein('victor', 1)
  AND name @@ ts_levenshtein('kesling', 1);
result
entry_idnamebirth_datelist
1Viktor Kessling1971-03-14sanctions
victor is one edit from viktor and kesling one from kessling. The entry's birth date is the applicant's.

Investigations: search what customers wrote on their transfers

Payment references are free text. When one transfer turns out to be a scam, a phrase search finds the others that read the same way, across customers, amounts and days, and each hit is a row to join with the account and its card payments.

built with
ts_phrasepayment_textkind = 'wire_out'
Full-text search →
example · outgoing transfers that say “as instructed”
SELECT ts::DATE AS day, customer_id, amount, reference
FROM transfers_idx
WHERE kind = 'wire_out'
  AND reference @@ ts_phrase('as instructed')
ORDER BY ts;
result
daycustomer_idamountreference
2026-11-032334800.00Urgent transfer to safe account as instructed by bank security
2026-11-04782250.00Crypto wallet top up as instructed
2026-11-05121900.00Tax office payment as instructed by phone
Three customers on three days, 8,950.00 in all. None of the other 100 outgoing transfers has the phrase.

FAQ

Frequently asked questions

Go to docs →

A finance data platform is where a bank, payment company or fintech keeps its transaction, customer and reference data, and what its fraud checks, anti-money-laundering (AML) monitoring and reporting read from. Its parts are ingestion, storage and query, and the applications on top. SereneDB is the storage and query part, in SQL.

Ingestion of payments as they happen, rules over each card's or account's recent history, name screening that tolerates typos and spellings, a way to compare a payment with known fraud, search over free text such as payment references, reports over the same data, scheduled jobs and access control. The checklist above maps each to its SereneDB feature and docs.

Fraud detection compares each transaction with what is expected of the card or account: rules such as velocity limits and impossible travel, scores from models, and comparisons with fraud already reported. On this page the rules and the comparison are SQL over the payments table, and a model's score can be stored next to each payment as a column.

As a window over each card's payments: count(*) OVER (PARTITION BY card_id ORDER BY ts RANGE BETWEEN INTERVAL '10 minutes' PRECEDING AND CURRENT ROW) counts the payments in the last 10 minutes, and a WHERE on that count is the rule. The lab's Velocity question flags tok_0217 from its fifth payment.

One that answers fraud checks on payments as they arrive and the analytics over the same rows, without copying them between systems. SereneDB runs the rules, the name screening, the vector comparison and the reports as SQL on one set of tables, over the PostgreSQL protocol; the architecture above follows a payment through all four.

Fraud detection looks for payments the customer did not make or was tricked into making. Anti-money laundering looks for money moved to hide where it came from, and screens customers against sanctions and other watchlists. Both read the same transactions and customer records, so one platform can serve both.

Normalize both names, then score them: strip_accents(lower(name)) folds case and accents, and jaro_winkler_similarity or damerau_levenshtein compares the results. Keep a second key such as the birth date: in the lab, Maria Lindqvist scores 0.866 against a listed name with another birth date. For one name at a time, an inverted index matches each word within an edit distance with ts_levenshtein.

Know your customer (KYC) checks confirm who a new customer is and whether they appear on a sanctions or other watchlist before the account opens. The KYC example above checks one applicant's name and birth date against the list as they sign up.

Yes, with a vector per transaction and a nearest-neighbour query. Here each payment's vector holds its amount, time of day, channel, country, recent small online payments and category; an HNSW index returns the payments nearest the reported one, and tok_0261's purchase, 0.058 away, is one the velocity rule missed.

Yes. A payment is in the table as soon as its INSERT commits, so a check before approval is a query over the card's last minutes, as in the architecture's ingest stage. Rules over all cards can run as a job: CREATE JOB fraud_rules EVERY 5 MINUTES writes what they flag to an alerts table.

With roles and grants, as in PostgreSQL. A column grant lets an analyst role read amounts, merchants and times but not card tokens or customer ids, and a query that asks for card_id fails with permission denied.

Yes, under the Apache-2.0 license, with its source on GitHub. Its Docker image runs the walkthrough above with one command.

> get started

Get started with SereneDB

Start SereneDB, load your payments, customers and watchlists, and run fraud rules, name screening and reports in SQL from any PostgreSQL client.