diff --git a/internal/hold/scanner.go b/internal/hold/scanner.go deleted file mode 100644 index 6181d29..0000000 --- a/internal/hold/scanner.go +++ /dev/null @@ -1,313 +0,0 @@ -package hold - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "log/slog" - "sync" - "time" - - "github.com/IDEA-Amrita/paystable/internal/gateway" -) - -// 1)StartExpiryScanner runs a background loop that ticks every 30 seconds -// to scan and process holds that have reached their expiration time. -func StartExpiryScanner(ctx context.Context, db *sql.DB, clientFactory func(string) gateway.GatewayClient) { - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - - slog.Info("hold expiry scanner started", "interval", "30s") - - for { - select { - case <-ctx.Done(): - slog.Info("hold expiry scanner stopping") - return - case <-ticker.C: //communication channel for ticker - scanExpiredHolds(ctx, db, clientFactory) - } - } -} - -// scanExpiredHolds claims up to 10 expired PENDING holds, transitions them -// to VERIFYING status, and processes them concurrently in a worker pool. -func scanExpiredHolds(ctx context.Context, db *sql.DB, clientFactory func(string) gateway.GatewayClient) { - tx, err := db.BeginTx(ctx, nil) - if err != nil { - slog.Error("expiry scanner: begin tx failed", "error", err) - return - } - defer func() { _ = tx.Rollback() }() - - rows, err := tx.QueryContext(ctx, ` - SELECT txn_id, gateway, amount - FROM holds - WHERE (status = 'PENDING' AND expires_at <= now()) - OR (status = 'VERIFYING' AND updated_at <= now() - interval '5 minutes') - ORDER BY expires_at - LIMIT 10 - FOR UPDATE OF holds SKIP LOCKED`) - if err != nil { - slog.Error("expiry scanner: query failed", "error", err) - _ = tx.Rollback() - return - } - - type expiredHold struct { - TxnID string - Gateway string - Amount int64 - } - var batch []expiredHold - for rows.Next() { - var h expiredHold - if err := rows.Scan(&h.TxnID, &h.Gateway, &h.Amount); err != nil { - slog.Error("expiry scanner: scan failed", "error", err) - continue - } - batch = append(batch, h) - } - if err := rows.Close(); err != nil { - slog.Error("expiry scanner: close rows failed", "error", err) - _ = tx.Rollback() - return - } - - if err := rows.Err(); err != nil { - slog.Error("expiry scanner: rows iteration failed", "error", err) - _ = tx.Rollback() - return - } - - if len(batch) == 0 { - _ = tx.Rollback() - return - } - - for _, h := range batch { - if _, err := tx.ExecContext(ctx, - `UPDATE holds SET status='VERIFYING' WHERE txn_id=$1`, h.TxnID, - ); err != nil { - slog.Error("expiry scanner: claim update failed", "txn_id", h.TxnID, "error", err) - _ = tx.Rollback() - return - } - } - - if err := tx.Commit(); err != nil { - slog.Error("expiry scanner: commit claim tx failed", "error", err) - return - } - - const maxConcurrent = 5 - sem := make(chan struct{}, maxConcurrent) - var wg sync.WaitGroup - -Loop: - for _, h := range batch { - h := h - select { - case sem <- struct{}{}: - case <-ctx.Done(): - break Loop - } - wg.Add(1) - go func() { - defer wg.Done() - defer func() { <-sem }() // release slot when done - processExpiredHold(ctx, db, clientFactory, h.TxnID, h.Gateway, h.Amount) - }() - } - - wg.Wait() // wait for all goroutines to finish before returning -} - -// 3)processExpiredHold performs a final status check on a single expired hold -// by querying the external gateway and resolving the hold state accordingly. -func processExpiredHold(ctx context.Context, db *sql.DB, clientFactory func(string) gateway.GatewayClient, txnID, gw string, holdAmount int64) { - client := clientFactory(gw) - if client == nil { - slog.Error("expiry scanner: no client for gateway", "gateway", gw, "txn_id", txnID) - if err := finalizeAsIndeterminate(ctx, db, txnID, "no_gateway_client", 0, holdAmount); err != nil { - slog.Error("expiry scanner: finalizeAsIndeterminate failed", "txn_id", txnID, "error", err) - } - return - } - - cctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - - gatewayStatus, gatewayAmount, _, err := client.Status(cctx, txnID) - if err != nil { - slog.Error("expiry scanner: gateway call failed", "txn_id", txnID, "error", err) - if err2 := finalizeAsIndeterminate(ctx, db, txnID, "gateway_error", 0, holdAmount); err2 != nil { - slog.Error("expiry scanner: finalizeAsIndeterminate failed", "txn_id", txnID, "error", err2) - } - return - } - - slog.Info("expiry scanner: gateway responded", "txn_id", txnID, "gateway_status", gatewayStatus, "gateway_amount", gatewayAmount) - - switch { - case isSuccess(gatewayStatus) && gatewayAmount == holdAmount: - // Gateway confirms success and the amount matches — CONFIRMED. - if err := finalizeAsConfirmed(ctx, db, txnID); err != nil { - slog.Error("expiry scanner: finalizeAsConfirmed failed", "txn_id", txnID, "error", err) - } - case isSuccess(gatewayStatus) && gatewayAmount != holdAmount: - if err := finalizeAsMismatch(ctx, db, txnID, gatewayAmount, holdAmount); err != nil { - slog.Error("expiry scanner: finalizeAsMismatch failed", "txn_id", txnID, "error", err) - } - default: - reason := "ttl_expired_gateway_status: " + gatewayStatus - if gatewayStatus == "failed" || gatewayStatus == "failure" { - if err := finalizeAsFailed(ctx, db, txnID, reason); err != nil { - slog.Error("expiry scanner: finalizeAsFailed failed", "txn_id", txnID, "error", err) - } - return - } - if err := finalizeAsIndeterminate(ctx, db, txnID, reason, gatewayAmount, holdAmount); err != nil { - slog.Error("expiry scanner: finalizeAsIndeterminate failed", "txn_id", txnID, "error", err) - } - } -} - -// 4) isSuccess returns true if the status received from the external gateway -// represents a final successful payment status (e.g., success, captured). -func isSuccess(s string) bool { - switch s { - case "success", "captured", "completed": - return true - } - return false -} - -// 5)finalizeAsConfirmed transitions the expired hold to CONFIRMED and enqueues -// a confirmation event in the outbox table for delivery to the client. -func finalizeAsConfirmed(ctx context.Context, db *sql.DB, txnID string) error { - return finalizeHold(ctx, db, txnID, "CONFIRMED", "ttl_expiry_success", func(tx *sql.Tx) error { - payloadBytes, err := json.Marshal(struct { - TxnID string `json:"txn_id"` - Status string `json:"status"` - }{TxnID: txnID, Status: "CONFIRMED"}) - if err != nil { - return fmt.Errorf("marshal payload: %w", err) - } - idempotency := "evt_" + txnID + "_CONFIRMED" - _, err = tx.ExecContext(ctx, - `INSERT INTO outbox (txn_id, event_type, payload, idempotency_key, next_attempt_at) - VALUES ($1, 'transaction.CONFIRMED', $2, $3, now()) - ON CONFLICT (idempotency_key) DO NOTHING`, - txnID, json.RawMessage(payloadBytes), idempotency) - return err - }) -} - -// 6)finalizeAsFailed transitions the expired hold to FAILED and enqueues -// a failure event in the outbox table to notify the client application. -func finalizeAsFailed(ctx context.Context, db *sql.DB, txnID, reason string) error { - return finalizeHold(ctx, db, txnID, "FAILED", reason, func(tx *sql.Tx) error { - payloadBytes, err := json.Marshal(struct { - TxnID string `json:"txn_id"` - Status string `json:"status"` - Reason string `json:"reason"` - }{TxnID: txnID, Status: "FAILED", Reason: reason}) - if err != nil { - return fmt.Errorf("marshal payload: %w", err) - } - idempotency := "evt_" + txnID + "_FAILED_ttl_expiry" - _, err = tx.ExecContext(ctx, - `INSERT INTO outbox (txn_id, event_type, payload, idempotency_key, next_attempt_at) - VALUES ($1, 'transaction.FAILED', $2, $3, now()) - ON CONFLICT (idempotency_key) DO NOTHING`, - txnID, json.RawMessage(payloadBytes), idempotency) - return err - }) -} - -// 7)finalizeAsIndeterminate transitions the hold to INDETERMINATE when the payment -// amount reported by the gateway does not match the expected hold amount. -func finalizeAsMismatch(ctx context.Context, db *sql.DB, txnID string, gatewayAmount, holdAmount int64) error { - return finalizeWithReview(ctx, db, txnID, "MISMATCH", "amount_mismatch", gatewayAmount, holdAmount) -} - -func finalizeAsIndeterminate(ctx context.Context, db *sql.DB, txnID, reason string, gatewayAmount, holdAmount int64) error { - return finalizeWithReview(ctx, db, txnID, "INDETERMINATE", reason, gatewayAmount, holdAmount) -} - -func finalizeWithReview(ctx context.Context, db *sql.DB, txnID, status, reason string, gatewayAmount, holdAmount int64) error { - return finalizeHold(ctx, db, txnID, status, reason, func(tx *sql.Tx) error { - payloadBytes, err := json.Marshal(struct { - TxnID string `json:"txn_id"` - Status string `json:"status"` - Reason string `json:"reason"` - GatewayAmount int64 `json:"gateway_amount"` - HoldAmount int64 `json:"hold_amount"` - }{TxnID: txnID, Status: status, Reason: reason, GatewayAmount: gatewayAmount, HoldAmount: holdAmount}) - if err != nil { - return fmt.Errorf("marshal payload: %w", err) - } - idempotency := "evt_" + txnID + "_" + status - _, err = tx.ExecContext(ctx, - `INSERT INTO outbox (txn_id, event_type, payload, idempotency_key, next_attempt_at) - VALUES ($1, $2, $3, $4, now()) - ON CONFLICT (idempotency_key) DO NOTHING`, - txnID, "transaction."+status, json.RawMessage(payloadBytes), idempotency) - return err - }) -} - -// 8)finalizeHold is a helper that wraps the database state transitions, ledger logging, -// and event queue writing in a single, safe transaction. -func finalizeHold(ctx context.Context, db *sql.DB, txnID, toStatus, reason string, writeOutbox func(*sql.Tx) error) error { - tx, err := db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() - var fromStatus string - if err := tx.QueryRowContext(ctx, - `SELECT status FROM holds WHERE txn_id=$1 FOR UPDATE`, txnID, - ).Scan(&fromStatus); err != nil { - return err - } - - // Already finalised by another path (stabilizer, manual ops, etc.) — skip. - switch fromStatus { - case "CONFIRMED", "FAILED", "REFUNDED", "INDETERMINATE", "MISMATCH": - slog.Info("expiry scanner: hold already finalised, skipping", - "txn_id", txnID, "current_status", fromStatus) - return tx.Commit() - } - - // Transition the hold status. - if _, err := tx.ExecContext(ctx, - `UPDATE holds SET status=$1 WHERE txn_id=$2`, toStatus, txnID, - ); err != nil { - return err - } - - // Write an audit ledger entry so ops can see this came from the TTL sweeper. - detailBytes, err := json.Marshal(struct { - Reason string `json:"reason"` - }{Reason: reason}) - if err != nil { - return fmt.Errorf("marshal ledger detail: %w", err) - } - if _, err := tx.ExecContext(ctx, - `INSERT INTO ledger (txn_id, event_type, source, from_status, to_status, detail) - VALUES ($1, 'state_transition', 'ttl_sweeper', $2, $3, $4)`, - txnID, fromStatus, toStatus, json.RawMessage(detailBytes), - ); err != nil { - return err - } - - // Write the outbox event (delivery worker will send merchant callback). - if err := writeOutbox(tx); err != nil { - return err - } - - return tx.Commit() -} diff --git a/internal/hold/scanner_test.go b/internal/hold/scanner_test.go deleted file mode 100644 index 0785473..0000000 --- a/internal/hold/scanner_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package hold - -import ( - "context" - "database/sql" - "encoding/json" - "testing" - "time" - - "github.com/IDEA-Amrita/paystable/internal/gateway" - - _ "github.com/lib/pq" -) - -// mockGatewayClient lets each test control exactly what the gateway returns. -type mockGatewayClient struct { - status string - amount int64 - err error -} - -func (m *mockGatewayClient) Status(_ context.Context, _ string) (string, int64, json.RawMessage, error) { - return m.status, m.amount, nil, m.err -} - -// setupScannerTestDB creates an in-memory-style test hold row that looks -// expired (expires_at in the past) and returns the txn_id and a cleanup func. -// Requires a real Postgres instance pointed to by TEST_DATABASE_URL. -func setupScannerTestDB(t *testing.T, status string) (*sql.DB, string) { - t.Helper() - db := openTestDB(t) // shared helper from main_test.go - - txnID := "scanner_test_" + t.Name() + "_" + time.Now().Format("150405.000000000") - - _, err := db.Exec(` - INSERT INTO holds (txn_id, gateway, status, amount, currency, read_token, - callback_url, ttl_seconds, expires_at) - VALUES ($1, 'payu', $2, 5000, 'INR', $3, 'https://merchant.example/cb', 30, - now() - interval '1 second')`, - txnID, status, "pst_rt_"+txnID) - if err != nil { - t.Fatalf("insert test hold: %v", err) - } - return db, txnID -} - -// assertHoldStatus reads the hold status from DB and fails the test if it does not match. -func assertHoldStatus(t *testing.T, db *sql.DB, txnID, want string) { - t.Helper() - var got string - if err := db.QueryRow(`SELECT status FROM holds WHERE txn_id=$1`, txnID).Scan(&got); err != nil { - t.Fatalf("read hold status: %v", err) - } - if got != want { - t.Errorf("hold status = %q, want %q", got, want) - } -} - -// assertOutboxEvent checks that an outbox row for txnID exists with the given event_type. -func assertOutboxEvent(t *testing.T, db *sql.DB, txnID, eventType string) { - t.Helper() - var count int - if err := db.QueryRow( - `SELECT count(*) FROM outbox WHERE txn_id=$1 AND event_type=$2`, txnID, eventType, - ).Scan(&count); err != nil { - t.Fatalf("query outbox: %v", err) - } - if count == 0 { - t.Errorf("no outbox row for txn_id=%q event_type=%q", txnID, eventType) - } -} - -// assertLedgerSource checks that a ledger row for txnID was written by source='ttl_sweeper'. -func assertLedgerSource(t *testing.T, db *sql.DB, txnID string) { - t.Helper() - var count int - if err := db.QueryRow( - `SELECT count(*) FROM ledger WHERE txn_id=$1 AND source='ttl_sweeper'`, txnID, - ).Scan(&count); err != nil { - t.Fatalf("query ledger: %v", err) - } - if count == 0 { - t.Errorf("no ledger row with source='ttl_sweeper' for txn_id=%q", txnID) - } -} - -// ── Test Cases ────────────────────────────────────────────────────────────── - -// TestCase1: Hold expires, gateway says success + amount matches → CONFIRMED. -func TestScannerConfirmedOnSuccess(t *testing.T) { - db, txnID := setupScannerTestDB(t, "PENDING") - - factory := func(_ string) gateway.GatewayClient { - return &mockGatewayClient{status: "success", amount: 5000} - } - scanExpiredHolds(context.Background(), db, factory) - - assertHoldStatus(t, db, txnID, "CONFIRMED") - assertOutboxEvent(t, db, txnID, "transaction.CONFIRMED") - assertLedgerSource(t, db, txnID) -} - -// TestCase2: Hold expires, gateway says failed → FAILED. -func TestScannerFailedOnGatewayFailed(t *testing.T) { - db, txnID := setupScannerTestDB(t, "PENDING") - - factory := func(_ string) gateway.GatewayClient { - return &mockGatewayClient{status: "failed", amount: 0} - } - scanExpiredHolds(context.Background(), db, factory) - - assertHoldStatus(t, db, txnID, "FAILED") - assertOutboxEvent(t, db, txnID, "transaction.FAILED") - assertLedgerSource(t, db, txnID) -} - -// TestCase3: Hold expires, gateway call returns error/timeout → INDETERMINATE. -func TestScannerFailedOnGatewayError(t *testing.T) { - db, txnID := setupScannerTestDB(t, "PENDING") - - factory := func(_ string) gateway.GatewayClient { - return &mockGatewayClient{err: context.DeadlineExceeded} - } - scanExpiredHolds(context.Background(), db, factory) - - assertHoldStatus(t, db, txnID, "INDETERMINATE") - assertOutboxEvent(t, db, txnID, "transaction.INDETERMINATE") - assertLedgerSource(t, db, txnID) -} - -// TestCase4: Hold expires, gateway says success but amount mismatches → MISMATCH. -func TestScannerIndeterminateOnAmountMismatch(t *testing.T) { - db, txnID := setupScannerTestDB(t, "PENDING") - - factory := func(_ string) gateway.GatewayClient { - // Hold amount is 5000; gateway reports 3000 — mismatch. - return &mockGatewayClient{status: "success", amount: 3000} - } - scanExpiredHolds(context.Background(), db, factory) - - assertHoldStatus(t, db, txnID, "MISMATCH") - assertOutboxEvent(t, db, txnID, "transaction.MISMATCH") - assertLedgerSource(t, db, txnID) -} - -// TestCase5 (bonus): Hold already CONFIRMED before scanner runs → scanner is a no-op. -func TestScannerSkipsAlreadyFinalised(t *testing.T) { - db, txnID := setupScannerTestDB(t, "CONFIRMED") - - callCount := 0 - factory := func(_ string) gateway.GatewayClient { - callCount++ - return &mockGatewayClient{status: "success", amount: 5000} - } - scanExpiredHolds(context.Background(), db, factory) - - // The status constraint allows CONFIRMED → REFUNDED only, so the scanner - // must detect CONFIRMED and skip before touching the hold. - assertHoldStatus(t, db, txnID, "CONFIRMED") - if callCount > 0 { - t.Errorf("gateway called %d times, expected 0 — scanner should skip already-finalised holds", callCount) - } -} diff --git a/internal/stabilizer/ttl_test.go b/internal/stabilizer/ttl_test.go index fa41311..3aa7d87 100644 --- a/internal/stabilizer/ttl_test.go +++ b/internal/stabilizer/ttl_test.go @@ -44,13 +44,22 @@ func ttlTestDB(t *testing.T) *sql.DB { } func seedExpiredHold(t *testing.T, db *sql.DB, amount int64) string { + t.Helper() + return seedTTLHold(t, db, "VERIFYING", amount, true) +} + +func seedTTLHold(t *testing.T, db *sql.DB, status string, amount int64, expired bool) string { t.Helper() txnID := "ttl-" + strconv.Itoa(int(time.Now().UnixNano())) + expiresAt := time.Now().Add(time.Minute) + if expired { + expiresAt = time.Now().Add(-time.Minute) + } _, err := db.Exec(` INSERT INTO holds (txn_id, gateway, status, amount, currency, read_token, - callback_url, ttl_seconds, expires_at) - VALUES ($1, 'payu', 'VERIFYING', $2, 'INR', $3, 'http://x/cb', 300, now()-interval '1 minute')`, - txnID, amount, "tok_"+txnID) + callback_url, ttl_seconds, expires_at, metadata) + VALUES ($1, 'payu', $2, $3, 'INR', $4, 'http://x/cb', 300, $5, $6::jsonb)`, + txnID, status, amount, "tok_"+txnID, expiresAt, []byte(`{"source":"ttl_test"}`)) if err != nil { t.Fatalf("seed: %v", err) } @@ -70,6 +79,55 @@ func holdStatusOf(t *testing.T, db *sql.DB, txnID string) string { return s } +func requireLedgerTransition(t *testing.T, db *sql.DB, txnID, wantSource, wantToStatus string) { + t.Helper() + var source, toStatus string + if err := db.QueryRow(` + SELECT source, to_status + FROM ledger + WHERE txn_id=$1 AND event_type='state_transition' + ORDER BY created_at DESC + LIMIT 1`, txnID).Scan(&source, &toStatus); err != nil { + t.Fatalf("read ledger transition: %v", err) + } + if source != wantSource || toStatus != wantToStatus { + t.Fatalf("ledger transition = source:%q to:%q, want source:%q to:%q", source, toStatus, wantSource, wantToStatus) + } +} + +func requireOutboxPayload(t *testing.T, db *sql.DB, txnID, wantEventType, wantPayloadEvent, wantStatus string) { + t.Helper() + var eventType string + var payload json.RawMessage + if err := db.QueryRow(` + SELECT event_type, payload + FROM outbox + WHERE txn_id=$1 + ORDER BY created_at DESC + LIMIT 1`, txnID).Scan(&eventType, &payload); err != nil { + t.Fatalf("read outbox payload: %v", err) + } + if eventType != wantEventType { + t.Fatalf("outbox event_type = %q, want %q", eventType, wantEventType) + } + var body map[string]any + if err := json.Unmarshal(payload, &body); err != nil { + t.Fatalf("decode outbox payload: %v", err) + } + if body["event"] != wantPayloadEvent { + t.Fatalf("payload event = %v, want %s", body["event"], wantPayloadEvent) + } + if body["status"] != wantStatus { + t.Fatalf("payload status = %v, want %s", body["status"], wantStatus) + } + if body["gateway"] != "payu" { + t.Fatalf("payload gateway = %v, want payu", body["gateway"]) + } + if body["currency"] != "INR" { + t.Fatalf("payload currency = %v, want INR", body["currency"]) + } +} + func TestResolveExpiredHold_SuccessMatch_Confirmed(t *testing.T) { db := ttlTestDB(t) txnID := seedExpiredHold(t, db, 49900) @@ -81,6 +139,8 @@ func TestResolveExpiredHold_SuccessMatch_Confirmed(t *testing.T) { if got := holdStatusOf(t, db, txnID); got != "CONFIRMED" { t.Errorf("status = %q, want CONFIRMED", got) } + requireLedgerTransition(t, db, txnID, "stabilizer", "CONFIRMED") + requireOutboxPayload(t, db, txnID, "transaction.CONFIRMED", "transaction.confirmed", "CONFIRMED") } func TestResolveExpiredHold_SuccessAmountMismatch_Mismatch(t *testing.T) { @@ -94,6 +154,8 @@ func TestResolveExpiredHold_SuccessAmountMismatch_Mismatch(t *testing.T) { if got := holdStatusOf(t, db, txnID); got != "MISMATCH" { t.Errorf("status = %q, want MISMATCH", got) } + requireLedgerTransition(t, db, txnID, "stabilizer", "MISMATCH") + requireOutboxPayload(t, db, txnID, "transaction.MISMATCH", "transaction.mismatch", "MISMATCH") } func TestResolveExpiredHold_Failed(t *testing.T) { @@ -107,6 +169,8 @@ func TestResolveExpiredHold_Failed(t *testing.T) { if got := holdStatusOf(t, db, txnID); got != "FAILED" { t.Errorf("status = %q, want FAILED", got) } + requireLedgerTransition(t, db, txnID, "stabilizer", "FAILED") + requireOutboxPayload(t, db, txnID, "transaction.FAILED", "transaction.failed", "FAILED") } func TestResolveExpiredHold_Pending_Indeterminate(t *testing.T) { @@ -120,4 +184,21 @@ func TestResolveExpiredHold_Pending_Indeterminate(t *testing.T) { if got := holdStatusOf(t, db, txnID); got != "INDETERMINATE" { t.Errorf("status = %q, want INDETERMINATE (pending is not a confirmed failure)", got) } + requireLedgerTransition(t, db, txnID, "stabilizer", "INDETERMINATE") + requireOutboxPayload(t, db, txnID, "transaction.INDETERMINATE", "transaction.indeterminate", "INDETERMINATE") +} + +func TestClaimExpiredHolds_SkipsTerminalHolds(t *testing.T) { + db := ttlTestDB(t) + txnID := seedTTLHold(t, db, "CONFIRMED", 49900, true) + + holds, err := claimExpiredHolds(context.Background(), db) + if err != nil { + t.Fatalf("claim expired holds: %v", err) + } + for _, h := range holds { + if h.TxnID == txnID { + t.Fatalf("terminal hold %q was claimed", txnID) + } + } }