SQL and events · Transaction integrity and reconciliation
The 60-second model#
Reconciliation compares business identities and values across representations, not merely row counts. Asynchronous processing introduces lag and duplicate/out-of-order delivery. Use a declared observation window, correlation IDs and an agreed consistency boundary before classifying a mismatch.
SQL/Kafka are strengths in your current CV, not a confirmed OMNO database/message stack. These PostgreSQL examples assume a hypothetical sandbox schema:
-- Teaching schema only; do not run schema changes against an employer database.
CREATE TABLE payments (
id text PRIMARY KEY, merchant_id text NOT NULL, amount_minor bigint NOT NULL,
currency text NOT NULL, status text NOT NULL, created_at timestamptz NOT NULL
);
CREATE TABLE attempts (
id text PRIMARY KEY, payment_id text NOT NULL, provider_reference text,
status text NOT NULL, created_at timestamptz NOT NULL
);
CREATE TABLE ledger (
id text PRIMARY KEY, payment_id text NOT NULL, kind text NOT NULL,
amount_minor bigint NOT NULL, currency text NOT NULL
);
CREATE TABLE events (
id text PRIMARY KEY, payment_id text NOT NULL, payload jsonb NOT NULL,
created_at timestamptz NOT NULL
);
Filtering, NULL and stable windows#
-- Failed payments for one merchant in a half-open UTC day.
SELECT id, amount_minor, currency FROM payments
WHERE merchant_id = 'merchant-sandbox' AND status IN ('DECLINED', 'FAILED')
AND created_at >= TIMESTAMPTZ '2026-10-01 00:00:00+00'
AND created_at < TIMESTAMPTZ '2026-10-02 00:00:00+00'
ORDER BY created_at, id LIMIT 50;
-- NULL is unknown: `= NULL` does not select missing references.
SELECT id, COALESCE(provider_reference, '(missing)') AS reference
FROM attempts WHERE provider_reference IS NULL;
Parenthesize AND/OR combinations; BETWEEN includes both ends and can double-count shared timestamp boundaries. LIKE/ILIKE provide pattern/case-insensitive matching where supported. ORDER BY needs a stable tie-breaker; LIMIT without order is not a reliable “latest” result. OFFSET can shift under writes; keyset pagination uses the last ordered key.
-- Keyset continuation; next cursor is the prior page's last timestamp and id.
SELECT id, created_at FROM payments
WHERE (created_at, id) > (TIMESTAMPTZ '2026-10-01 12:00:00+00', 'pay-100')
ORDER BY created_at, id LIMIT 100;
Joins, duplicates and totals#
| Construct | QA purpose | Common trap |
|---|---|---|
| INNER JOIN | Matching payment/attempt rows | Missing parents disappear |
| LEFT JOIN | Preserve payment and detect absent child | Right-side WHERE predicate removes unmatched rows |
| FULL JOIN | Compare both source/target sides | Coalesce keys and handle NULL deliberately |
| EXISTS / NOT EXISTS | Presence/absence without multiplying parent rows | NOT IN behaves unexpectedly with NULL |
| GROUP BY / HAVING | Aggregate groups / filter aggregated condition | WHERE cannot filter aggregate output directly |
| DISTINCT | Remove repeated selected tuples | Can hide a bad join instead of fixing grain |
-- Candidate duplicate capture effect. Actual ledger grain must be agreed first.
SELECT payment_id, COUNT(*) AS capture_rows
FROM ledger WHERE kind = 'CAPTURE'
GROUP BY payment_id HAVING COUNT(*) > 1;
Partial captures may legitimately produce multiple rows; refine this query with operation/deduplication identity and actual business rules. A query flags candidates, not automatic proof of a defect.
-- Orphan attempts in the declared schema (which intentionally has no FK here).
SELECT a.id, a.payment_id FROM attempts a
LEFT JOIN payments p ON p.id = a.payment_id WHERE p.id IS NULL;
-- Captured payment missing any capture projection.
SELECT p.id FROM payments p WHERE p.status = 'CAPTURED'
AND NOT EXISTS (
SELECT 1 FROM ledger l WHERE l.payment_id = p.id AND l.kind = 'CAPTURE'
);
-- Aggregate BEFORE joining so multiple attempts cannot multiply money totals.
WITH captured AS (
SELECT payment_id, currency, SUM(amount_minor) AS captured_minor
FROM ledger WHERE kind = 'CAPTURE' GROUP BY payment_id, currency
)
SELECT p.id, p.amount_minor, c.captured_minor
FROM payments p LEFT JOIN captured c ON c.payment_id = p.id AND c.currency = p.currency
WHERE p.status = 'CAPTURED' AND p.amount_minor IS DISTINCT FROM c.captured_minor;
-- Currency-separated totals and basic statistics.
SELECT currency, COUNT(*) AS payments, SUM(amount_minor) AS total_minor,
MIN(amount_minor), MAX(amount_minor), AVG(amount_minor)
FROM payments WHERE status = 'CAPTURED'
GROUP BY currency HAVING COUNT(*) >= 1;
Do not sum GEL and EUR into a single meaningful financial total without an explicit conversion/valuation contract. Fees, partial capture, refunds, settlement dates and rounding can make payment gross amount differ from settlement reports legitimately.
Latest state and event inspection#
WITH ranked AS (
SELECT a.*, ROW_NUMBER() OVER (
PARTITION BY payment_id ORDER BY created_at DESC, id DESC
) AS rn FROM attempts a
)
SELECT payment_id, id, status FROM ranked WHERE rn = 1;
SELECT payment_id, id,
payload->>'status' AS business_status,
payload->'provider' AS provider_json
FROM events WHERE payload @> '{"status":"CAPTURED"}'::jsonb
ORDER BY created_at, id;
SELECT id, payment_id, status,
LAG(status) OVER (PARTITION BY payment_id ORDER BY created_at, id) AS previous_status
FROM attempts;
ROW_NUMBER chooses a single deterministic ordered row; RANK shares positions for ties. Timestamp alone may not determine business event order: use the documented sequence/version where appropriate.
Safe reads, transactions and query cost#
Use parameterized queries (WHERE payment_id = $1 with bound values in a supporting driver), not string concatenation. Values are parameterized; dynamic identifiers require allowlisting/composition appropriate to the driver. Authorized read-only access still needs bounded queries and privacy controls.
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT COUNT(*) FROM payments WHERE status = 'CAPTURED';
SELECT COUNT(*) FROM ledger WHERE kind = 'CAPTURE';
ROLLBACK;
PostgreSQL read committed normally takes a snapshot per statement; repeatable read provides a stable transaction snapshot. Serializable can require retrying a transaction after serialization failure, but does not make external financial side effects safe to replay. ACID means atomicity, consistency of declared invariants, isolation and durability. EXPLAIN estimates a plan; EXPLAIN ANALYZE executes it. Indexes improve selective reads/joins at write/storage cost; inspect actual plans and cardinality.
Async processing and bounded polling#
Flow: request accepted → event produced → broker/queue → consumer → DB/read model → API/UI. At-least-once delivery permits duplicates. Ordering is typically scoped to key/partition/configuration, not global. An idempotent consumer needs a business deduplication boundary, not just “the broker is reliable.” Kafka is one example from your experience; ask what OMNO actually uses.
// Scoped test body. Only read completion; do not repeat payment creation in poll.
let observed = 'PENDING';
await expect.poll(async () => {
const response = await request.get(`/sandbox/payments/${payment.id}`);
expect(response.status()).toBe(200);
const body: { status: string } = await response.json();
observed = body.status;
return ['CAPTURED', 'DECLINED', 'FAILED'].includes(observed);
}, { timeout: 15_000, intervals: [200, 500, 1000] }).toBe(true);
expect(observed).toBe('CAPTURED');
This stops on a known terminal result and then checks success. Schema/auth failures may be retried by expect.poll; a production helper should classify fatal failures explicitly. A projection is allowed to lag only within the actual contract. Reconciliation of a changing dataset needs snapshot/watermark and late-arrival rules, not arbitrary sleeps.
Interview answer and references#
30 seconds: “I compare keys, duplicates, values and currency-specific totals, with the right grain and time window. I pre-aggregate before one-to-many joins. For async flows I follow correlation identity and poll the supported final state, then check projections through authorized evidence. Equal counts alone are not proof of correctness.”
90-second answer: payment totals differ across systems
I first align source of truth, amount definition, currency, fees/refunds, timestamps and settlement window. I compare missing/unexpected keys, duplicate operations and per-payment amounts before aggregate totals. If a join includes attempts/events, I check fanout and pre-aggregate at the intended grain. For live event processing I use a stable snapshot or explicit watermark and permitted lag. I follow mismatched payment/provider references into sanitized service/event evidence. I use parameterized bounded reads and avoid exposing customer data. I distinguish an actual integrity defect from a valid difference in reporting definitions before filing a financial-impact bug.
Memory hook: keys → grain → window → values → totals. References: PostgreSQL SELECT, isolation, JSON functions. Next: CI/Git/debugging.
Quick practice#
What does HTTP 202 mean?
- The request is accepted under the contract, not necessarily completed
- Observe a supported terminal business state and resulting effects separately.
Pitfall: Comparing a transport code directly with CAPTURED.
Why can totals double after a join?
- One payment can match multiple attempts/events and multiply its amount
- Establish grain and pre-aggregate or use EXISTS when only presence matters.
Pitfall: DISTINCT blindly hides an incorrect financial query.
How do you find a missing projection?
- Compare source/payment identity with NOT EXISTS or a deliberate outer join
- Allow only documented processing lag using stable windows or barriers.
Pitfall: Equal counts imply the same records exist.