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:

sql
-- 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#

sql
-- 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;
sql
-- 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.

sql
-- 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
sql
-- 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.

sql
-- 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;
sql
-- 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'
);
sql
-- 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;
sql
-- 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#

sql
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;
sql
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;
sql
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.

sql
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.

typescript
// 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.

Full explanation.

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.

Full explanation.

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.

Full explanation.

Mid-Level QA Automation Engineer — Payment Orchestration · CV refreshed 2026-10-16. Prepared from reviewed role-specific sources.