The process for handling CT600 submissions seems to be that the first step the CT600 system takes is to accept and "acknowledge" the submission you make. This can go wrong, and immediately fail (as I have seen a number of times) if there are obvious errors in the submission (such as invalid format, or missing or invalid credentials). Anything else is then presumably added to a queue, from which it is later removed and analyzed.
Section 3.4 of Transaction.pdf outlines how (and when) to then follow up on what happened after a successful submission.
This process depends on three pieces of information recovered from the submission response: the correlation ID, the response endpoint, and the poll interval. I recovered one of those from the message yesterday, but not the other two, so let's do that now.
for tok, err := decoder.Token(); err == nil; tok, err = decoder.Token() {
switch tok := tok.(type) {
case xml.StartElement:
switch tok.Name.Local {
case "ResponseEndPoint":
for _, a := range tok.Attr {
if a.Name.Local == "PollInterval" {
log.Printf("ResponseEndPoint PollInterval: %s\n", a.Value)
}
}
}
case xml.EndElement:
switch tok.Name.Local {
case "Function":
log.Printf("Function: %s\n", data)
case "Qualifier":
log.Printf("Qualifier: %s\n", data)
case "CorrelationID":
log.Printf("CorrelationID: %s\n", data)
case "ResponseEndPoint":
log.Printf("ResponseEndPoint: %s\n", data)
}
case xml.CharData:
data = string(tok)
}
}
CT600_RESPONSE_ENDPOINT:accounts/internal/ct600/submission/submit.go
The PollInterval is an attribute on the ResponseEndpoint node, so needs to be recovered when we are looking at the start tag; the endpoint itself is data contained inside the element, so is recovered from the CharData and stored in data and then transferred when we process the end tag. Section 3.3 says that the PollInterval is in seconds.So I now see this output when I submit:
2025/07/09 07:27:46 Qualifier: acknowledgementAnd the documentation says to wait at least the recommended number of seconds before following up with an appropriate message on the given endpoint.
2025/07/09 07:27:46 Function: submit
2025/07/09 07:27:46 CorrelationID: 049B1F598F4C41938A5697241749C945
2025/07/09 07:27:46 ResponseEndPoint PollInterval: 10
2025/07/09 07:27:46 ResponseEndPoint: https://test-transaction-engine.tax.service.gov.uk/poll
Polling for the Response
As I indicated at the time, yesterday I started implemented the "poll" flow from Section 3.4, only to find that it wasn't the right flow for getting a list of all outstanding messages. But because I left the code there, I'm in a good position to implement it now.In a bit, we'll couple "poll" up to "submit", together with a timer, but for now I want to add it as a command line option. The command line will be like this:
$ ct600 --poll https://test-transaction-engine.tax.service.gov.uk/poll 049B1F598F4C41938A5697241749C945And it will then send an appropriate message to the gateway and display the XML response.
This really isn't all that hard. First we update main() to handle the poll code.
func main() {
conf, mode, err := config.ParseArguments(os.Args[1:])
if err != nil {
fmt.Printf("error parsing arguments: %v\n", err)
return
}
switch mode {
case config.SUBMIT_MODE:
err := submission.Submit(conf)
if err != nil {
fmt.Printf("submission failed: %v\n", err)
return
}
case config.LIST_MODE:
err := submission.List(conf)
if err != nil {
fmt.Printf("listing failed: %v\n", err)
return
}
case config.POLL_MODE:
err := submission.Poll(conf)
if err != nil {
fmt.Printf("polling failed: %v\n", err)
return
}
default:
log.Fatalf("there is no handler for mode %s\n", mode)
}
}
CT600_POLL:accounts/cmd/ct600/main.go
This obviously depends on ParseArguments having changed:const POLL_MODE = "--poll"
func ParseArguments(args []string) (*Config, string, error) {
mode := ""
conf := MakeBlankConfig()
for _, f := range args {
switch f {
case LIST_MODE:
fallthrough
case POLL_MODE:
fallthrough
case SUBMIT_MODE:
if mode != "" {
return nil, "", fmt.Errorf("cannot specify %s and %s", mode, f)
}
mode = f
default:
if mode == POLL_MODE {
err := PollParameter(conf, f)
if err != nil {
return nil, "", err
}
} else {
err := IncludeConfig(conf, f)
if err != nil {
return nil, "", fmt.Errorf("failed to read config %s: %v", f, err)
}
}
}
}
if mode == "" {
mode = SUBMIT_MODE // default is submit
}
return conf, mode, nil
}
CT600_POLL:accounts/internal/ct600/config/args.go
And that, in turn, depends on PollParameter:type Config struct {...
config.Configuration
Sender, Password string
Utr string
Vendor, Product, Version string
// Arguments for Polling
PollURI, CorrelationID string
}
func PollParameter(conf *Config, arg string) error {
if conf.PollURI == "" {
conf.PollURI = arg
return nil
} else if conf.CorrelationID == "" {
conf.CorrelationID = arg
return nil
} else {
return fmt.Errorf("usage: --poll <uri> <correlation-id>")
}
}
CT600_POLL:accounts/internal/ct600/config/config.go
And once we have lined up all our ducks, we can actually do the polling:package submission
import (
"log"
"github.com/gmmapowell/ignorance/accounts/internal/ct600/config"
"github.com/gmmapowell/ignorance/accounts/internal/ct600/govtalk"
)
func Poll(conf *config.Config) error {
pollOptions := &govtalk.EnvelopeOptions{Qualifier: "poll", Function: "submit", SendCorrelationID: true, CorrelationID: conf.CorrelationID, IncludeSender: false}
send, err := Generate(conf, pollOptions)
if err != nil {
return err
}
msg, err := transmitTo(conf.PollURI, send)
if err != nil {
return err
}
log.Printf("%s\n", string(msg))
return nil
}
CT600_POLL:accounts/internal/ct600/submission/poll.go
This requires a small tweak to transmit to extract transmitTo which takes a specific URI:func transmit(body io.Reader) ([]byte, error) {
return transmitTo("https://test-transaction-engine.tax.service.gov.uk/submission", body)
}
func transmitTo(uri string, body io.Reader) ([]byte, error) {
cli := &http.Client{}
resp, err := cli.Post(uri, "application/x-binary", body)
if err != nil {
log.Fatalf("error posting xml file: %v", err)
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatalf("error reading response: %v", err)
}
msg := string(respBody)
if strings.Contains(msg, "GovTalkErrors") {
fmt.Printf("%s", msg)
return respBody, fmt.Errorf("there was a GovTalkErrors block")
}
return respBody, nil
}
CT600_POLL:accounts/internal/ct600/submission/transmit.go
And none of us are surprised when this encounters an error and we see a splurge of XML on our screen (which I then reformatted using xmllint):$ xmllint --format fooI'm going to go out on a limb and say "I wouldn't give that error message myself", but I can't honestly say I wouldn't. It gives the impression that you have committed an error of commission (specifying a namespace that does not exist) whereas I believe I have committed an error of omission (not including anything in my Body element). But it comes down to the same thing: we are failing to submit a tax return because we simply haven't included one.
<?xml version="1.0" encoding="UTF-8"?>
<GovTalkMessage xmlns="http://www.govtalk.gov.uk/CM/envelope">
<EnvelopeVersion>2.0</EnvelopeVersion>
<Header>
<MessageDetails>
<Class>HMRC-CT-CT600</Class>
<Qualifier>error</Qualifier>
<Function>submit</Function>
<TransactionID/>
<CorrelationID>049B1F598F4C41938A5697241749C945</CorrelationID>
<ResponseEndPoint PollInterval="10">https://test-transaction-engine.tax.service.gov.uk/submission</ResponseEndPoint>
<Transformation>XML</Transformation>
<GatewayTimestamp>2025-07-09T08:26:17.424</GatewayTimestamp>
</MessageDetails>
<SenderDetails/>
</Header>
<GovTalkDetails>
<Keys/>
<GovTalkErrors>
<Error>
<RaisedBy>Department</RaisedBy>
<Number>3001</Number>
<Type>business</Type>
<Text>The submission of this document has failed due to departmental specific business logic in the Body tag.</Text>
</Error>
</GovTalkErrors>
</GovTalkDetails>
<Body>
<ErrorResponse xmlns="http://www.govtalk.gov.uk/CM/errorresponse" SchemaVersion="2.0">
<Application>
<MessageCount>1</MessageCount>
</Application>
<Error>
<RaisedBy>ChRIS</RaisedBy>
<Number>5001</Number>
<Type>business</Type>
<Text>Your submission contains an unrecognised namespace.</Text>
<Location>IRenvelope</Location>
</Error>
</ErrorResponse>
</Body>
</GovTalkMessage>
Extracting the Error Messages
I don't want to copy and paste and reformat this body every time I receive an error. So instead, I'm going to pull the errors out of GovTalkDetails and Body using the same Decoder technique we used to extract the list and correlation information last time. This actually has an additional advantage over "looking" for the errors, in that we just look for <Error> start and close tags.func transmitTo(uri string, body io.Reader) ([]byte, error) {
cli := &http.Client{}
resp, err := cli.Post(uri, "application/x-binary", body)
if err != nil {
log.Fatalf("error posting xml file: %v", err)
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatalf("error reading response: %v", err)
}
return respBody, handleErrors(respBody)
}
type Error struct {
raisedBy string
number string
ofType string
message string
location string
}
func handleErrors(msg []byte) error {
var all []*Error
decoder := xml.NewDecoder(bytes.NewReader(msg))
var errmsg *Error
var data string
for tok, err := decoder.Token(); err == nil; tok, err = decoder.Token() {
switch tok := tok.(type) {
case xml.StartElement:
if tok.Name.Local == "Error" {
errmsg = &Error{}
all = append(all, errmsg)
}
case xml.EndElement:
if tok.Name.Local == "Error" {
errmsg = nil
} else if errmsg != nil {
switch tok.Name.Local {
case "RaisedBy":
errmsg.raisedBy = data
case "Number":
errmsg.number = data
case "Type":
errmsg.ofType = data
case "Text":
errmsg.message = data
case "Location":
errmsg.location = data
}
}
case xml.CharData:
data = string(tok)
default:
// log.Printf("%T", tok)
}
}
if len(all) > 0 {
fmt.Printf("%d error(s) reported:\n", len(all))
fmt.Printf(" %-5s %-20s %-10s %-10s %-50s\n", "Code", "Raised By", "Location", "Type", "Message")
for _, e := range all {
fmt.Printf(" %-5s %-20s %-10s %-10s %-50s\n", e.number, e.raisedBy, e.location, e.ofType, e.message)
}
return fmt.Errorf("%d error(s) reported", len(all))
}
return nil
}
CT600_ERROR_MESSAGES:accounts/internal/ct600/submission/transmit.go
And, having done that, we get the nicely formatted version of what we saw above:2 error(s) reported:
Code Raised By Location Type Message
3001 Department business The submission of this document has failed due to departmental specific business logic in the Body tag.
5001 ChRIS IRenvelope business Your submission contains an unrecognised namespace.
polling failed: 2 error(s) reported
Polling Automatically
OK. All I really want to do now is fix this by actually generating a tax return (which is why we're here after all). But before I do, I want to add a sleep and polling to the submission process. We already extracted all the relevant information from the response, so waiting 10 seconds and polling once will not be too hard. This will save me time and effort as I debug the tax return generation. It bugs me that I have to wait 10s, but I may find it takes longer than that and I want to go around multiple times. Anyway, we can always come back and revisit this.This is not a very big change:
decoder := xml.NewDecoder(bytes.NewReader(msg))
var data string
var waitFor time.Duration = 10
pollOn := config.MakeBlankConfig()
for tok, err := decoder.Token(); err == nil; tok, err = decoder.Token() {
switch tok := tok.(type) {
case xml.StartElement:
switch tok.Name.Local {
case "ResponseEndPoint":
for _, a := range tok.Attr {
if a.Name.Local == "PollInterval" {
log.Printf("ResponseEndPoint PollInterval: %s\n", a.Value)
tmp, err := strconv.Atoi(a.Value)
if err != nil {
log.Printf("failed to parse number %s\n", a.Value)
} else {
waitFor = time.Duration(tmp)
}
}
}
}
case xml.EndElement:
switch tok.Name.Local {
case "Function":
log.Printf("Function: %s\n", data)
case "Qualifier":
log.Printf("Qualifier: %s\n", data)
case "CorrelationID":
log.Printf("CorrelationID: %s\n", data)
pollOn.CorrelationID = data
case "ResponseEndPoint":
log.Printf("ResponseEndPoint: %s\n", data)
pollOn.PollURI = data
}
case xml.CharData:
data = string(tok)
}
}
time.Sleep(waitFor * time.Second)
Poll(pollOn)
CT600_POLL_AFTER_SUBMIT:accounts/internal/ct600/submission/submit.go
And when we run this, we see the following (with a 10s delay in the middle):--- xmllint outputNothing more or less than I expected.
submit.xml validates
----
2025/07/09 10:12:19 Qualifier: acknowledgement
2025/07/09 10:12:19 Function: submit
2025/07/09 10:12:19 CorrelationID: 99C7229660CF41D39362214CDC27CB53
2025/07/09 10:12:19 ResponseEndPoint PollInterval: 10
2025/07/09 10:12:19 ResponseEndPoint: https://test-transaction-engine.tax.service.gov.uk/poll
---- xmllint output
submit.xml validates
----
2 error(s) reported:
Code Raised By Location Type Message
3001 Department business The submission of this document has failed due to departmental specific business logic in the Body tag.
5001 ChRIS IRenvelope business Your submission contains an unrecognised namespace.
No comments:
Post a Comment