Skip to content

feat(bank): add a receive-IBAN check to the bank client and hook - #192

Merged
TaprootFreak merged 7 commits into
developfrom
feat/bank-receive-iban-check
Jul 27, 2026
Merged

feat(bank): add a receive-IBAN check to the bank client and hook#192
TaprootFreak merged 7 commits into
developfrom
feat/bank-receive-iban-check

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

What

Adds PUT /v1/bank/receiveIban to @dfx.swiss/core and @dfx.swiss/react: the BankUrl.receiveIban route, the ReceiveIbanStatus enum (DfxIban, NotMatched, InvalidIban, LoginRequired), the ReceiveIbanCheck response type, useBank().checkReceiveIban(iban) in the React hook, and DfxApiClient.bank.checkReceiveIban(iban) in the API client. getBanks / bank.list() and the value of BankUrl.get are unchanged.

Why

The support form's "missing transfer" reason requires picking a receiver IBAN from a dropdown fed by GET /bank, which lists only the collective accounts. Customers with a personal deposit IBAN cannot find their actual receiver there and are forced to select an account that is not theirs. The dropdown is being replaced by a free-text field; this endpoint lets the entered IBAN be validated server-side so the customer gets a meaningful response.

Ordering — the API side has landed

DFXswiss/api#4391 was merged on 27 July. The wire contract in this PR was re-checked against
the merged state of develop in the api repo and matches exactly: path, method, request body,
response shape and all four enum values.

Merging here publishes to npm, and that release is what unblocks the downstream consumer,
DFXswiss/services#1191 — its CI installs from the lockfile and therefore cannot go green until
a published version carries checkReceiveIban.

Consuming this

ReceiveIbanStatus and both checkReceiveIban methods carry JSDoc that reaches the published .d.ts, so the details show up in the IDE. In short: there is no abort signal, so a consumer driving this from a text field must debounce and discard responses that do not belong to the most recent request. An HTTP failure never arrives as a ReceiveIbanStatus; the call rejects with an ApiException, and that means the IBAN could not be checked, not that it was rejected.

Also note that NotMatched is deliberately not a statement that DFX does not own the IBAN; the JSDoc on the enum is the source of truth for the user-facing wording.

Version pins for downstream

@dfx.swiss/core is not a direct dependency of services; it arrives transitively through @dfx.swiss/react, the only package here that depends on it. Bumping react therefore pulls the new core version along automatically and yields a single copy — there is no separate core pin to raise.

Worth knowing for anyone who does pin core directly (nobody today): core is on 0.x, where lerna's feat bump raises the minor and a ^0.4.1 range does not accept 0.5.0, so such a pin would have to be raised explicitly.

One reviewer-facing nuance: checkReceiveIban is a required member of BankInterface, so anyone who constructs the return type of useBank() by hand — a test mock, for instance — has to add the new method. This follows the established convention here — 045ab68 (#181) added three required members to a hook interface while @dfx.swiss/react stood at 1.4.0-beta.1, and the next release went to 1.4.0-beta.2, with no major bump — so it is intentionally not released as a breaking change, but it is a real source-compatibility detail and should be an explicit decision rather than an oversight.

Testing

packages/core/src/__tests__/bank-api.test.ts covers the call shape of checkReceiveIban over two distinct example IBANs — so an implementation that sent a hardcoded one would fail the second row — and pins that no token: false is passed — unlike list(), this endpoint must send the bearer token when one exists, otherwise the API can never distinguish an authenticated NotMatched from LoginRequired. The same file keeps list() asserted with token: false, so the deliberate asymmetry between the two methods is recorded in one place.

The status documentation is JSDoc rather than line comments, because tsc strips line comments from the declaration output and a consumer only ever sees dist/. Verified on the built artifact: packages/core/dist/definitions/bank.d.ts carries all four blocks.

Run locally from the repo root, the same chain as CI (.github/workflows/pr.yaml):

Command Result
lerna run lint Successfully ran target lint for 4 projects, 0 errors, no new warnings
lerna run format:check All matched files use Prettier code style
lerna run build Successfully ran target build for 4 projects
lerna run test core 68 passed (6 suites); bip322-multisig 45 passed, 1 todo

The support form asks a customer which account their missing transfer went to. Today
that field is a required dropdown filled from GET /bank, which lists only the collective
accounts - a customer who deposits through a personal IBAN can never find their own
receiving IBAN there and has to pick an account they never transferred to. The field is
becoming free text, and the API gained PUT /bank/receive-iban so the frontend can tell
the customer straight away whether what they typed is an IBAN DFX receives money on.

This exposes that call: BankUrl.receiveIban, the ReceiveIbanStatus enum with its four
wire values, the ReceiveIbanCheck response type, BankApi.checkReceiveIban on the client,
and checkReceiveIban on the useBank hook.

NotMatched deliberately does not claim the IBAN is unknown to DFX - personal IBANs of
other accounts are never checked, and an account merge leaves personal IBANs behind on
the former account, so a customer's own older IBAN can land there too. The comment on the
enum says so, because the wording a frontend shows depends on it.

Unlike the neighbouring list(), the new client method does not pass token: false. The
endpoint distinguishes an authenticated check from LoginRequired, so the bearer token has
to travel with the request whenever one exists.

No existing API changes: getBanks, BankUrl.get and the Bank type are untouched.
Review found two gaps in the previous commit.

The client method had no test, although CONTRIBUTING asks for tests when functionality is
added to core, and the pattern was there all along - auth-api.test.ts asserts the exact
call shape of a client method, token flag included. The relevant invariant here is the
absence of token: false, since dropping the bearer token would make LoginRequired
unresolvable and silently disable the personal-IBAN half of the check. bank-api.test.ts
now pins the URL, method and body, asserts that no token key is present, and keeps list()
asserted with token: false so the deliberate asymmetry between the two is written down.

The enum comments were line comments. tsc strips those from the declaration output, and a
consumer only ever sees dist/ - so the warning that NotMatched makes no claim about DFX
ownership would never have reached the developer writing the customer-facing wording, who
is exactly who it was written for. All four members are JSDoc now and reach
dist/definitions/bank.d.ts, verified on the built artifact rather than the source.

DfxIban is phrased as ownership rather than active receiving, because most rows in the
bank registry are decommissioned accounts and a present-tense claim would be untrue.
…ban branches

Review found the test title stating the inverse of the invariant it guards. The
API answers LoginRequired precisely when no token arrives, so naming the
token-preserving test after that status said the opposite of what it pins, and mocking
LoginRequired made the wrong reading look deliberate. Title and mock now agree with the
assertion and its comment; the assertion itself is unchanged.

The DfxIban documentation named only the collective-account branch. The other one - the
requesting account's own personal deposit IBAN - is the case the endpoint exists for,
and with the enum documented as the source of truth for the customer-facing wording,
leaving the positive case unstated was the worst half to omit.
The API repository documents camelCase for URL routes, and the closest sibling for
this very concept - GET /v1/buy/personalIban - follows it, as do the two read-with-a-body
precedents this endpoint was modelled on. The hyphenated form was an unjustified
deviation.

Doing it now is free: the endpoint is unreleased and has no consumer in production.
Once this package ships the call, the same rename would break everyone using it.

Only the route value changes. The BankUrl key, the enum values, the method and type
names and the documentation all stay as they are; BankApi and the hook reference the
constant symbolically and follow automatically. The test keeps a literal rather than the
constant, so an unintended change to the route still fails a test.
…ree status claims

Two independent reviews found that the enum documentation had been copied from the API
repository without the corrections made there afterwards. Three statements were therefore
still wrong here.

LoginRequired described itself as "the caller is not logged in". That excludes the case it
also covers: an authenticated token that carries no account-scoped identity. It now names
the cause instead of the login state.

DfxIban claimed that many bank rows are decommissioned. That quantifies over the contents
of the production table, cannot be checked from this repository, and was never the point -
what a consumer needs is that a match does not imply the account still accepts funds.

NotMatched explained itself by naming a function in the API repository. A reader of this
package cannot open it, and the statement can go stale there without anything failing here.
The consequence a consumer actually needs survives without the mechanism: an IBAN the same
customer used earlier can land in this state, so it must never be phrased as "this IBAN
does not belong to DFX".

The concurrency and throttling requirements lived only in the pull request description,
which nobody sees who installs the package. They are now JSDoc on both public methods and
verified present in the built declarations. For the React side that meant annotating the
interface member rather than the function inside the hook: useBank emits as returning
BankInterface, so a comment on the implementation would have produced no declaration at all
and the note would have been invisible for the third time in this repository.
@TaprootFreak

Copy link
Copy Markdown
Contributor Author

This diff went through four review passes before two independent reviews came back with no findings. What they caught:

The status documentation had been copied from the API repository without the corrections made there afterwards. Three statements were consequently wrong here: LoginRequired described itself as "not logged in", which excludes the case it also covers — an authenticated token carrying no account-scoped identity; DfxIban claimed that many bank rows are decommissioned, which quantifies over production data and cannot be checked from this repository; and NotMatched explained itself by naming a function in the API repository, which a reader of this package cannot open and which can go stale there without anything failing here. The consequence a consumer actually needs survives in all three cases.

The concurrency and throttling requirements were only in this description, which nobody sees who installs the package. They are now JSDoc on both public methods, verified present in the built declarations rather than in the source. On the React side that meant annotating the interface member and not the function inside the hook: useBank emits as returning BankInterface, so a comment on the implementation reaches no declaration at all.

A test was missing for the one deliberate asymmetry in the client: unlike list(), this call must not suppress the bearer token, or the API could never distinguish an authenticated unmatched result from one where personal IBANs were never checked. The call shape and the absence of that flag are now pinned, and inverting either fails the test.

Two further points were rejected with evidence rather than applied: the commit type of the route rename, since amending pushed commits is not done here and the branch already carries the commit that drives the correct bump; and a demand to release a major version because the hook interface gained a required member — 045ab68 (#181) added three such members and this package went to a patch release, so that is not how additions are treated here. The source-compatibility nuance is documented in the description instead, so it stays an explicit decision.

lerna run lint, format:check, build and test are green locally and in CI.

This PR stays in draft on purpose. Merging it publishes a beta immediately, and the endpoint it binds to is not merged yet — see the ordering section above.

…erify

This documentation ships as .d.ts to consumers who have only the installed
package in front of them. Anything it asserts about the service behind the call
is unverifiable there and goes stale without a single test turning red.

Removed accordingly: that the endpoint is throttled and what HTTP 429 means,
that many DFX accounts are decommissioned, that an IBAN the same customer used
earlier can end up unmatched, that another customer's personal IBAN is never
checked, and which steps the check skips when no identity is present.

What replaces them is either verifiable in this repository or phrased as
guidance rather than fact: any non-2xx response is raised as an ApiException by
the client in this package, and a rejected call means the IBAN could not be
checked rather than that it was rejected. The wording rule for NotMatched
stays - it is an instruction to the consumer, not a claim about the service.

The test comment loses its assertion about how the service distinguishes two
statuses and states what the assertion actually pins: unlike list(), this call
does not opt out of the token.
…the test bite

The previous wording said any non-2xx response is raised. That holds for the
core client, but not for the hook: on HTTP 401 the shared api hook retries once
with the current token when the stored token changed while the call was in
flight, and a successful retry resolves normally. Both places now say what is
true in either package - a failure never arrives as a status, and a rejection is
an ApiException - and keep the instruction to read that as "could not check".

The endpoint test asserted the sent IBAN against the same constant it passed in,
so an implementation that always sent one hardcoded IBAN passed. It now runs
over two distinct example IBANs, which fails that implementation on the second
case.

The second test's title claimed what the service can answer. It now states what
the assertion pins: unlike list(), this call does not set token: false.
@TaprootFreak

Copy link
Copy Markdown
Contributor Author

Three review passes were needed before both independent reviews came back clean. What changed along the way:

Documentation that a consumer cannot verify. This package ships its docs as .d.ts to people who only have the installed library in front of them, so anything asserted there about the service behind the call is unverifiable for them and goes stale without a test ever failing. Removed accordingly: that the endpoint is throttled and what HTTP 429 means, that many accounts are decommissioned, that an IBAN the same customer used earlier can end up unmatched, that another customer's personal IBAN is never checked, and which steps the check skips without an identity. What remains is either verifiable in this repository or phrased as guidance — including the wording rule for NotMatched, which is an instruction to the consumer rather than a claim about the service.

A failure statement that was true for only one of the two packages. The replacement wording said any non-2xx response is raised. That holds for the core client, but the React hook retries once on HTTP 401 when the stored token changed while the call was in flight, and a successful retry resolves normally. Both places now say what is true in either package: an HTTP failure never arrives as a status, and a rejection is an ApiException.

A test that did not prove what its name claimed. The endpoint test asserted the sent IBAN against the same constant it passed in, so an implementation that always sent one hardcoded IBAN passed it. It now runs over two distinct example IBANs and fails such an implementation on the second row. Its sibling test also lost a title that described what the service can answer, in favour of what the assertion actually pins.

Two reported points were rejected with reasons rather than applied: retrying anonymously on HTTP 401, which cannot occur here because the endpoint's guard never rejects an unusable token but treats the request as anonymous; and reducing the NotMatched and LoginRequired blocks to their normative sentence, which would leave the enum members without a definition of what they mean.

The PR description was corrected twice as well: it still carried the ordering note from before the API side landed, and it named the wrong version for the precedent it cites.

@TaprootFreak
TaprootFreak marked this pull request as ready for review July 27, 2026 19:37
@TaprootFreak
TaprootFreak merged commit e7a8f89 into develop Jul 27, 2026
1 check passed
@TaprootFreak
TaprootFreak deleted the feat/bank-receive-iban-check branch July 27, 2026 20:01
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.

1 participant