Skip to content

fix: deliver every portion of a response the bank spreads over several messages (code 3040) - #31

Merged
robocode13 merged 2 commits into
robocode13:mainfrom
phkoenig:fix/parted-response-segments
Aug 1, 2026
Merged

fix: deliver every portion of a response the bank spreads over several messages (code 3040)#31
robocode13 merged 2 commits into
robocode13:mainfrom
phkoenig:fix/parted-response-segments

Conversation

@phkoenig

Copy link
Copy Markdown
Contributor

The symptom

When a bank spreads a response over several messages, the data never reaches the caller. Not with an error — with success: true, zero transactions and no warning:

const response = await client.getCreditCardStatements(account, from, to);
// response.success === true
// response.statements === []   // the bank sent 233 transactions

The wider the date range, the more likely it is. A caller has no way to tell this apart from an account that genuinely had no activity, which is what makes it expensive: a sync can report success for weeks while silently losing everything.

What I measured

Berliner Volksbank (GAD backend), credit card account via HKCAZ v1, same account, same day, only the range varied:

Range Bank's answer Transactions before after
40 days code 20 (ok) 105 105
60 days code 3040 0 206
75 days code 3040 0 233

The two returned CAMT documents cover adjacent, non-overlapping periods, so nothing is counted twice.

Three defects, all in the same path

1 · Delivery. handlePartedMessages reassigned its responseMessage parameter to the follow-up message and spliced the assembled segment into that one. The caller still holds the first message, which still contains the unresolved PARTED placeholder — so findSegment finds nothing.

2 · Assembly. The portions were concatenated as raw bytes, on the assumption that a segment continues mid-field. It does not. Each portion is a complete, self-contained response segment: a follow-up HICAZ repeats account and CAMT descriptor before carrying its own share of the documents. Gluing the raw text produced an unparseable segment — the CAMT parser reports Extra text at the end.

3 · Only the first placeholder was resolved. One bank message may carry several response segments. The rest stayed in the tree as PARTED, invisible to findAllSegments.

Separately, Message.decodeSegment compared the segment id with a plain startsWith, so looking for HICAZ also caught HICAZS (and HIEKA caught HIEKAS). A parameter segment held back as PARTED is never decoded. A segment starts with SEGID:number:version, so the colon belongs in the comparison.

What changed

Every portion is decoded on its own, and all of them are placed into the message the caller holds.

Combining the payloads needs to know what they mean, so that step moved to the interactions, which now collect via findAllSegments:

Interaction How portions combine
CAMT (HICAZ) append the document lists
SEPA accounts (HISPA) append the account lists
Credit card (DIKKU) append the transactions, balance is identical in each
MT940 (HIKAZ) join — one continuous stream
MT535 (HIWPD) join — one continuous stream

That distinction matters: joining a list of CAMT documents produces invalid XML, and appending MT940 fragments produces an unparseable stream.

No public API change. A response that fits into one message takes exactly the path it took before.

Tests

src/tests/partedResponse.test.ts, 5 cases: a parted response delivered to the caller's message; portions decoded individually rather than byte-glued; several response segments in one message; a parameter segment (HICAZS) passing through untouched; and the unparted case as a regression guard.

117 tests pass (112 before). There was no test for code 3040 previously, which is why this could go unnoticed.

Scope of verification — please read

The live evidence above comes from one bank and one backend (Berliner Volksbank, GAD, HKCAZ v1). Everything else — MT940, MT535, SEPA, credit card via DIKKU — follows the specification and is covered by tests, but I have not seen a real 3040 response on those paths. The per-interaction combination rules are the part I would look at hardest, since they are where a wrong assumption would show up as corrupted rather than missing data.

Happy to adjust naming, comment density or structure to your preference.


Found while tracking down a 69-day gap in a production credit card sync. Written with AI assistance (noted in the commit trailers); the measurements and the review are my own.

phkoenig and others added 2 commits July 28, 2026 20:57
A response too large for one message is announced with return code 3040 and a
continuation mark. Repeating the order yields the rest. Two defects made that
data vanish without a trace — `success: true`, zero transactions, no warning.

1. Delivery. `handlePartedMessages` reassigned its `responseMessage` parameter to
   the follow-up message and spliced the assembled segment into THAT one. The
   caller kept the first message, which still held the unresolved PARTED segment,
   so `findSegment` found nothing.

2. Assembly. The portions were concatenated as raw bytes, assuming one segment
   continues mid-field. It does not: each portion is a COMPLETE response segment.
   A follow-up HICAZ repeats account and CAMT descriptor before carrying its own
   share of the documents, so gluing the raw text produced an unparseable segment
   ("Extra text at the end" from the CAMT parser).

Every portion is now decoded on its own and all of them are placed into the
message the caller holds. Combining the payloads requires knowing what they mean
— one MT940 stream continues, a list of CAMT documents is appended — so that step
moved to the interactions, which collect via `findAllSegments`: CAMT and SEPA
accounts append their lists, MT940 and MT535 join their streams, credit card
statements append their transactions.

Measured against a live bank (Berliner Volksbank, HKCAZ v1) on a credit card
account whose volume exceeds one message:

    75 days   0 -> 233 transactions
    60 days   0 -> 206 transactions
    40 days   105 unchanged (fits one message, never affected)

The two returned CAMT documents cover adjacent, non-overlapping periods, so
nothing is counted twice. Regression tests cover the parted and the unparted case
plus the interaction; there was no test for code 3040 before, which is why this
could go unnoticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nts for responses

Two gaps in the parted-response handling, both found by adversarial review.

Only the FIRST placeholder was resolved. A bank message may carry several response
segments; the rest stayed in the tree as PARTED, where findAllSegments cannot see
them — lost without a trace, the same silent shape as the defect this code was
written to fix.

And the id comparison was a plain startsWith, so looking for HIEKA also caught
HIEKAS, HICAZ caught HICAZS. A parameter segment held back as PARTED is never
decoded. A segment starts with 'SEGID:number:version', so the colon belongs in the
comparison.

Tests cover both: two response segments in one message, and a parameter segment that
must pass through untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@robocode13

Copy link
Copy Markdown
Owner

Thank you for this contribution. I myself didn't have this problem before even with a 1.400 transaction statement, but then every bank handles this differently i guess. At least i can confirm that everything i can test with four different banks is still working, so i will adopt this PR.

@robocode13
robocode13 merged commit 749cfef into robocode13:main Aug 1, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants