The goal is to achieve idempotency on the endpoint so that if two concurrent requests arrive at the service, the amount will be transferred only once.
This may be a little bit tangled, as the process-at-most-once property that you want isn't quite the same thing as idempotency. So let's digress for a moment.
Here's a java example of idempotency:
HashMap<String, String> example = new HashMap<>();
example.put("A", "B");
example.put("A", "B");
assert example.size() == 1;
Notice that invoking HashMap::put
twice (with the same arguments) produces the same effect as invoking the method once. That's the idempotent bit; processing the command a second time is redundant, and that's a natural consequence of the semantics of the method.
Assignment, and set operations like add/remove have idempotent semantics. Increment/decrement do not.
In the general case, things aren't idempotent.
A thing you can sometimes do is treat the collection of incoming messages themselves as a set, and you "upsert" each new message as it arrives, thus ensuring that there are zero or one copies of each message in the collection.
An alternative is to take a compare-and-swap approach, where you describe in the message some predicate that will be false if the message has already been processed. Including a sequence number/target version is one common approach - if the version doesn't match what you find at processing time, then you no-op. (If you are already familiar with conditional requests, than you will recognize this as essentially the same idea).
And this is all well and good in the imaginary world where you only have to worry about handling a single message at a time. But in the real world, we have to worry about concurrent requests, and the possibility that there are zombie processes still trying to do work, and goodness knows what else.
Therefore, if you are intending that your data model satisfies some constraint, like "at most one copy of each message in the list", then you are going to need some form of locking somewhere. Where that is, and what form the lock should take, tends to vary with context; I'll note in passing that the versions where the domain processing can happen asynchronously are much simpler than the synchronous/request-response cases.
If your processing includes side effects in addition to local bookkeeping, then you are going to want to be very cautious about trying to incorporate multiple concerns into the same handler.
Is it enough to use the transferId (which is generated by the client) as an idempotency key?
Maybe. Review de Graauw 2010. Part of the challenge is whether you need to worry about cases like the "same" logical messages being manifest as collections of data with different ids (example: I tried to send an HTTP request, the system seemed unresponsive, so I tried again from a different browser/machine, so there's a second copy of the message using a different transferId. How important to the business is it to get that right the first time we process the messages?)
Is it redundant or necessary to perform a lock on the accountId to ensure idempotency?
If you have two processes trying to concurrently write to a (logical) data structure, you are going to need some mechanism in place to ensure that the data doesn't get corrupted / that writes don't get lost / and so on.
That might mean acquiring a lock, or it might mean leveraging compare and swap commands. It absolutely requires recognizing the contention, and paying attention to the failure modes.