From 98cbd824232773df4e78c734a68f99e5a37f607f Mon Sep 17 00:00:00 2001 From: tomasz Date: Tue, 29 Sep 2026 16:01:18 +0200 Subject: [PATCH 1/2] feat(reject): add an ISO 20022 reason code to Reject --- CHANGELOG.md | 10 ++++++++++ reject.go | 14 ++++++++++++++ reject_test.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d15cfca..666dabe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- `RejectBody.Code` (`code`): an optional machine-readable reason next to the + free-text `reason`, drawn from the ISO 20022 `ExternalStatusReason1Code` list + that [TAIP-19] maps a Reject onto. First value: + `RejectCodeInvalidCreditorAccountNumber` (`AC03`) — the beneficiary side does + not hold the settlement address. An empty code is omitted from the JSON, so a + Reject without one is unchanged on the wire. + ## [0.8.0] - 2026-09-11 ### Changed @@ -48,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). [TAIP-5]: https://github.com/TransactionAuthorizationProtocol/TAIPs/blob/main/TAIPs/taip-5.md [TAIP-6]: https://github.com/TransactionAuthorizationProtocol/TAIPs/blob/main/TAIPs/taip-6.md +[TAIP-19]: https://github.com/TransactionAuthorizationProtocol/TAIPs/blob/main/TAIPs/taip-19.md ## [0.7.0] - 2026-07-23 diff --git a/reject.go b/reject.go index daffb58..fec37f1 100644 --- a/reject.go +++ b/reject.go @@ -9,12 +9,26 @@ import ( ) // RejectBody represents the body of a TAP Reject message (TAIP-4). +// +// Reason is free text for people. Code is a machine-readable reason drawn from +// the ISO 20022 ExternalStatusReason1Code list — the codes TAIP-19 maps a Reject +// onto (pacs.002 RJCT) — so a receiver can act on why without parsing Reason. +// Both are optional; a receiver that does not know a code ignores it. type RejectBody struct { Context string `json:"@context"` Type string `json:"@type"` Reason string `json:"reason,omitempty"` + Code string `json:"code,omitempty"` } +// Reject codes (ISO 20022 ExternalStatusReason1Code). +const ( + // RejectCodeInvalidCreditorAccountNumber (AC03, "Creditor account number + // invalid or missing"): the beneficiary side does not hold the settlement + // address the transfer names. + RejectCodeInvalidCreditorAccountNumber = "AC03" +) + func (b *RejectBody) TAPType() string { return TypeReject } // NewRejectMessage creates a new DIDComm message with a Reject body. diff --git a/reject_test.go b/reject_test.go index b03d58a..26f8043 100644 --- a/reject_test.go +++ b/reject_test.go @@ -3,6 +3,7 @@ package tap import ( "encoding/json" "os" + "strings" "testing" ) @@ -90,3 +91,49 @@ func TestRejectBody_ParseBody(t *testing.T) { t.Errorf("Reason: got %q", rb.Reason) } } + +// Code carries the ISO 20022 status reason next to the free-text reason, and is +// left out entirely when unset so a Reject without one reads exactly as before. +func TestRejectBody_Code(t *testing.T) { + cases := []struct { + name string + body RejectBody + wantJSON string + }{ + { + name: "code_round_trips", + body: RejectBody{Reason: "Blockchain address is not ours", Code: RejectCodeInvalidCreditorAccountNumber}, + wantJSON: `"code":"AC03"`, + }, + { + name: "empty_code_is_omitted", + body: RejectBody{Reason: "Compliance check failed"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + msg, err := NewRejectMessage("from", []string{"to"}, "thid", &tc.body) + if err != nil { + t.Fatalf("create: %v", err) + } + raw := string(msg.Body) + if tc.wantJSON == "" && strings.Contains(raw, `"code"`) { + t.Fatalf("empty code must be omitted, got %s", raw) + } + if tc.wantJSON != "" && !strings.Contains(raw, tc.wantJSON) { + t.Fatalf("body %s does not contain %s", raw, tc.wantJSON) + } + parsed, err := ParseBody(msg) + if err != nil { + t.Fatalf("parse: %v", err) + } + rb, ok := parsed.(*RejectBody) + if !ok { + t.Fatalf("expected *RejectBody, got %T", parsed) + } + if rb.Code != tc.body.Code { + t.Errorf("Code: got %q, want %q", rb.Code, tc.body.Code) + } + }) + } +} From bac69134fde2ff69e7485f27e0dfb07db4fa0085 Mon Sep 17 00:00:00 2001 From: tomasz Date: Wed, 30 Sep 2026 07:44:02 +0200 Subject: [PATCH 2/2] feat(reject): send only the reject codes this library defines --- CHANGELOG.md | 4 +++- reject.go | 32 +++++++++++++++++++++++++------- reject_test.go | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 666dabe..1be4f7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). that [TAIP-19] maps a Reject onto. First value: `RejectCodeInvalidCreditorAccountNumber` (`AC03`) — the beneficiary side does not hold the settlement address. An empty code is omitted from the JSON, so a - Reject without one is unchanged on the wire. + Reject without one is unchanged on the wire. `NewRejectMessage` refuses a code + this library does not define (`ErrInvalidBody`); `ParseBody` keeps any code, so + a receiver can ignore one it does not know. ## [0.8.0] - 2026-09-11 diff --git a/reject.go b/reject.go index fec37f1..3f35c8a 100644 --- a/reject.go +++ b/reject.go @@ -13,26 +13,44 @@ import ( // Reason is free text for people. Code is a machine-readable reason drawn from // the ISO 20022 ExternalStatusReason1Code list — the codes TAIP-19 maps a Reject // onto (pacs.002 RJCT) — so a receiver can act on why without parsing Reason. -// Both are optional; a receiver that does not know a code ignores it. +// Both are optional. NewRejectMessage sends only the codes defined below; +// ParseBody keeps any code, so a receiver ignores one it does not know rather +// than dropping the message. type RejectBody struct { - Context string `json:"@context"` - Type string `json:"@type"` - Reason string `json:"reason,omitempty"` - Code string `json:"code,omitempty"` + Context string `json:"@context"` + Type string `json:"@type"` + Reason string `json:"reason,omitempty"` + Code RejectCode `json:"code,omitempty"` } -// Reject codes (ISO 20022 ExternalStatusReason1Code). +// RejectCode is an ISO 20022 ExternalStatusReason1Code value on a Reject. +type RejectCode string + +// Reject codes this library sends. Add a value here to allow sending it. const ( // RejectCodeInvalidCreditorAccountNumber (AC03, "Creditor account number // invalid or missing"): the beneficiary side does not hold the settlement // address the transfer names. - RejectCodeInvalidCreditorAccountNumber = "AC03" + RejectCodeInvalidCreditorAccountNumber RejectCode = "AC03" ) +// Known reports whether c is a code this library sends; the empty code (no +// code) counts as known. +func (c RejectCode) Known() bool { + switch c { + case "", RejectCodeInvalidCreditorAccountNumber: + return true + } + return false +} + func (b *RejectBody) TAPType() string { return TypeReject } // NewRejectMessage creates a new DIDComm message with a Reject body. func NewRejectMessage(from string, to []string, thid string, body *RejectBody) (*didcomm.Message, error) { + if !body.Code.Known() { + return nil, fmt.Errorf("%w: unknown reject code %q", ErrInvalidBody, body.Code) + } body.Context = TAPContext body.Type = TypeReject diff --git a/reject_test.go b/reject_test.go index 26f8043..cd5acd7 100644 --- a/reject_test.go +++ b/reject_test.go @@ -2,9 +2,12 @@ package tap import ( "encoding/json" + "errors" "os" "strings" "testing" + + didcomm "github.com/notabene-id/go-didcomm" ) func TestNewRejectMessage(t *testing.T) { @@ -137,3 +140,48 @@ func TestRejectBody_Code(t *testing.T) { }) } } + +// Sending is strict: only a code this library defines goes out, so a typo or an +// invented code fails at construction. Reading is lenient: an unknown code from +// another implementation is kept for the receiver to ignore, not a parse error. +func TestRejectCode_SendStrictReadLenient(t *testing.T) { + cases := []struct { + name string + code RejectCode + wantErr bool + }{ + {name: "known_code_is_sent", code: RejectCodeInvalidCreditorAccountNumber}, + {name: "no_code_is_sent", code: ""}, + {name: "unknown_code_is_refused", code: "AC99", wantErr: true}, + {name: "lowercase_known_code_is_refused", code: "ac03", wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := NewRejectMessage("from", []string{"to"}, "thid", &RejectBody{Reason: "r", Code: tc.code}) + if tc.wantErr { + if !errors.Is(err, ErrInvalidBody) { + t.Fatalf("want ErrInvalidBody, got %v", err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } + + t.Run("unknown_code_is_kept_on_read", func(t *testing.T) { + msg := &didcomm.Message{ + Type: TypeReject, + Body: json.RawMessage(`{"@context":"https://tap.rsvp/schema/1.0",` + + `"@type":"https://tap.rsvp/schema/1.0#Reject","reason":"r","code":"AC04"}`), + } + parsed, err := ParseBody(msg) + if err != nil { + t.Fatalf("parse: %v", err) + } + if got := parsed.(*RejectBody).Code; got != "AC04" { + t.Errorf("Code: got %q, want AC04", got) + } + }) +}