This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)}},
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user