This commit is contained in:
@@ -31,6 +31,10 @@ func (r fakeRow) Scan(dest ...any) error {
|
||||
*target = value.(string)
|
||||
case *int64:
|
||||
*target = value.(int64)
|
||||
case *int:
|
||||
*target = value.(int)
|
||||
case *ledger.SettlementStatus:
|
||||
*target = value.(ledger.SettlementStatus)
|
||||
case *bool:
|
||||
*target = value.(bool)
|
||||
case *uint32:
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE kuknos_settlements DROP CONSTRAINT kuknos_settlements_status_check;
|
||||
ALTER TABLE kuknos_settlements ADD CONSTRAINT kuknos_settlements_status_check
|
||||
CHECK (status IN ('PENDING','SUBMITTED','CONFIRMED','RETRYABLE','MANUAL_REVIEW','IGNORED'));
|
||||
|
||||
ALTER TABLE kuknos_settlement_operator_audits DROP CONSTRAINT kuknos_settlement_operator_audits_action_check;
|
||||
ALTER TABLE kuknos_settlement_operator_audits ADD CONSTRAINT kuknos_settlement_operator_audits_action_check
|
||||
CHECK (action IN ('RETRY','IGNORE'));
|
||||
@@ -147,8 +147,8 @@ func (r *SettlementRepository) RetryByOperator(ctx context.Context, id, actorID,
|
||||
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 previous == ledger.SettlementConfirmed || previous == ledger.SettlementIgnored {
|
||||
return ledger.Settlement{}, fmt.Errorf("confirmed or ignored 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)
|
||||
@@ -162,6 +162,35 @@ func (r *SettlementRepository) RetryByOperator(ctx context.Context, id, actorID,
|
||||
return r.Get(ctx, id, "")
|
||||
}
|
||||
|
||||
func (r *SettlementRepository) IgnoreByOperator(ctx context.Context, id, actorID, reason string) (ledger.Settlement, error) {
|
||||
tx, err := r.database.Begin(ctx)
|
||||
if err != nil {
|
||||
return ledger.Settlement{}, fmt.Errorf("begin operator dismissal: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var previous, lockedBy string
|
||||
if err := tx.QueryRow(ctx, `SELECT status, locked_by FROM kuknos_settlements WHERE id=$1 FOR UPDATE`, id).Scan(&previous, &lockedBy); err != nil {
|
||||
return ledger.Settlement{}, fmt.Errorf("lock settlement for dismissal: %w", err)
|
||||
}
|
||||
if lockedBy != "" || (previous != string(ledger.SettlementManualReview) && previous != string(ledger.SettlementRetryable)) {
|
||||
return ledger.Settlement{}, fmt.Errorf("only unlocked settlements requiring review or retry can be ignored")
|
||||
}
|
||||
// Preserve the error and signed payload as evidence; this is not a payment confirmation.
|
||||
if _, err := tx.Exec(ctx, `UPDATE kuknos_settlements SET status='IGNORED', updated_at=clock_timestamp() WHERE id=$1`, id); err != nil {
|
||||
return ledger.Settlement{}, fmt.Errorf("ignore 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,'IGNORE',$3,$4,'IGNORED')`, id, actorID, reason, previous); err != nil {
|
||||
return ledger.Settlement{}, fmt.Errorf("audit operator dismissal: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE kuknos_reconciliation_issues SET status='RESOLVED', resolved_at=clock_timestamp() WHERE settlement_id=$1 AND status='OPEN'`, id); err != nil {
|
||||
return ledger.Settlement{}, fmt.Errorf("resolve dismissed settlement issues: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return ledger.Settlement{}, fmt.Errorf("commit operator dismissal: %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) {
|
||||
|
||||
@@ -4,8 +4,51 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gl/domain/ledger"
|
||||
)
|
||||
|
||||
func TestIgnoreByOperatorRejectsActiveLockedAndTerminalSettlements(t *testing.T) {
|
||||
for _, state := range []struct{ status, lock string }{
|
||||
{"PENDING", ""}, {"SUBMITTED", ""}, {"CONFIRMED", ""}, {"IGNORED", ""}, {"RETRYABLE", "worker"},
|
||||
} {
|
||||
tx := &fakeTx{rows: []Row{fakeRow{values: []any{state.status, state.lock}}}}
|
||||
repository := NewSettlementRepository(&fakeDatabase{tx: tx})
|
||||
if _, err := repository.IgnoreByOperator(context.Background(), "id", "actor", "reviewed"); err == nil {
|
||||
t.Fatalf("dismissed ineligible state: %+v", state)
|
||||
}
|
||||
if tx.execCount != 0 || tx.committed || !tx.rolledBack {
|
||||
t.Fatalf("ineligible dismissal changed state: %+v", tx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnoreByOperatorAuditsAndResolvesAtomically(t *testing.T) {
|
||||
tx := &fakeTx{rows: []Row{fakeRow{values: []any{"MANUAL_REVIEW", ""}}}}
|
||||
repository := NewSettlementRepository(&fakeDatabase{tx: tx, directRow: fakeRow{values: []any{
|
||||
"id", "wallet", "110", "wallet:110:kuknos", ledger.SettlementIgnored, "kuknos", "", 1, time.Now(), "tx_bad_seq", "signed-xdr",
|
||||
}}})
|
||||
got, err := repository.IgnoreByOperator(context.Background(), "id", "actor", "reviewed")
|
||||
if err != nil || got.Status != ledger.SettlementIgnored || got.LastError != "tx_bad_seq" || !tx.committed || tx.execCount != 3 {
|
||||
t.Fatalf("unexpected dismissal: %+v tx=%+v err=%v", got, tx, err)
|
||||
}
|
||||
for _, failedStep := range []int{2, 3} {
|
||||
tx := &fakeTx{rows: []Row{fakeRow{values: []any{"RETRYABLE", ""}}}, execErrAt: failedStep}
|
||||
repository := NewSettlementRepository(&fakeDatabase{tx: tx})
|
||||
if _, err := repository.IgnoreByOperator(context.Background(), "id", "actor", "reviewed"); err == nil || tx.committed || !tx.rolledBack {
|
||||
t.Fatalf("audit or issue failure did not roll back: %+v err=%v", tx, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnoredSettlementCannotBeRetried(t *testing.T) {
|
||||
tx := &fakeTx{rows: []Row{fakeRow{values: []any{ledger.SettlementIgnored}}}}
|
||||
repository := NewSettlementRepository(&fakeDatabase{tx: tx})
|
||||
if _, err := repository.RetryByOperator(context.Background(), "id", "actor", "retry", time.Now()); err == nil || tx.execCount != 0 {
|
||||
t.Fatal("ignored settlement was queued again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileConfirmationEvidenceCommitsDurableIssueStats(t *testing.T) {
|
||||
tx := &fakeTx{rows: []Row{
|
||||
fakeRow{values: []any{int64(1)}},
|
||||
|
||||
Reference in New Issue
Block a user