feat: dismiss reviewed settlement failures
CI/CD Stage / stage (push) Failing after 37s

This commit is contained in:
2026-10-07 00:32:20 +03:30
parent 88e227f80c
commit d33ec51201
13 changed files with 385 additions and 57 deletions
+15
View File
@@ -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
+10
View File
@@ -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) {
+22
View File
@@ -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 {
+2 -1
View File
@@ -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() {
+139 -24
View File
@@ -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,
},
+35 -30
View File
@@ -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
+42
View File
@@ -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",
@@ -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)}},
+14
View File
@@ -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}
}
+21
View File
@@ -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)
}
}
}