Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ 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. `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

### Changed
Expand Down Expand Up @@ -48,6 +59,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

Expand Down
38 changes: 35 additions & 3 deletions reject.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,48 @@ 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. 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"`
Context string `json:"@context"`
Type string `json:"@type"`
Reason string `json:"reason,omitempty"`
Code RejectCode `json:"code,omitempty"`
}

// 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 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

Expand Down
95 changes: 95 additions & 0 deletions reject_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ package tap

import (
"encoding/json"
"errors"
"os"
"strings"
"testing"

didcomm "github.com/notabene-id/go-didcomm"
)

func TestNewRejectMessage(t *testing.T) {
Expand Down Expand Up @@ -90,3 +94,94 @@ 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)
}
})
}
}

// 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)
}
})
}
Loading