Learning

Go on this site: the camt and pain readers

November 12, 2026

The work page points at the software page. Two of the tools there read an ISO 20022 file in Go: a camt.053 bank statement, and a pain.001 credit transfer. This note is a reading of that code for someone coming from Java. It describes the programs in this repository. It is not a claim about a production payment system.

The decision overview for spending more time on Go or on Rust is a separate note. This one is only the code that is already here.

The examples are two programs, not two files. Each program is a package main split across a handler, a parser, and a test.

Where the code is

The statement reader:

  • netlify/functions/camt/main.go is the entry point.
  • netlify/functions/camt/parse.go turns camt.053 XML into the JSON the page shows.
  • netlify/functions/camt/parse_test.go checks the parser against the example file.
  • netlify/functions/camt/go.mod names the module hostlund.com/camt and depends on github.com/aws/aws-lambda-go v1.55.1. The go line says 1.26.

The payment reader:

  • netlify/functions/pain/main.go
  • netlify/functions/pain/parse.go
  • netlify/functions/pain/parse_test.go
  • netlify/functions/pain/go.mod, module hostlund.com/pain, with the same dependency.

The two programs do not import each other. The namespace wrapper and the small string helpers are copied into each parse.go.

The example documents the tests read are static/samples/camt053-example.xml and static/samples/pain001-example.xml. The software page describes both as written for the site: made-up names and amounts, and a specimen IBAN.

What each program does

parseCamt in netlify/functions/camt/parse.go decodes a bank-to-customer statement. The document element it maps is BkToCstmrStmt/Stmt. For each statement it builds a statementView: an id, a creation time, the account (IBAN, or the other account id when there is no IBAN, plus currency and name), the balances, and the entries. Balance codes such as OPBD and CLBD become labels ("Opening booked", "Closing booked") in balanceLabel. Credit and debit indicators CRDT and DBIT become "Credit" and "Debit" in direction.

An entry can contain transaction details. entryViews flattens NtryDtls/TxDtls. When those details exist, the amount prefers the transaction amount, then the detail amount, then the entry amount. The counterparty depends on the direction: a debit's other party is the creditor, and a credit's other party is the debtor, falling back to the creditor when the debtor name is empty. That logic is counterparty. When an entry has no transaction details, the view still has the entry amount, dates, status, servicer reference, and additional info.

The group header of the sample statement (GrpHdr, message id SAMPLE-2026-02) is not mapped. encoding/xml skips elements that no struct field asks for.

parsePain in netlify/functions/pain/parse.go decodes a customer credit transfer initiation, CstmrCdtTrfInitn. The JSON is one paymentView: the group header (message id, created time, initiator, count, control sum) and a list of payment blocks. Each block has the debtor, the debtor IBAN (or the other account id), the debtor BIC (BICFI, or the older BIC tag when BICFI is empty), the execution date, and the transfers. A transfer's account is the creditor IBAN, or the other account id. Remittance is the unstructured Ustrd lines joined with spaces.

A few parsed fields are not copied onto the JSON. On a transfer, InstrId and the creditor BIC are unmarshaled and then left off transferView. The page does not show them.

Both parsers reject an empty body and a document that does not have the expected shape. camt returns "no statement was found in the file" when no Stmt was mapped. pain returns "no payment was found in the file" when the message id is empty and there are no payment blocks.

How the Experience section shows them

The work page does not paste this source. The sentence there links to the software page. The software page links to /camt/ and /pain/, and describes the two readers in a sentence each.

src/pages/camt.js posts the XML to /api/camt and renders statements: the account line, a balance table (label, date, creditDebit, amount, currency), and an entry table (bookingDate, valueDate, creditDebit, amount, counterparty, endToEndId or reference, remittance).

src/pages/pain.js posts to /api/pain and renders the payment object itself: initiator, message id, count, control sum, and each block's debtor, IBAN, BIC, execution date, and transfers.

Both pages also show the example XML and say the file is not stored. Nothing in the Go writes the body to disk or to a database. The response sets cache-control: no-store.

The third software page, FinTS, is not Go.

How a request is handled

The shape of main.go is the same in both programs.

handler accepts only POST. Anything else is HTTP 405. requestBody base64-decodes the body when IsBase64Encoded is set, which is the API Gateway proxy behaviour, and otherwise uses the body as bytes. evaluate rejects a body over maxBytes (1000000) with HTTP 413, calls the parser, and on success returns 200. camt wraps the slice as {"statements": ...}. pain marshals the paymentView as the whole body. A parser error becomes HTTP 400 with {"error": "..."}.

main has two modes. The statement program reads stdin when CAMT_DEV is stdin. The payment program does the same when PAIN_DEV is stdin. In that mode it prints the status code, a newline, and the JSON. Otherwise it calls lambda.Start(handler).

In gatsby develop, gatsby-node.js mounts POST /api/camt and POST /api/pain. Each mount runs go run . in the function directory with that environment variable set to stdin, passes the request body as the process input, and splits stdout on the first newline into a status and a payload. The timeout is 60 seconds. If the process fails or prints no newline, the dev server responds 500 with {"error":"The file could not be read."}.

On the deployed site, netlify.toml redirects /api/camt to /.netlify/functions/camt and /api/pain to /.netlify/functions/pain. Nothing in this repo sets CAMT_DEV or PAIN_DEV for that deploy, so main takes the lambda.Start path. The Gatsby build command in netlify.toml is npm install && npm run build. It does not compile the Go. Netlify builds a function from each of netlify/functions/camt and netlify/functions/pain, which are package main directories with their own go.mod.

How to build and run them

From netlify/functions/camt or netlify/functions/pain:

go test
go run .

go test runs TestExampleStatement or TestExamplePayment, which read the sample XML three directories up, and TestRejectsEmptyAndOtherXML, which checks that an empty body and an unrelated <Document> are errors.

go run . with neither dev variable set starts the Lambda handler and waits for an invocation. The path that the site uses locally is the dev variable, through the Gatsby process above. Sending a file by hand looks like this, from the function directory:

CAMT_DEV=stdin go run . < ../../../static/samples/camt053-example.xml

The first line of stdout is the status code. The rest is the JSON.

Types and functions

There are no classes. The data types are structs. The XML structs carry xml struct tags, and the view structs carry json struct tags. A tag such as xml:"BkToCstmrStmt>Stmt" maps a nested element. xml:"Ccy,attr" maps an attribute. xml:",chardata" maps the text inside the element. A slice field maps repeated elements: []entry with xml:"Ntry", or []string with xml:"RmtInf>Ustrd".

In the Java libraries this is closest to JAXB or Jackson annotations on fields. The path syntax (Parent>Child) is doing the job a chain of nested types would do. Both styles are in this code. amount is its own struct because currency is an attribute and the value is character data. The account name is a string with a path tag, xml:"Ownr>Nm", because nothing else from that subtree is needed.

localNames is the interesting type. It wraps an xml.Decoder and implements Token() (xml.Token, error). parseCamt and parsePain pass it to xml.NewTokenDecoder. On each start and end element, and on each attribute, the method clears Name.Space. The namespace URI is discarded, so one set of structs accepts a camt.053 whose xmlns is urn:iso:std:iso:20022:tech:xsd:camt.053.001.02 and a later version of the same statement shape. The camt page says this in a sentence. The pain sample is pain.001.001.09, and the same wrapper is why the tag names matter and the URN does not.

The functions, in the order a request uses them:

camt: main, handler, requestBody, evaluate, parseCamt, toView, entryViews, flatten, counterparty, direction, balanceLabel, firstText, cleanList, jsonStatus, jsonResponse.

pain: main, handler, requestBody, evaluate, parsePain, toView, firstText, cleanList, jsonStatus, jsonResponse.

jsonResponse in the camt program marshals on its own. In the pain program it calls jsonStatus and uses that result. The behaviour a caller sees is the same.

Idioms, for a Java developer

One package, several files. Every file says package main. All of the functions and types in the directory are in one package, and main is the entry point, the same role as public static void main. There is no class wrapping them. Java would have made parseCamt a method on a class. Go lets the function sit at package scope.

Visibility is the capital letter. parseCamt, handler, and statement start with a lowercase letter, so another module cannot call them. The tests are package main, not package main_test, so they can call parseCamt directly. A Java test in the same package can call a package-private method for the same reason. The module path in go.mod is the import path, closest to Maven coordinates, and nothing in this repo imports either module. They are programs, not libraries.

Errors are values. parseCamt returns ([]statementView, error). The caller writes if err != nil. There is no throw and no checked exception. fmt.Errorf builds the error. HTTP status is not the Go error return. handler almost always returns a nil error and puts 400, 405, or 413 on the response. The error return is the function runtime's failure channel. A nil error with a 400 response means "the invocation succeeded, the file did not."

Structs instead of beans. A struct is a block of fields. The zero value is usable: pain returns paymentView{} on failure, which is the analogue of an empty object whose fields are "", nil, or 0. There is no constructor unless someone writes one. These files do not. toView fills the struct in a composite literal, which reads like a constructor call with named arguments.

Slices instead of ArrayList. views := make([]statementView, 0, len(doc.Statements)) allocates a slice with length 0 and enough capacity for the statements, then append adds to it. The capacity argument is the same idea as new ArrayList<>(n). append may allocate a larger array if the capacity is exceeded. flatten starts from a nil slice and appends. A nil slice marshals as JSON null if it is ever returned directly. The views in this code are built with make, so an empty list of balances or transfers marshals as [].

The interface is implicit. localNames does not declare that it implements a type. xml.NewTokenDecoder asks for a TokenReader, which is an interface with one method, Token() (Token, error). Having the method is enough. Java would say implements TokenReader. The method has a value receiver, func (l localNames) Token(), and the decoder pointer lives inside the struct, so the copy still reads the same underlying decoder. The loop for i := range element.Attr uses the index because a range value is a copy. Writing through the index updates the attribute that is returned. That is a common Go slip, and this code avoids it.

switch does not fall through. direction and balanceLabel switch on the upper-cased code and return from each case. There is no break. A case ends at the next case.

Variadic helpers. firstText(values ...string) is String... in Java. It returns the first value that is not blank after strings.TrimSpace. Dates use it so a date-only tag wins when it is present and a date-time tag is the fallback: firstText(item.BookingDate, item.BookingTime).

any. map[string]any{"statements": statements} is a map from string to an arbitrary value, closest to Map<String, Object>. any is a predeclared alias for interface{}. This code does not use type parameters beyond that.

Short declarations. stmt := statements[0] declares and assigns. The type comes from the right-hand side, the same information var carries in Java, written at the call.

Tests. parse_test.go imports the testing package. t.Fatal fails the test and stops it, in the role of an assertion failure. There is no test class and no framework in these files. go test finds the files by the _test.go suffix.

What this code does not use

There are no goroutines, no channels, and no mutexes. Concurrency is not a reason this program is in Go. The reasons visible in the code are the small runtime, the XML and JSON libraries, a single binary, and the function entry point.

There is no shared internal package. The duplication of localNames, amount, firstText, and cleanList is the cost of that.

TODO: after changing one of the duplicated helpers, write whether I still want two copies. That is my note to make once I have edited the code, not a refactor hidden in this write-up.

TODO: a line on what felt familiar from Java the first time I reread parse.go, and what did not. I want that in my own words.

All notes