diff --git a/README.md b/README.md index 3df9236..b1e1455 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,21 @@ theme toggle that initially follows the operating-system preference. The gRPC service owns schema migrations. The dashboard is read-only, uses its own `dashboard.cfg.toml`, and does not run migrations. +AdminPanel settlement review supports `IgnoreSettlement` through the authenticated +GL admin connection. Operators with `ledgerops.retry_kuknossettlement` can review an +unlocked `MANUAL_REVIEW` or `RETRYABLE` settlement and record a reason to close it. +GL atomically sets `IGNORED`, records the actor/reason/status transition in +`kuknos_settlement_operator_audits`, and resolves linked open reconciliation issues. +The original error and signed payload remain available as evidence. Ignored +settlements are terminal, cannot be retried, and leave the attention queue. +Dismissal does not confirm a payment or reverse funds; IRT withdrawal reservations +still require financial reconciliation. + +Deploy the updated GL service first so its startup applies migration +`000007_ignore_settlement.up.sql` and exposes the RPC, then deploy Wallet and +AdminPanel. An older GL service rejects the command; AdminPanel preserves the +review form and shows the error rather than reporting a successful dismissal. + Run the dashboard load test against a started dashboard with: ```bash diff --git a/application/settlement/service.go b/application/settlement/service.go index 169b204..5ba392f 100644 --- a/application/settlement/service.go +++ b/application/settlement/service.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "strings" "time" "gl/domain/ledger" @@ -14,6 +15,15 @@ type EnqueueRepository interface { Enqueue(context.Context, ledger.Settlement) (ledger.Settlement, bool, error) Get(context.Context, string, string) (ledger.Settlement, error) RetryByOperator(context.Context, string, string, string, time.Time) (ledger.Settlement, error) + IgnoreByOperator(context.Context, string, string, string) (ledger.Settlement, error) +} + +func (s *Service) IgnoreByOperator(ctx context.Context, settlementID, actorID, reason string) (ledger.Settlement, error) { + settlementID, actorID, reason = strings.TrimSpace(settlementID), strings.TrimSpace(actorID), strings.TrimSpace(reason) + if settlementID == "" || actorID == "" || reason == "" || len([]rune(reason)) > 500 { + return ledger.Settlement{}, fmt.Errorf("settlement id, actor id, and a reason of at most 500 characters are required") + } + return s.repository.IgnoreByOperator(ctx, settlementID, actorID, reason) } func (s *Service) RetryByOperator(ctx context.Context, settlementID, actorID, reason string) (ledger.Settlement, error) { diff --git a/application/settlement/service_test.go b/application/settlement/service_test.go index f9f5b9f..246191b 100644 --- a/application/settlement/service_test.go +++ b/application/settlement/service_test.go @@ -23,6 +23,28 @@ func (r *serviceRepository) RetryByOperator(_ context.Context, id, actor, reason return ledger.Settlement{ID: id, Status: ledger.SettlementRetryable}, nil } +func (r *serviceRepository) IgnoreByOperator(_ context.Context, id, actor, reason string) (ledger.Settlement, error) { + r.retriedID, r.actor, r.reason = id, actor, reason + return ledger.Settlement{ID: id, Status: ledger.SettlementIgnored}, nil +} + +func TestIgnoreByOperatorRequiresReviewedAuditIdentity(t *testing.T) { + repository := &serviceRepository{} + service := NewService(repository) + for _, reason := range []string{"", " "} { + if _, err := service.IgnoreByOperator(context.Background(), "id", "actor", reason); err == nil { + t.Fatal("expected blank review reason to be rejected") + } + } + if repository.retriedID != "" { + t.Fatal("invalid dismissal reached repository") + } + got, err := service.IgnoreByOperator(context.Background(), "id", "actor-7", " cannot complete ") + if err != nil || got.Status != ledger.SettlementIgnored || repository.actor != "actor-7" || repository.reason != "cannot complete" { + t.Fatalf("unexpected dismissal: %+v repo=%+v err=%v", got, repository, err) + } +} + func TestRetryByOperatorRequiresCompleteAuditIdentity(t *testing.T) { service := NewService(&serviceRepository{}) if _, err := service.RetryByOperator(context.Background(), "id", "actor", ""); err == nil { diff --git a/domain/ledger/settlement.go b/domain/ledger/settlement.go index 5dec533..be0628b 100644 --- a/domain/ledger/settlement.go +++ b/domain/ledger/settlement.go @@ -18,6 +18,7 @@ const ( SettlementConfirmed SettlementStatus = "CONFIRMED" SettlementRetryable SettlementStatus = "RETRYABLE" SettlementManualReview SettlementStatus = "MANUAL_REVIEW" + SettlementIgnored SettlementStatus = "IGNORED" ) type Settlement struct { @@ -45,7 +46,7 @@ func (s Settlement) Validate() error { if s.ID == "" || s.SourceService == "" || s.SourceTxID == "" || s.IdempotencyKey == "" { return fmt.Errorf("settlement identity fields are required") } - if s.Status != SettlementPending && s.Status != SettlementSubmitted && s.Status != SettlementConfirmed && s.Status != SettlementRetryable && s.Status != SettlementManualReview { + if s.Status != SettlementPending && s.Status != SettlementSubmitted && s.Status != SettlementConfirmed && s.Status != SettlementRetryable && s.Status != SettlementManualReview && s.Status != SettlementIgnored { return fmt.Errorf("invalid settlement status %q", s.Status) } if s.Attempts < 0 || s.AvailableAt.IsZero() { diff --git a/gen/ledger/v1/msg.pb.go b/gen/ledger/v1/msg.pb.go index f3066dc..a3f63a1 100644 --- a/gen/ledger/v1/msg.pb.go +++ b/gen/ledger/v1/msg.pb.go @@ -2313,6 +2313,110 @@ func (x *RetrySettlementResponse) GetSettlement() *Settlement { return nil } +type IgnoreSettlementRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SettlementId string `protobuf:"bytes,1,opt,name=settlement_id,json=settlementId,proto3" json:"settlement_id,omitempty"` + ActorId string `protobuf:"bytes,2,opt,name=actor_id,json=actorId,proto3" json:"actor_id,omitempty"` + Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IgnoreSettlementRequest) Reset() { + *x = IgnoreSettlementRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IgnoreSettlementRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IgnoreSettlementRequest) ProtoMessage() {} + +func (x *IgnoreSettlementRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IgnoreSettlementRequest.ProtoReflect.Descriptor instead. +func (*IgnoreSettlementRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{29} +} + +func (x *IgnoreSettlementRequest) GetSettlementId() string { + if x != nil { + return x.SettlementId + } + return "" +} + +func (x *IgnoreSettlementRequest) GetActorId() string { + if x != nil { + return x.ActorId + } + return "" +} + +func (x *IgnoreSettlementRequest) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +type IgnoreSettlementResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Settlement *Settlement `protobuf:"bytes,1,opt,name=settlement,proto3" json:"settlement,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IgnoreSettlementResponse) Reset() { + *x = IgnoreSettlementResponse{} + mi := &file_ledger_v1_msg_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IgnoreSettlementResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IgnoreSettlementResponse) ProtoMessage() {} + +func (x *IgnoreSettlementResponse) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IgnoreSettlementResponse.ProtoReflect.Descriptor instead. +func (*IgnoreSettlementResponse) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{30} +} + +func (x *IgnoreSettlementResponse) GetSettlement() *Settlement { + if x != nil { + return x.Settlement + } + return nil +} + var File_ledger_v1_msg_proto protoreflect.FileDescriptor const file_ledger_v1_msg_proto_rawDesc = "" + @@ -2525,6 +2629,14 @@ const file_ledger_v1_msg_proto_rawDesc = "" + "\x17RetrySettlementResponse\x125\n" + "\n" + "settlement\x18\x01 \x01(\v2\x15.ledger.v1.SettlementR\n" + + "settlement\"q\n" + + "\x17IgnoreSettlementRequest\x12#\n" + + "\rsettlement_id\x18\x01 \x01(\tR\fsettlementId\x12\x19\n" + + "\bactor_id\x18\x02 \x01(\tR\aactorId\x12\x16\n" + + "\x06reason\x18\x03 \x01(\tR\x06reason\"Q\n" + + "\x18IgnoreSettlementResponse\x125\n" + + "\n" + + "settlement\x18\x01 \x01(\v2\x15.ledger.v1.SettlementR\n" + "settlement*\x9a\x02\n" + "\fAccountClass\x12\x1d\n" + "\x19ACCOUNT_CLASS_UNSPECIFIED\x10\x00\x12 \n" + @@ -2559,7 +2671,7 @@ func file_ledger_v1_msg_proto_rawDescGZIP() []byte { } var file_ledger_v1_msg_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_ledger_v1_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 32) +var file_ledger_v1_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 34) var file_ledger_v1_msg_proto_goTypes = []any{ (AccountClass)(0), // 0: ledger.v1.AccountClass (TransactionState)(0), // 1: ledger.v1.TransactionState @@ -2592,50 +2704,53 @@ var file_ledger_v1_msg_proto_goTypes = []any{ (*Settlement)(nil), // 28: ledger.v1.Settlement (*RetrySettlementRequest)(nil), // 29: ledger.v1.RetrySettlementRequest (*RetrySettlementResponse)(nil), // 30: ledger.v1.RetrySettlementResponse - nil, // 31: ledger.v1.Journal.MetadataEntry - nil, // 32: ledger.v1.AppendJournalRequest.MetadataEntry - nil, // 33: ledger.v1.AppendTransactionEventRequest.MetadataEntry - (*timestamppb.Timestamp)(nil), // 34: google.protobuf.Timestamp + (*IgnoreSettlementRequest)(nil), // 31: ledger.v1.IgnoreSettlementRequest + (*IgnoreSettlementResponse)(nil), // 32: ledger.v1.IgnoreSettlementResponse + nil, // 33: ledger.v1.Journal.MetadataEntry + nil, // 34: ledger.v1.AppendJournalRequest.MetadataEntry + nil, // 35: ledger.v1.AppendTransactionEventRequest.MetadataEntry + (*timestamppb.Timestamp)(nil), // 36: google.protobuf.Timestamp } var file_ledger_v1_msg_proto_depIdxs = []int32{ - 34, // 0: ledger.v1.OperatingModeResponse.changed_at:type_name -> google.protobuf.Timestamp + 36, // 0: ledger.v1.OperatingModeResponse.changed_at:type_name -> google.protobuf.Timestamp 5, // 1: ledger.v1.GetUserBalancesResponse.balances:type_name -> ledger.v1.UserAssetBalance 0, // 2: ledger.v1.AccountReference.account_class:type_name -> ledger.v1.AccountClass 7, // 3: ledger.v1.JournalEntry.account:type_name -> ledger.v1.AccountReference 8, // 4: ledger.v1.Journal.entries:type_name -> ledger.v1.JournalEntry - 34, // 5: ledger.v1.Journal.occurred_at:type_name -> google.protobuf.Timestamp - 34, // 6: ledger.v1.Journal.recorded_at:type_name -> google.protobuf.Timestamp + 36, // 5: ledger.v1.Journal.occurred_at:type_name -> google.protobuf.Timestamp + 36, // 6: ledger.v1.Journal.recorded_at:type_name -> google.protobuf.Timestamp 9, // 7: ledger.v1.Journal.blockchain:type_name -> ledger.v1.BlockchainReference - 31, // 8: ledger.v1.Journal.metadata:type_name -> ledger.v1.Journal.MetadataEntry + 33, // 8: ledger.v1.Journal.metadata:type_name -> ledger.v1.Journal.MetadataEntry 8, // 9: ledger.v1.AppendJournalRequest.entries:type_name -> ledger.v1.JournalEntry - 34, // 10: ledger.v1.AppendJournalRequest.occurred_at:type_name -> google.protobuf.Timestamp + 36, // 10: ledger.v1.AppendJournalRequest.occurred_at:type_name -> google.protobuf.Timestamp 9, // 11: ledger.v1.AppendJournalRequest.blockchain:type_name -> ledger.v1.BlockchainReference - 32, // 12: ledger.v1.AppendJournalRequest.metadata:type_name -> ledger.v1.AppendJournalRequest.MetadataEntry + 34, // 12: ledger.v1.AppendJournalRequest.metadata:type_name -> ledger.v1.AppendJournalRequest.MetadataEntry 10, // 13: ledger.v1.AppendJournalResponse.journal:type_name -> ledger.v1.Journal 1, // 14: ledger.v1.AppendTransactionEventRequest.state:type_name -> ledger.v1.TransactionState - 34, // 15: ledger.v1.AppendTransactionEventRequest.occurred_at:type_name -> google.protobuf.Timestamp + 36, // 15: ledger.v1.AppendTransactionEventRequest.occurred_at:type_name -> google.protobuf.Timestamp 9, // 16: ledger.v1.AppendTransactionEventRequest.blockchain:type_name -> ledger.v1.BlockchainReference - 33, // 17: ledger.v1.AppendTransactionEventRequest.metadata:type_name -> ledger.v1.AppendTransactionEventRequest.MetadataEntry + 35, // 17: ledger.v1.AppendTransactionEventRequest.metadata:type_name -> ledger.v1.AppendTransactionEventRequest.MetadataEntry 13, // 18: ledger.v1.TransactionEvent.event:type_name -> ledger.v1.AppendTransactionEventRequest - 34, // 19: ledger.v1.TransactionEvent.recorded_at:type_name -> google.protobuf.Timestamp + 36, // 19: ledger.v1.TransactionEvent.recorded_at:type_name -> google.protobuf.Timestamp 14, // 20: ledger.v1.AppendTransactionEventResponse.event:type_name -> ledger.v1.TransactionEvent 7, // 21: ledger.v1.ListEntriesRequest.account:type_name -> ledger.v1.AccountReference - 34, // 22: ledger.v1.ListEntriesRequest.recorded_from:type_name -> google.protobuf.Timestamp - 34, // 23: ledger.v1.ListEntriesRequest.recorded_to:type_name -> google.protobuf.Timestamp + 36, // 22: ledger.v1.ListEntriesRequest.recorded_from:type_name -> google.protobuf.Timestamp + 36, // 23: ledger.v1.ListEntriesRequest.recorded_to:type_name -> google.protobuf.Timestamp 10, // 24: ledger.v1.ListEntriesResponse.journals:type_name -> ledger.v1.Journal 7, // 25: ledger.v1.GetBalanceRequest.account:type_name -> ledger.v1.AccountReference - 34, // 26: ledger.v1.GetBalanceRequest.as_of:type_name -> google.protobuf.Timestamp + 36, // 26: ledger.v1.GetBalanceRequest.as_of:type_name -> google.protobuf.Timestamp 7, // 27: ledger.v1.GetBalanceResponse.account:type_name -> ledger.v1.AccountReference - 34, // 28: ledger.v1.GetBalanceResponse.as_of:type_name -> google.protobuf.Timestamp + 36, // 28: ledger.v1.GetBalanceResponse.as_of:type_name -> google.protobuf.Timestamp 11, // 29: ledger.v1.ReplayJournalsRequest.journals:type_name -> ledger.v1.AppendJournalRequest 10, // 30: ledger.v1.ReplayJournalResult.journal:type_name -> ledger.v1.Journal 22, // 31: ledger.v1.ReplayJournalsResponse.results:type_name -> ledger.v1.ReplayJournalResult 28, // 32: ledger.v1.RetrySettlementResponse.settlement:type_name -> ledger.v1.Settlement - 33, // [33:33] is the sub-list for method output_type - 33, // [33:33] is the sub-list for method input_type - 33, // [33:33] is the sub-list for extension type_name - 33, // [33:33] is the sub-list for extension extendee - 0, // [0:33] is the sub-list for field type_name + 28, // 33: ledger.v1.IgnoreSettlementResponse.settlement:type_name -> ledger.v1.Settlement + 34, // [34:34] is the sub-list for method output_type + 34, // [34:34] is the sub-list for method input_type + 34, // [34:34] is the sub-list for extension type_name + 34, // [34:34] is the sub-list for extension extendee + 0, // [0:34] is the sub-list for field type_name } func init() { file_ledger_v1_msg_proto_init() } @@ -2661,7 +2776,7 @@ func file_ledger_v1_msg_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_ledger_v1_msg_proto_rawDesc), len(file_ledger_v1_msg_proto_rawDesc)), NumEnums: 2, - NumMessages: 32, + NumMessages: 34, NumExtensions: 0, NumServices: 0, }, diff --git a/gen/ledger/v1/srv.pb.go b/gen/ledger/v1/srv.pb.go index 3d21b10..42e371f 100644 --- a/gen/ledger/v1/srv.pb.go +++ b/gen/ledger/v1/srv.pb.go @@ -25,7 +25,7 @@ var File_ledger_v1_srv_proto protoreflect.FileDescriptor const file_ledger_v1_srv_proto_rawDesc = "" + "\n" + - "\x13ledger/v1/srv.proto\x12\tledger.v1\x1a\x11base/v1/msg.proto\x1a\x13ledger/v1/msg.proto2\x95\t\n" + + "\x13ledger/v1/srv.proto\x12\tledger.v1\x1a\x11base/v1/msg.proto\x1a\x13ledger/v1/msg.proto2\xf2\t\n" + "\x14GeneralLedgerService\x12D\n" + "\x10GetOperatingMode\x12\x0e.base.v1.Empty\x1a .ledger.v1.OperatingModeResponse\x12X\n" + "\x10SetOperatingMode\x12\".ledger.v1.SetOperatingModeRequest\x1a .ledger.v1.OperatingModeResponse\x12X\n" + @@ -42,7 +42,8 @@ const file_ledger_v1_srv_proto_rawDesc = "" + "\x0eReplayJournals\x12 .ledger.v1.ReplayJournalsRequest\x1a!.ledger.v1.ReplayJournalsResponse\x12^\n" + "\x11EnqueueSettlement\x12#.ledger.v1.EnqueueSettlementRequest\x1a$.ledger.v1.EnqueueSettlementResponse\x12G\n" + "\rGetSettlement\x12\x1f.ledger.v1.GetSettlementRequest\x1a\x15.ledger.v1.Settlement\x12X\n" + - "\x0fRetrySettlement\x12!.ledger.v1.RetrySettlementRequest\x1a\".ledger.v1.RetrySettlementResponseBy\n" + + "\x0fRetrySettlement\x12!.ledger.v1.RetrySettlementRequest\x1a\".ledger.v1.RetrySettlementResponse\x12[\n" + + "\x10IgnoreSettlement\x12\".ledger.v1.IgnoreSettlementRequest\x1a#.ledger.v1.IgnoreSettlementResponseBy\n" + "\rcom.ledger.v1B\bSrvProtoP\x01Z\x19gl/gen/ledger/v1;ledgerv1\xa2\x02\x03LXX\xaa\x02\tLedger.V1\xca\x02\tLedger\\V1\xe2\x02\x15Ledger\\V1\\GPBMetadata\xea\x02\n" + "Ledger::V1b\x06proto3" @@ -59,18 +60,20 @@ var file_ledger_v1_srv_proto_goTypes = []any{ (*EnqueueSettlementRequest)(nil), // 9: ledger.v1.EnqueueSettlementRequest (*GetSettlementRequest)(nil), // 10: ledger.v1.GetSettlementRequest (*RetrySettlementRequest)(nil), // 11: ledger.v1.RetrySettlementRequest - (*OperatingModeResponse)(nil), // 12: ledger.v1.OperatingModeResponse - (*AppendJournalResponse)(nil), // 13: ledger.v1.AppendJournalResponse - (*GetUserBalancesResponse)(nil), // 14: ledger.v1.GetUserBalancesResponse - (*HealthResponse)(nil), // 15: ledger.v1.HealthResponse - (*AppendTransactionEventResponse)(nil), // 16: ledger.v1.AppendTransactionEventResponse - (*Journal)(nil), // 17: ledger.v1.Journal - (*ListEntriesResponse)(nil), // 18: ledger.v1.ListEntriesResponse - (*GetBalanceResponse)(nil), // 19: ledger.v1.GetBalanceResponse - (*ReplayJournalsResponse)(nil), // 20: ledger.v1.ReplayJournalsResponse - (*EnqueueSettlementResponse)(nil), // 21: ledger.v1.EnqueueSettlementResponse - (*Settlement)(nil), // 22: ledger.v1.Settlement - (*RetrySettlementResponse)(nil), // 23: ledger.v1.RetrySettlementResponse + (*IgnoreSettlementRequest)(nil), // 12: ledger.v1.IgnoreSettlementRequest + (*OperatingModeResponse)(nil), // 13: ledger.v1.OperatingModeResponse + (*AppendJournalResponse)(nil), // 14: ledger.v1.AppendJournalResponse + (*GetUserBalancesResponse)(nil), // 15: ledger.v1.GetUserBalancesResponse + (*HealthResponse)(nil), // 16: ledger.v1.HealthResponse + (*AppendTransactionEventResponse)(nil), // 17: ledger.v1.AppendTransactionEventResponse + (*Journal)(nil), // 18: ledger.v1.Journal + (*ListEntriesResponse)(nil), // 19: ledger.v1.ListEntriesResponse + (*GetBalanceResponse)(nil), // 20: ledger.v1.GetBalanceResponse + (*ReplayJournalsResponse)(nil), // 21: ledger.v1.ReplayJournalsResponse + (*EnqueueSettlementResponse)(nil), // 22: ledger.v1.EnqueueSettlementResponse + (*Settlement)(nil), // 23: ledger.v1.Settlement + (*RetrySettlementResponse)(nil), // 24: ledger.v1.RetrySettlementResponse + (*IgnoreSettlementResponse)(nil), // 25: ledger.v1.IgnoreSettlementResponse } var file_ledger_v1_srv_proto_depIdxs = []int32{ 0, // 0: ledger.v1.GeneralLedgerService.GetOperatingMode:input_type -> base.v1.Empty @@ -87,22 +90,24 @@ var file_ledger_v1_srv_proto_depIdxs = []int32{ 9, // 11: ledger.v1.GeneralLedgerService.EnqueueSettlement:input_type -> ledger.v1.EnqueueSettlementRequest 10, // 12: ledger.v1.GeneralLedgerService.GetSettlement:input_type -> ledger.v1.GetSettlementRequest 11, // 13: ledger.v1.GeneralLedgerService.RetrySettlement:input_type -> ledger.v1.RetrySettlementRequest - 12, // 14: ledger.v1.GeneralLedgerService.GetOperatingMode:output_type -> ledger.v1.OperatingModeResponse - 12, // 15: ledger.v1.GeneralLedgerService.SetOperatingMode:output_type -> ledger.v1.OperatingModeResponse - 13, // 16: ledger.v1.GeneralLedgerService.PostInternalJournal:output_type -> ledger.v1.AppendJournalResponse - 14, // 17: ledger.v1.GeneralLedgerService.GetUserBalances:output_type -> ledger.v1.GetUserBalancesResponse - 15, // 18: ledger.v1.GeneralLedgerService.Health:output_type -> ledger.v1.HealthResponse - 13, // 19: ledger.v1.GeneralLedgerService.AppendJournal:output_type -> ledger.v1.AppendJournalResponse - 16, // 20: ledger.v1.GeneralLedgerService.AppendTransactionEvent:output_type -> ledger.v1.AppendTransactionEventResponse - 17, // 21: ledger.v1.GeneralLedgerService.GetJournal:output_type -> ledger.v1.Journal - 18, // 22: ledger.v1.GeneralLedgerService.ListEntries:output_type -> ledger.v1.ListEntriesResponse - 19, // 23: ledger.v1.GeneralLedgerService.GetBalance:output_type -> ledger.v1.GetBalanceResponse - 20, // 24: ledger.v1.GeneralLedgerService.ReplayJournals:output_type -> ledger.v1.ReplayJournalsResponse - 21, // 25: ledger.v1.GeneralLedgerService.EnqueueSettlement:output_type -> ledger.v1.EnqueueSettlementResponse - 22, // 26: ledger.v1.GeneralLedgerService.GetSettlement:output_type -> ledger.v1.Settlement - 23, // 27: ledger.v1.GeneralLedgerService.RetrySettlement:output_type -> ledger.v1.RetrySettlementResponse - 14, // [14:28] is the sub-list for method output_type - 0, // [0:14] is the sub-list for method input_type + 12, // 14: ledger.v1.GeneralLedgerService.IgnoreSettlement:input_type -> ledger.v1.IgnoreSettlementRequest + 13, // 15: ledger.v1.GeneralLedgerService.GetOperatingMode:output_type -> ledger.v1.OperatingModeResponse + 13, // 16: ledger.v1.GeneralLedgerService.SetOperatingMode:output_type -> ledger.v1.OperatingModeResponse + 14, // 17: ledger.v1.GeneralLedgerService.PostInternalJournal:output_type -> ledger.v1.AppendJournalResponse + 15, // 18: ledger.v1.GeneralLedgerService.GetUserBalances:output_type -> ledger.v1.GetUserBalancesResponse + 16, // 19: ledger.v1.GeneralLedgerService.Health:output_type -> ledger.v1.HealthResponse + 14, // 20: ledger.v1.GeneralLedgerService.AppendJournal:output_type -> ledger.v1.AppendJournalResponse + 17, // 21: ledger.v1.GeneralLedgerService.AppendTransactionEvent:output_type -> ledger.v1.AppendTransactionEventResponse + 18, // 22: ledger.v1.GeneralLedgerService.GetJournal:output_type -> ledger.v1.Journal + 19, // 23: ledger.v1.GeneralLedgerService.ListEntries:output_type -> ledger.v1.ListEntriesResponse + 20, // 24: ledger.v1.GeneralLedgerService.GetBalance:output_type -> ledger.v1.GetBalanceResponse + 21, // 25: ledger.v1.GeneralLedgerService.ReplayJournals:output_type -> ledger.v1.ReplayJournalsResponse + 22, // 26: ledger.v1.GeneralLedgerService.EnqueueSettlement:output_type -> ledger.v1.EnqueueSettlementResponse + 23, // 27: ledger.v1.GeneralLedgerService.GetSettlement:output_type -> ledger.v1.Settlement + 24, // 28: ledger.v1.GeneralLedgerService.RetrySettlement:output_type -> ledger.v1.RetrySettlementResponse + 25, // 29: ledger.v1.GeneralLedgerService.IgnoreSettlement:output_type -> ledger.v1.IgnoreSettlementResponse + 15, // [15:30] is the sub-list for method output_type + 0, // [0:15] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name diff --git a/gen/ledger/v1/srv_grpc.pb.go b/gen/ledger/v1/srv_grpc.pb.go index 5a26f1a..9588c1d 100644 --- a/gen/ledger/v1/srv_grpc.pb.go +++ b/gen/ledger/v1/srv_grpc.pb.go @@ -34,6 +34,7 @@ const ( GeneralLedgerService_EnqueueSettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/EnqueueSettlement" GeneralLedgerService_GetSettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/GetSettlement" GeneralLedgerService_RetrySettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/RetrySettlement" + GeneralLedgerService_IgnoreSettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/IgnoreSettlement" ) // GeneralLedgerServiceClient is the client API for GeneralLedgerService service. @@ -57,6 +58,9 @@ type GeneralLedgerServiceClient interface { GetSettlement(ctx context.Context, in *GetSettlementRequest, opts ...grpc.CallOption) (*Settlement, error) // Restricted operator command. Requires the configured x-admin-token metadata. RetrySettlement(ctx context.Context, in *RetrySettlementRequest, opts ...grpc.CallOption) (*RetrySettlementResponse, error) + // Operator-reviewed, terminal dismissal. Does not confirm or reverse payment. + // Requires x-admin-token and a recorded reason; only unlocked failed settlements. + IgnoreSettlement(ctx context.Context, in *IgnoreSettlementRequest, opts ...grpc.CallOption) (*IgnoreSettlementResponse, error) } type generalLedgerServiceClient struct { @@ -207,6 +211,16 @@ func (c *generalLedgerServiceClient) RetrySettlement(ctx context.Context, in *Re return out, nil } +func (c *generalLedgerServiceClient) IgnoreSettlement(ctx context.Context, in *IgnoreSettlementRequest, opts ...grpc.CallOption) (*IgnoreSettlementResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(IgnoreSettlementResponse) + err := c.cc.Invoke(ctx, GeneralLedgerService_IgnoreSettlement_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // GeneralLedgerServiceServer is the server API for GeneralLedgerService service. // All implementations should embed UnimplementedGeneralLedgerServiceServer // for forward compatibility. @@ -228,6 +242,9 @@ type GeneralLedgerServiceServer interface { GetSettlement(context.Context, *GetSettlementRequest) (*Settlement, error) // Restricted operator command. Requires the configured x-admin-token metadata. RetrySettlement(context.Context, *RetrySettlementRequest) (*RetrySettlementResponse, error) + // Operator-reviewed, terminal dismissal. Does not confirm or reverse payment. + // Requires x-admin-token and a recorded reason; only unlocked failed settlements. + IgnoreSettlement(context.Context, *IgnoreSettlementRequest) (*IgnoreSettlementResponse, error) } // UnimplementedGeneralLedgerServiceServer should be embedded to have @@ -279,6 +296,9 @@ func (UnimplementedGeneralLedgerServiceServer) GetSettlement(context.Context, *G func (UnimplementedGeneralLedgerServiceServer) RetrySettlement(context.Context, *RetrySettlementRequest) (*RetrySettlementResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method RetrySettlement not implemented") } +func (UnimplementedGeneralLedgerServiceServer) IgnoreSettlement(context.Context, *IgnoreSettlementRequest) (*IgnoreSettlementResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method IgnoreSettlement not implemented") +} func (UnimplementedGeneralLedgerServiceServer) testEmbeddedByValue() {} // UnsafeGeneralLedgerServiceServer may be embedded to opt out of forward compatibility for this service. @@ -551,6 +571,24 @@ func _GeneralLedgerService_RetrySettlement_Handler(srv interface{}, ctx context. return interceptor(ctx, in, info, handler) } +func _GeneralLedgerService_IgnoreSettlement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(IgnoreSettlementRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).IgnoreSettlement(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_IgnoreSettlement_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).IgnoreSettlement(ctx, req.(*IgnoreSettlementRequest)) + } + return interceptor(ctx, in, info, handler) +} + // GeneralLedgerService_ServiceDesc is the grpc.ServiceDesc for GeneralLedgerService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -614,6 +652,10 @@ var GeneralLedgerService_ServiceDesc = grpc.ServiceDesc{ MethodName: "RetrySettlement", Handler: _GeneralLedgerService_RetrySettlement_Handler, }, + { + MethodName: "IgnoreSettlement", + Handler: _GeneralLedgerService_IgnoreSettlement_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "ledger/v1/srv.proto", diff --git a/infrastructure/postgres/journal_repository_test.go b/infrastructure/postgres/journal_repository_test.go index a90154f..362c65d 100644 --- a/infrastructure/postgres/journal_repository_test.go +++ b/infrastructure/postgres/journal_repository_test.go @@ -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: diff --git a/infrastructure/postgres/migrations/000007_ignore_settlement.up.sql b/infrastructure/postgres/migrations/000007_ignore_settlement.up.sql new file mode 100644 index 0000000..9bea594 --- /dev/null +++ b/infrastructure/postgres/migrations/000007_ignore_settlement.up.sql @@ -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')); diff --git a/infrastructure/postgres/settlement_repository.go b/infrastructure/postgres/settlement_repository.go index 0657dc6..09852b6 100644 --- a/infrastructure/postgres/settlement_repository.go +++ b/infrastructure/postgres/settlement_repository.go @@ -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) { diff --git a/infrastructure/postgres/settlement_repository_test.go b/infrastructure/postgres/settlement_repository_test.go index d2f14ae..f261c47 100644 --- a/infrastructure/postgres/settlement_repository_test.go +++ b/infrastructure/postgres/settlement_repository_test.go @@ -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)}}, diff --git a/interface/grpc/settlement.go b/interface/grpc/settlement.go index b15346a..c306666 100644 --- a/interface/grpc/settlement.go +++ b/interface/grpc/settlement.go @@ -38,6 +38,20 @@ func (h *Handler) RetrySettlement(ctx context.Context, request *ledgerv1.RetrySe return &ledgerv1.RetrySettlementResponse{Settlement: settlementMessage(record)}, nil } +func (h *Handler) IgnoreSettlement(ctx context.Context, request *ledgerv1.IgnoreSettlementRequest) (*ledgerv1.IgnoreSettlementResponse, error) { + if err := h.requireAdmin(ctx); err != nil { + return nil, err + } + if h.settlement == nil { + return nil, status.Error(codes.Unimplemented, "settlement is not configured") + } + record, err := h.settlement.IgnoreByOperator(ctx, request.GetSettlementId(), request.GetActorId(), request.GetReason()) + if err != nil { + return nil, status.Error(codes.FailedPrecondition, err.Error()) + } + return &ledgerv1.IgnoreSettlementResponse{Settlement: settlementMessage(record)}, nil +} + func settlementMessage(record ledger.Settlement) *ledgerv1.Settlement { return &ledgerv1.Settlement{SettlementId: record.ID, SourceService: record.SourceService, SourceTransactionId: record.SourceTxID, IdempotencyKey: record.IdempotencyKey, Status: string(record.Status), Network: record.Network, TransactionHash: record.TransactionHash, Attempts: uint32(record.Attempts), LastError: record.LastError} } diff --git a/interface/grpc/settlement_test.go b/interface/grpc/settlement_test.go new file mode 100644 index 0000000..5656598 --- /dev/null +++ b/interface/grpc/settlement_test.go @@ -0,0 +1,21 @@ +package grpcadapter + +import ( + "context" + "testing" + + ledgerv1 "gl/gen/ledger/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +func TestIgnoreSettlementRequiresAdminToken(t *testing.T) { + handler := NewHandler(nil, nil).WithSettlementAdminToken("secret") + for _, token := range []string{"", "wrong"} { + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-admin-token", token)) + if _, err := handler.IgnoreSettlement(ctx, &ledgerv1.IgnoreSettlementRequest{}); status.Code(err) != codes.PermissionDenied { + t.Fatalf("unauthorized dismissal: %v", err) + } + } +}