Thursday, July 10, 2025

Polling for Responses


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: acknowledgement
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
And the documentation says to wait at least the recommended number of seconds before following up with an appropriate message on the given endpoint.

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 049B1F598F4C41938A5697241749C945
And 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 foo
<?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>
I'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.

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 output
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.
Nothing more or less than I expected.

Conclusion

We have now successfully managed to interrogate the Government's system and find out what happened to our submission. While the fact that it failed because it was incomplete should surprise nobody, the fact that we have now connected the polling directly to the submission means that we are set up for the next step, which is actually generating a CompanyTaxReturn.

No comments:

Post a Comment