Files
GL/infrastructure/postgres/settlement_repository.go
T

205 lines
9.9 KiB
Go

package postgres
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"gl/domain/ledger"
)
type SettlementRepository struct{ database Database }
type ReconciliationStats struct {
Open, Detected, Resolved int64
}
type txQueryer interface {
Query(context.Context, string, ...any) (Rows, error)
}
func NewSettlementRepository(database Database) *SettlementRepository {
return &SettlementRepository{database: database}
}
func (r *SettlementRepository) Stats(ctx context.Context) (ledger.SettlementStats, error) {
var stats ledger.SettlementStats
err := r.database.QueryRow(ctx, `SELECT
COUNT(*) FILTER (WHERE status='PENDING'),
COUNT(*) FILTER (WHERE status='RETRYABLE'),
COUNT(*) FILTER (WHERE status='MANUAL_REVIEW'),
COALESCE(EXTRACT(EPOCH FROM (clock_timestamp() - MIN(created_at) FILTER (WHERE status IN ('PENDING','RETRYABLE'))))::bigint, 0)
FROM kuknos_settlements`).Scan(&stats.Pending, &stats.Retryable, &stats.ManualReview, &stats.OldestPendingSeconds)
if err != nil {
return ledger.SettlementStats{}, fmt.Errorf("settlement stats: %w", err)
}
return stats, nil
}
func (r *SettlementRepository) Enqueue(ctx context.Context, s ledger.Settlement) (ledger.Settlement, bool, error) {
if err := s.Validate(); err != nil {
return ledger.Settlement{}, false, err
}
var inserted string
err := r.database.QueryRow(ctx, `INSERT INTO kuknos_settlements
(id, source_service, source_transaction_id, idempotency_key, status, network, signed_transaction_xdr, available_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (idempotency_key) DO NOTHING RETURNING id`,
s.ID, s.SourceService, s.SourceTxID, s.IdempotencyKey, s.Status, s.Network, s.SignedTransactionXDR, s.AvailableAt).Scan(&inserted)
if err == nil {
return s, false, nil
}
if err == pgx.ErrNoRows {
var existingXDR string
if err := r.database.QueryRow(ctx, `SELECT signed_transaction_xdr, id FROM kuknos_settlements WHERE idempotency_key=$1`, s.IdempotencyKey).Scan(&existingXDR, &inserted); err != nil {
return ledger.Settlement{}, false, fmt.Errorf("resolve settlement idempotency: %w", err)
}
if existingXDR != s.SignedTransactionXDR {
return ledger.Settlement{}, false, ledger.ErrIdempotencyConflict
}
existing, err := r.Get(ctx, inserted, "")
return existing, true, err
}
return ledger.Settlement{}, false, fmt.Errorf("enqueue settlement: %w", err)
}
func (r *SettlementRepository) Get(ctx context.Context, id, idempotencyKey string) (ledger.Settlement, error) {
var s ledger.Settlement
err := r.database.QueryRow(ctx, `SELECT id, source_service, source_transaction_id, idempotency_key, status, network, transaction_hash, attempts, available_at, last_error, signed_transaction_xdr FROM kuknos_settlements WHERE id=NULLIF($1,'')::uuid OR idempotency_key=NULLIF($2,'')`, id, idempotencyKey).Scan(&s.ID, &s.SourceService, &s.SourceTxID, &s.IdempotencyKey, &s.Status, &s.Network, &s.TransactionHash, &s.Attempts, &s.AvailableAt, &s.LastError, &s.SignedTransactionXDR)
if err != nil {
return ledger.Settlement{}, fmt.Errorf("get settlement: %w", err)
}
return s, nil
}
func (r *SettlementRepository) ClaimDue(ctx context.Context, workerID string, limit int, now time.Time) ([]ledger.Settlement, error) {
tx, err := r.database.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `UPDATE kuknos_settlements SET locked_at=NULL, locked_by='', updated_at=clock_timestamp() WHERE locked_at < clock_timestamp() - interval '10 minutes'`); err != nil {
return nil, fmt.Errorf("release stale settlement locks: %w", err)
}
queryer, ok := tx.(txQueryer)
if !ok {
return nil, fmt.Errorf("settlement transaction does not support row queries")
}
rows, err := queryer.Query(ctx, `WITH due AS (
SELECT id FROM kuknos_settlements
WHERE status IN ('PENDING','RETRYABLE') AND available_at <= $1 AND locked_by=''
ORDER BY available_at, created_at FOR UPDATE SKIP LOCKED LIMIT $2
)
UPDATE kuknos_settlements s SET locked_at=clock_timestamp(), locked_by=$3, updated_at=clock_timestamp()
FROM due WHERE s.id=due.id
RETURNING s.id, s.source_service, s.source_transaction_id, s.idempotency_key, s.status, s.network, s.transaction_hash, s.attempts, s.available_at, s.last_error, s.signed_transaction_xdr`, now, limit, workerID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []ledger.Settlement
for rows.Next() {
var s ledger.Settlement
if err := rows.Scan(&s.ID, &s.SourceService, &s.SourceTxID, &s.IdempotencyKey, &s.Status, &s.Network, &s.TransactionHash, &s.Attempts, &s.AvailableAt, &s.LastError, &s.SignedTransactionXDR); err != nil {
return nil, err
}
result = append(result, s)
}
if err := rows.Err(); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return result, nil
}
func (r *SettlementRepository) Save(ctx context.Context, s ledger.Settlement, workerID string) error {
_, err := r.database.Exec(ctx, `UPDATE kuknos_settlements SET status=$2, transaction_hash=$3, attempts=$4, available_at=$5, last_error=$6, locked_at=NULL, locked_by='', submitted_at=CASE WHEN $2 IN ('SUBMITTED','CONFIRMED') THEN COALESCE(submitted_at, clock_timestamp()) ELSE submitted_at END, confirmed_at=CASE WHEN $2='CONFIRMED' THEN COALESCE(confirmed_at, clock_timestamp()) ELSE confirmed_at END, manual_review_at=CASE WHEN $2='MANUAL_REVIEW' THEN clock_timestamp() ELSE manual_review_at END, updated_at=clock_timestamp() WHERE id=$1 AND locked_by=$7`, s.ID, s.Status, s.TransactionHash, s.Attempts, s.AvailableAt, s.LastError, workerID)
return err
}
func (r *SettlementRepository) RetryByOperator(ctx context.Context, id, actorID, reason string, now time.Time) (ledger.Settlement, error) {
tx, err := r.database.Begin(ctx)
if err != nil {
return ledger.Settlement{}, fmt.Errorf("begin operator retry: %w", err)
}
defer tx.Rollback(ctx)
var previous ledger.SettlementStatus
if err := tx.QueryRow(ctx, `SELECT status FROM kuknos_settlements WHERE id=$1 FOR UPDATE`, id).Scan(&previous); err != nil {
return ledger.Settlement{}, fmt.Errorf("lock settlement for operator retry: %w", err)
}
if previous == ledger.SettlementConfirmed {
return ledger.Settlement{}, fmt.Errorf("confirmed settlement cannot be retried")
}
if _, err := tx.Exec(ctx, `UPDATE kuknos_settlements SET status='RETRYABLE', available_at=$2, locked_at=NULL, locked_by='', last_error='', manual_review_at=NULL, updated_at=clock_timestamp() WHERE id=$1`, id, now); err != nil {
return ledger.Settlement{}, fmt.Errorf("retry settlement: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO kuknos_settlement_operator_audits (settlement_id, actor_id, action, reason, previous_status, resulting_status) VALUES ($1,$2,'RETRY',$3,$4,'RETRYABLE')`, id, actorID, reason, previous); err != nil {
return ledger.Settlement{}, fmt.Errorf("audit operator retry: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return ledger.Settlement{}, fmt.Errorf("commit operator retry: %w", err)
}
return r.Get(ctx, id, "")
}
// ReconcileConfirmationEvidence durably records confirmed Kuknos settlements
// whose latest GL lifecycle evidence is missing or has a different hash.
func (r *SettlementRepository) ReconcileConfirmationEvidence(ctx context.Context, grace time.Duration) (ReconciliationStats, error) {
if grace <= 0 {
grace = 10 * time.Minute
}
tx, err := r.database.Begin(ctx)
if err != nil {
return ReconciliationStats{}, err
}
defer tx.Rollback(ctx)
var detected int64
err = tx.QueryRow(ctx, `WITH inconsistent AS (
SELECT s.id,
CASE WHEN e.id IS NULL OR e.blockchain_transaction_hash='' THEN 'MISSING_LEDGER_CONFIRMATION' ELSE 'TRANSACTION_HASH_MISMATCH' END issue_type,
s.transaction_hash expected_hash, COALESCE(e.blockchain_transaction_hash,'') actual_hash
FROM kuknos_settlements s
LEFT JOIN LATERAL (
SELECT id, blockchain_transaction_hash FROM transaction_events
WHERE source_service=s.source_service AND source_transaction_id=s.source_transaction_id
ORDER BY event_version DESC, recorded_at DESC LIMIT 1
) e ON true
WHERE s.status='CONFIRMED' AND s.confirmed_at < clock_timestamp() - $1::interval
AND (e.id IS NULL OR e.blockchain_transaction_hash='' OR e.blockchain_transaction_hash<>s.transaction_hash)
), upserted AS (
INSERT INTO kuknos_reconciliation_issues (settlement_id, issue_type, expected_hash, actual_hash)
SELECT id, issue_type, expected_hash, actual_hash FROM inconsistent
ON CONFLICT (settlement_id) DO UPDATE SET issue_type=EXCLUDED.issue_type,
expected_hash=EXCLUDED.expected_hash, actual_hash=EXCLUDED.actual_hash,
status='OPEN', last_detected_at=clock_timestamp(), resolved_at=NULL
WHERE kuknos_reconciliation_issues.status<>'OPEN'
OR kuknos_reconciliation_issues.issue_type<>EXCLUDED.issue_type
OR kuknos_reconciliation_issues.expected_hash<>EXCLUDED.expected_hash
OR kuknos_reconciliation_issues.actual_hash<>EXCLUDED.actual_hash
RETURNING 1
) SELECT count(*) FROM upserted`, fmt.Sprintf("%f seconds", grace.Seconds())).Scan(&detected)
if err != nil {
return ReconciliationStats{}, fmt.Errorf("detect settlement reconciliation issues: %w", err)
}
result, err := tx.Exec(ctx, `UPDATE kuknos_reconciliation_issues i SET status='RESOLVED', resolved_at=clock_timestamp()
WHERE status='OPEN' AND EXISTS (
SELECT 1 FROM kuknos_settlements s JOIN transaction_events e
ON e.source_service=s.source_service AND e.source_transaction_id=s.source_transaction_id
WHERE s.id=i.settlement_id AND s.status='CONFIRMED'
AND e.blockchain_transaction_hash=s.transaction_hash AND e.blockchain_transaction_hash<>''
)`)
if err != nil {
return ReconciliationStats{}, fmt.Errorf("resolve settlement reconciliation issues: %w", err)
}
var open int64
if err := tx.QueryRow(ctx, `SELECT count(*) FROM kuknos_reconciliation_issues WHERE status='OPEN'`).Scan(&open); err != nil {
return ReconciliationStats{}, err
}
if err := tx.Commit(ctx); err != nil {
return ReconciliationStats{}, err
}
return ReconciliationStats{Open: open, Detected: detected, Resolved: result.RowsAffected()}, nil
}