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 | shWhat 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.
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;
| t | card_id | merchant | amount | n_10m | usd_10m |
|---|---|---|---|---|---|
| 02:17:02 | tok_0217 | Swift Rides | 1.50 | 5 | 7.99 |
| 02:18:15 | tok_0217 | Parcelo Market | 2.00 | 6 | 9.99 |
| 02:19:40 | tok_0217 | Pixelplay Games | 1.00 | 7 | 10.99 |
| 02:21:12 | tok_0217 | Voltix Electronics | 1249.00 | 8 | 1259.99 |
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';
| card_id | route | before | after | gap | amount |
|---|---|---|---|---|---|
| tok_0192 | US → GB | 12:26:46 | 13:18:30 | 00:51:44 | 890.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.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;
| full_name | listed_as | list | score | same_birth_date |
|---|---|---|---|---|
| Joaquin Ferrandis Ocana | Joaquín Ferrándiz Ocaña | sanctions | 0.983 | t |
| Victor Kesling | Viktor Kessling | sanctions | 0.904 | t |
| Maria Lindqvist | Marja Lindkvist | sanctions | 0.866 | f |
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;
| tx_id | t | card_id | amount | distance | reported |
|---|---|---|---|---|---|
| 27 | 02:21:12 | tok_0217 | 1249.00 | 0 | t |
| 51 | 03:33:05 | tok_0261 | 980.00 | 0.058 | f |
| 370 | 08:36:06 | tok_0112 | 351.00 | 1.045 | f |
| 340 | 08:22:39 | tok_0073 | 296.40 | 1.046 | f |
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;
| channel | transactions | approved_usd | declined_pct | flagged | reported_usd |
|---|---|---|---|---|---|
| atm | 179 | 21200.00 | 0.6 | 0 | 0.00 |
| ecom | 774 | 68312.46 | 1.4 | 4 | 1249.00 |
| pos | 1468 | 51323.21 | 1.1 | 1 | 890.00 |
| all | 2421 | 140835.67 | 1.2 | 5 | 2139.00 |
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.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.
| Need | How SereneDB does it | Looks like |
|---|---|---|
| Take in payments as they happen | The 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 history | Window 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 ways | jaro_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-up | An 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 fraud | One 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 references | Phrase 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 tables | FILTER, 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 schedule | A 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 what | Roles 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 files | COPY 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) |
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
What fraud and AML work needs from one engine
transactions · transfers · watchlistA 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 >= 5Case and accents folded, then Jaro-Winkler: Victor Kesling scores 0.904 against Viktor Kessling.
jaro_winkler_similarity(…) >= 0.85The reported fraud's nearest payment, 0.058 away, is one the velocity rule missed.
features <-> [0.891, 0.098, 1, 0, 1, 1]SearchBench, 92 queries over 1B OpenTelemetry log rows: all 92 under the 60 s cap, median 31.0 ms.
otel_logs_1b · 26.09.1Tutorial: 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.
docker run -d --name serenedb -p 7890:7890 \ -e POSTGRES_PASSWORD=<password> serenedb/serenedb:26.11.2 psql -h localhost -p 7890 -U postgres
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'));
-- 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;
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;
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;
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.
Real-time analytics →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;
| customer_id | deposits | total_usd | first | last |
|---|---|---|---|---|
| 144 | 4 | 38750.00 | 2026-10-31 | 2026-11-05 |
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.
Full-text search →SELECT entry_id, name, birth_date, list FROM watchlist_idx WHERE name @@ ts_levenshtein('victor', 1) AND name @@ ts_levenshtein('kesling', 1);
| entry_id | name | birth_date | list |
|---|---|---|---|
| 1 | Viktor Kessling | 1971-03-14 | sanctions |
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.
Full-text search →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;
| day | customer_id | amount | reference |
|---|---|---|---|
| 2026-11-03 | 233 | 4800.00 | Urgent transfer to safe account as instructed by bank security |
| 2026-11-04 | 78 | 2250.00 | Crypto wallet top up as instructed |
| 2026-11-05 | 12 | 1900.00 | Tax office payment as instructed by phone |
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 with SereneDB
Start SereneDB, load your payments, customers and watchlists, and run fraud rules, name screening and reports in SQL from any PostgreSQL client.