Appearance
9.7.8 — ATM
"Design an ATM. A customer inserts a card, enters a PIN, and can check their balance or withdraw cash."
This one is asked constantly because it hides three different problems inside a friendly prompt: a session that moves through states, a bounded physical resource (the notes in the machine), and money, which is the least forgiving kind of data there is. It is also the cleanest problem in the whole set for showing that you think about what happens when a step fails halfway.
1. The questions to ask first
Is the bank's account system in scope, or am I calling it? Almost always you are calling it. That single answer turns half the problem into an interface, which is exactly where you want it — you are designing the machine, not the ledger.
What can the customer do? Balance, withdraw, deposit, mini statement, PIN change. Take two or three. Withdrawal is the one that must work end to end, because it is the only one where the machine hands over something it cannot take back.
What varies? Note denominations differ by country and by machine. Daily limits differ by account type. Whether a deposit is counted immediately or held for a day is a bank policy. Each of those is an interface.
What are the numbers? One machine, a few thousand notes, one customer at a time. That last part matters enormously and candidates rarely say it out loud: an ATM serves exactly one session at a time, so most of the concurrency in this problem is not inside the machine. It is between this machine and every other machine touching the same account.
State the contract: "I will design the session, withdrawal end to end, and the cash dispensing. The bank's authorisation and ledger are behind an interface. I am deferring deposits and card-issuer routing."
2. Classify it
A lifecycle problem wrapped around a bounded resource, with money. So the skeleton is already known: a state machine for the session, a claim-and-release for the notes, a Money value object everywhere, and hardware behind interfaces.
3. Entities, values and roles
Entities. Session has the lifecycle. Cassette holds notes of one denomination and guards its own count. Card is the physical thing with a number and a retry count.
Values, all immutable. Money in integer minor units. Pin, which should be a type that never appears in a log or a toString. Denomination. NoteBundle, a map of denomination to count.
Roles, the things that vary.
typescript
interface BankService { // (1)
authorise(card: CardNumber, pin: Pin): Promise<AuthResult>;
reserve(account: AccountId, amount: Money, ref: TxnRef): Promise<ReserveResult>; // (2)
settle(ref: TxnRef): Promise<void>; // (3)
release(ref: TxnRef): Promise<void>; // (4)
balanceOf(account: AccountId): Promise<Money>;
}
interface CashDispenser { // (5)
available(): NoteBundle;
dispense(bundle: NoteBundle): Promise<DispenseResult>;
}
interface NoteSelector { // (6)
select(amount: Money, available: NoteBundle): NoteBundle | null;
}
interface WithdrawalPolicy { // (7)
check(account: AccountId, amount: Money, today: DailyTotals): PolicyResult;
}(1) The bank is one interface, which means the whole authorisation and ledger world is somebody else's problem and can be faked in a sentence.
(2), (3) and (4) are the design decision that makes this answer good, and section 6 is entirely about them. A withdrawal is not one call to "take money out". It is a reservation, then a physical dispense, then a settlement — or a release if the dispense failed.
(5) The hardware is an interface for the obvious reason and one less obvious one: it lets you say "the dispenser can jam", which is the failure the whole design has to survive.
(6) Which notes to give out is a real algorithm and a real variation axis. Section 5.
(7) Daily limits, per-transaction limits, and account-type rules are policy, not code. An administrator changes them; therefore they are data behind an interface.
4. The session state machine
The type follows the diagram one variant per state, carrying exactly what that state knows:
typescript
type SessionState =
| { kind: "idle" }
| { kind: "cardIn"; card: CardNumber; attemptsLeft: number } // (1)
| { kind: "authenticated"; card: CardNumber; account: AccountId }
| { kind: "reserved"; account: AccountId; amount: Money; ref: TxnRef } // (2)
| { kind: "dispensing"; ref: TxnRef; bundle: NoteBundle }
| { kind: "cardRetained"; reason: string };(1) The retry counter lives in the state that owns it, so there is no way to read attemptsLeft while idle. This is the union-of-states habit from 9.7.2 doing real work.
(2) From reserved onwards, the state carries a TxnRef. That reference is what makes recovery possible after a power cut, and section 6 explains why.
5. Choosing the notes
The customer asks for £180 and the machine holds twenties, fifties and tens. Which notes come out?
The instinctive answer is greedy: take the largest note that fits, repeat. Three fifties, one twenty, one ten. It is fast, it is one loop, and it is wrong in two different ways, both of which an interviewer may probe.
Greedy can fail even when a solution exists. Suppose the machine has only fifties and twenties, and the customer asks for £60. Greedy takes a fifty, then cannot make £10, and reports failure — while three twenties would have worked perfectly. Greedy is only guaranteed to work for denomination sets where every larger note is a whole multiple of the ones below it â pounds of 1, 2, 5, 10, 20 behave; a machine holding only fifties and twenties does not. Real machines run low on one denomination all the time, which is exactly when that condition stops holding.
Greedy exhausts the large notes first, so by the afternoon the machine is full of tens and can no longer serve a large withdrawal at all. Real machines deliberately mix denominations for this reason.
So the honest answer is a small dynamic-programming search over what is actually in the cassettes:
typescript
function select(amount: Money, available: NoteBundle): NoteBundle | null {
const target = amount.minorUnits; // (1)
const notes = available.denominations(); // e.g. [5000, 2000, 1000]
// best[v] = the cheapest bundle (fewest notes) that makes exactly v, or undefined
const best: Array<NoteBundle | undefined> = new Array(target + 1);
best[0] = NoteBundle.empty(); // (2)
for (let value = 1; value <= target; value++) {
for (const note of notes) {
if (note > value) continue;
const rest = best[value - note];
if (!rest) continue; // (3)
if (rest.countOf(note) >= available.countOf(note)) continue; // (4) respect stock
const candidate = rest.plus(note);
if (!best[value] || candidate.total() < best[value]!.total()) { // (5) fewest notes wins
best[value] = candidate;
}
}
}
return best[target] ?? null; // (6)
}(1) Working in minor units means no floating point anywhere near money.
(2) Zero is makeable with no notes, which seeds the whole table.
(3) If the remainder was not makeable, this path is dead.
(4) The line that makes this an ATM rather than a textbook coin-change exercise: you cannot use a note you do not have. The stock check is what handles "we ran out of twenties".
(5) Fewest notes is the tie-break customers actually want, and it is also the tie-break the machine wants, since notes are the scarce thing.
(6) null means "this amount cannot be made from what is in the machine", which is a normal, expected answer — the screen says try a multiple of £20 — and not an exception.
Say the cost out loud, because it is the point of the section. This is O(amount × denominations), and with amounts in pennies that is 18,000 iterations for £180, which is nothing. If the interviewer objects, the reply is that you can work in units of the smallest denomination rather than pennies, which shrinks the table by a factor of five hundred. Knowing the cost and having a cheaper variant ready is worth more than either algorithm on its own.
And the real-world touch worth a sentence: some machines apply a dispensing preference — keep at least N of each denomination in reserve, or prefer to hand out the note the machine has most of. That is another strategy behind the same NoteSelector interface, which is why the interface exists.
6. The part that makes this answer senior: the gap between debit and dispense
Here is the naive withdrawal, and it is wrong:
typescript
await bank.debit(account, amount);
await dispenser.dispense(bundle); // if this fails, the customer paid for nothingAnd here is the other naive version, which is wrong in the opposite direction:
typescript
await dispenser.dispense(bundle);
await bank.debit(account, amount); // if this fails, the bank paid for nothingThere is no ordering of two steps that is safe, because the machine can lose power between any two lines. You cannot make this atomic, so you make it recoverable, and the way you do that is a three-phase transaction with a reference the machine writes down before it does anything physical.
typescript
async function withdraw(state: Authenticated, amount: Money): Promise<Outcome> {
const policy = await policies.check(state.account, amount, todaysTotals);
if (!policy.ok) return Outcome.refused(policy.reason); // (1)
const bundle = noteSelector.select(amount, dispenser.available());
if (!bundle) return Outcome.refused("amount not dispensable"); // (2) before touching money
const ref = TxnRef.new();
await journal.write({ ref, phase: "reserving", account: state.account, amount }); // (3)
const reserve = await bank.reserve(state.account, amount, ref); // (4)
if (!reserve.ok) return Outcome.refused(reserve.reason);
await journal.write({ ref, phase: "dispensing", bundle }); // (5) BEFORE the shutter
const result = await dispenser.dispense(bundle);
if (!result.ok) {
await journal.write({ ref, phase: "dispense-failed" });
await bank.release(ref); // (6) hold removed
return Outcome.failed("machine fault");
}
await journal.write({ ref, phase: "dispensed" });
await bank.settle(ref); // (7) now it is real
await journal.write({ ref, phase: "settled" });
return Outcome.dispensed(bundle);
}(1) Policy first, because refusing early costs nothing and touches no money.
(2) Then check the machine can even make the amount. Both refusals happen before any state changes anywhere, which means the common failures leave no trace to clean up.
(3) The journal write comes before the bank call. This is the crash-recovery hinge: if power dies after reserve returns but before the machine knows it, the journal still says a reservation was attempted with this reference, so recovery has something to look for.
(4) reserve places a hold rather than a debit. The money is unavailable to other machines immediately — which is how the same account at two ATMs is handled — but it has not moved.
(5) The journal is written before the shutter opens, and this line is the whole design. If the machine dies mid-dispense, the recovery process knows a dispense was in progress for this reference and can look at the physical note count to determine whether the customer got the cash.
(6) A failed dispense releases the hold. Nothing was taken, so nothing is owed.
(7) Settlement is the last step, and it is the moment the money actually leaves the account.
On restart, the machine reads its journal and finishes the story. For each reference not marked settled or released: if the phase is reserving, ask the bank whether the hold exists and release it if so. If the phase is dispensing, this is the genuinely ambiguous case, and the honest answer is that you resolve it by counting — the machine knows how many notes it had, and the cassette sensors say how many it has now. If the notes left, settle. If they did not, release. If the count is ambiguous because the notes are stuck in the shutter, mark the transaction for a human and reconcile it against the cash audit at the next service visit.
Say that last part out loud in an interview. Most candidates stop at "I would use a transaction". The observation that some failures can only be resolved by a physical count and a human, and that the design's job is to make that resolution possible and rare rather than to pretend it never happens, is what a senior answer sounds like.
7. Where the races are
Inside one machine: almost nowhere. One customer, one session, one physical shutter. Saying this explicitly is worth a point, because it shows you scope concurrency to where it exists rather than sprinkling locks everywhere.
Across machines, on the same account: this is the real one. A card and its cloned copy used at two ATMs at once, or a joint account. The guarantee cannot live in the machine, because the two machines cannot see each other. It lives at the bank, as a conditional operation:
sql
UPDATE accounts
SET held = held + :amount
WHERE id = :account AND balance - held >= :amount;Zero rows affected means insufficient available funds, and that is the answer both machines receive consistently no matter how their requests interleave (9.5.1).
The daily limit has the same shape and is easy to get wrong. Reading today's total and then comparing it is a check-then-act; the limit must be enforced by the same conditional write that places the hold, or two simultaneous withdrawals of £300 will both pass a £500 daily check.
The cassette count is a race only if the machine has a background task, such as a status poll that reports available cash to head office. Keep the count owned by the Cassette object with dispense as the only mutation path, and the invariant that a count never goes below zero holds by construction.
8. The twists, pre-walked
"Add deposits." New session states and a new hardware interface, and the important design point is the hold: deposited cash is credited but not available until it is verified, which is a second use of the same reserve mechanism you already built. Plugs in.
"Support multiple currencies." Money already carries a currency, and the cassettes gain one. NoteSelector operates per currency. The new work is the exchange rate, which is another policy interface. Plugs in.
"Prefer to hand out smaller notes in the evening." A new NoteSelector implementation and one registry line. Nothing else changes, which is the payoff for making note selection an interface in the first place.
"The bank is down. Allow offline withdrawals up to £50." This one genuinely restructures, and you should say so. Offline means you cannot reserve, so the machine takes the risk itself: a low limit, a local record of what it dispensed, and settlement when connectivity returns. The design change is that the journal stops being a recovery aid and becomes the source of truth for a period. Naming that shift — and naming the fraud exposure it creates, since a customer can drain the same account at several offline machines — is a much better answer than pretending it plugs in.
9. What the interviewer will push on
This is the section worth rehearsing, because these five follow-ups arrive in almost every run of this problem.
"What if the power fails right as the notes are coming out?" The most common follow-up in the entire problem. Answer with the journal-before-shutter ordering from section 6, then the recovery walk, then the honest admission that the truly ambiguous case is resolved by a physical count and a human. Candidates who say "the transaction rolls back" have not understood that a shutter is not transactional.
"Two ATMs, same card, same second." They are checking whether you put the guarantee in the machine or in the bank. The answer is the conditional update, and the giveaway that you have thought about it is mentioning that the daily limit has to be enforced in the same statement as the hold, not checked separately.
"How do you pick which notes to give out?" They want to hear that greedy is not always correct, with the fifty-and-twenty counterexample, and that stock levels change the answer. Bonus for mentioning that always-largest-first strands the machine with only small notes by the afternoon.
"Where does the PIN live?" A quiet security probe. The right answer is that the PIN is never stored, never logged, never in an exception message, and ideally never in your process at all — real machines encrypt it inside the keypad hardware and send an encrypted block onward. If your Pin type has a toString that returns the digits, you have created the leak yourself.
"How would you test this without a real ATM?" They are not asking for a test plan. They are asking whether your hardware is behind interfaces. The answer is that CashDispenser, CardReader and BankService are ports, so the entire session can be exercised with fakes, and that this is the reason to define them as interfaces rather than a nicety.
The one thing to volunteer that nobody asks for: the audit journal is not an implementation detail. It is a regulatory requirement, it is how disputes are settled when a customer says they never got the money, and it is the reason the design can recover at all. Mentioning it unprompted signals that you have thought about the system rather than the exercise.
Recall
- Classify: a lifecycle (the session) around a bounded resource (the notes), with money. State machine plus claim-and-release plus a
Moneyvalue object. - Hardware behind interfaces —
CardReader,CashDispenser,BankService,NoteSelector,WithdrawalPolicy. That is what makes the machine designable without hardware. - Withdrawal is three phases, not one:
reserveplaces a hold, the dispense is physical,settlemoves the money, andreleaseundoes the hold when the dispense fails. - Write the journal before the shutter opens. No ordering of two steps is safe against power loss, so you make it recoverable rather than atomic. On restart, replay the journal; the genuinely ambiguous case is resolved by a note count and a human.
- Note selection is not greedy. Greedy fails when the notes left are not whole multiples of each other (fifties and twenties, asked for £60) and strands the machine with small notes. Use a small dynamic-programming search bounded by actual stock;
nullmeans "not dispensable", which is a normal answer. - The race is across machines, not inside one. The hold and the daily limit must be enforced by one conditional update at the bank, or two withdrawals both pass the same check.
- The PIN is never stored, logged, or in a
toString.
Self-test: Why can't reserve-then-dispense be made atomic, and what do you do instead? Give the counterexample where greedy note selection fails. Which single statement enforces both the balance and the daily limit, and why must it be one statement? What does the journal entry written before the shutter opens buy you?
Quiz Bank
FoundationalModel the ATM session as a state machine and say what each state is allowed to know.
Six states, each carrying exactly the data that state has.
idle carries nothing. No card, no account, no attempt count — and because the type has no fields, no code can accidentally read a stale account id from a previous customer, which is a real class of bug in machines that use one mutable object for everything.
cardIn carries the card number and the number of PIN attempts left. The counter belongs here and nowhere else. When the card is ejected, the state becomes idle and the counter ceases to exist, which is exactly the behaviour you want.
authenticated carries the card and the resolved account id. This is the state where the menu appears.
reserved carries the account, the amount and a transaction reference. The reference is generated by the machine before it contacts the bank, and from this state onwards every action is tied to it. That is what makes recovery possible.
dispensing carries the reference and the chosen note bundle. Note that it no longer needs the account id: by this point the money question is settled and the remaining question is physical.
cardRetained carries the reason. It is a terminal state for the session and requires a human to clear it.
The transitions are the interesting part. From cardIn, a wrong PIN goes back to cardIn with one fewer attempt; the third failure goes to cardRetained. From authenticated, a withdrawal request goes to reserved only if both the policy check and the bank's hold succeed, so reserved genuinely means the money is held. From reserved you can only go to dispensing or back to authenticated with the hold released.
And the rule that pre-answers a dozen questions: any event not drawn is rejected. A withdraw event received while idle throws rather than doing something surprising, and a pin event received while dispensing is ignored. Half of "but what if the user presses cancel during..." is answered by that one default branch.
AppliedA customer says the machine debited them but no cash came out. Walk through how your design settles the dispute.
This is the scenario the whole design exists for, so the answer should be a walk rather than a guess.
Step one: find the reference. The customer's bank statement shows a transaction; the machine's journal has an entry for the same reference. That correlation is only possible because the machine generated the reference before contacting the bank and wrote it down first.
Step two: read the last phase recorded.
If the journal's last entry for that reference is reserving, the bank was asked for a hold and the machine crashed before knowing the answer. No cash left. The hold is either still sitting there or has expired; either way, the customer's balance was never reduced, and the visible "debit" they are complaining about is a pending hold. Release it and explain.
If the last entry is dispensing, this is the genuinely ambiguous case and the honest answer is that the journal alone cannot settle it. The machine wrote that entry and then either opened the shutter successfully or did not.
If the last entry is dispensed but not settled, the notes left the machine and the bank was never told. The money is owed to the bank, not to the customer, and settlement should be completed.
If the last entry is settled, the machine believes it dispensed and the bank agrees.
Step three: resolve the ambiguous case physically. The cassettes count notes as they leave. The machine's expected count at the start of the day, minus everything it recorded dispensing, should equal what a service engineer counts at the next visit. A discrepancy of exactly the disputed amount tells you the notes are still in the machine — often stuck in the reject bin, which is where a machine puts notes it presented and the customer did not take — and the customer is refunded.
Step four: close the loop. Notes the customer did not take within the retract window are pulled back into a separate bin precisely so this case is unambiguous later.
The point worth stating in the interview: the design cannot prevent the ambiguous case, because a shutter is not transactional and a power cut respects nothing. What it can do is make the case rare, make it detectable, and make it resolvable from evidence the machine wrote down before acting. A candidate who says "the transaction would roll back" is describing a system that does not exist, and one who describes this walk has understood what it means for software to control something physical.
InterviewWhy is greedy note selection wrong, and what would you use instead? Give the cost.
Greedy is wrong in two independent ways, and giving both is what separates a memorised objection from an understood one.
It can fail when a solution exists. Greedy takes the largest note that fits and repeats. If the machine holds only fifties and twenties and the customer asks for £60, greedy takes one fifty and is then stuck with £10 to make from twenties. It reports failure — while three twenties would have worked. Greedy is only guaranteed correct when every larger note is a whole multiple of the smaller ones, and a real machine running low on one denomination no longer satisfies that condition. So this is not a theoretical objection; it is the normal afternoon state of a busy machine.
It strands the machine. Always spending the largest note first empties the high denominations early, so by the afternoon the machine holds only tens and physically cannot serve a £400 withdrawal even though it has the cash. Real machines mix denominations deliberately for this reason.
Instead: a small dynamic-programming search over the amount, bounded by actual stock. Build a table where entry v holds the best bundle making exactly v, seeded with the empty bundle at zero. For each value and each denomination, extend the best bundle for the remainder — but only if the resulting count of that note is still within what the machine actually holds. That stock check is the line that turns a textbook coin-change into an ATM.
The cost is O(amount × denominations) with a table proportional to the amount. In pennies, £180 is 18,000 entries, which is nothing on any hardware. If the interviewer pushes, the improvement is to work in units of the smallest denomination rather than pennies, since no machine dispenses coins — that shrinks the table by a factor of five hundred and changes nothing about correctness.
Two extras worth volunteering. Returning null for an amount that cannot be made is the expected outcome, not an error, and the screen response is "please choose a multiple of £20". And "fewest notes" is only one possible objective — a machine may prefer to preserve its stock of large notes, which is a different tie-break, which is precisely why note selection sits behind an interface rather than being a function buried in the withdrawal flow.
StaffThe bank's authorisation service is unreachable. Design offline mode, and be honest about what it costs.
Say first that this restructures rather than plugs in, because that framing is itself the answer they are looking for. Every design so far has assumed the bank is the source of truth and the machine is a terminal. Offline mode inverts that for a period, and inverting the source of truth is never a plug-in change.
What offline mode actually is. The machine cannot place a hold, so it cannot know the balance and cannot enforce anything the bank knows. It therefore takes the risk itself, under strict limits: a low cap per card, a low cap per session, and a stop after N offline transactions in total. Every dispense is written to the journal as an unsettled transaction, and the journal becomes the authoritative record until connectivity returns. On reconnection, the machine settles the queue in order and the bank applies them, possibly taking accounts negative.
The costs, stated plainly, because pretending there are none is the failure mode here.
Fraud exposure is the big one. A customer can withdraw the offline limit at machine A, then again at machine B, then again at C, because no machine can see the others. The limit is therefore not "£50", it is "£50 times however many offline machines the attacker can reach", and the mitigation is to keep the cap low enough that the loss is smaller than the goodwill of staying available.
Overdraft is now possible, and someone has to decide whether the bank absorbs it or pursues the customer. That is a business decision and you should ask for it rather than assume.
The card itself may be stolen or cancelled and the machine has no way to know. Offline mode should at minimum check the card's local blacklist, refreshed whenever connectivity exists.
Settlement can fail after the fact, so the machine needs a state for "dispensed, settlement rejected", which is a human-handled queue rather than an automatic path.
The design changes needed. BankService gains an offline implementation that approves within limits and enqueues; the journal gains a durable outbound queue with retry; the session gains a visible "limited service" mode so the customer knows why the amount is capped; and reconnection triggers a settlement sweep that is idempotent, keyed on the transaction reference that the machine already generates for exactly this reason.
And the decision that is not technical at all. Whether to have offline mode is a trade between availability and loss, and different institutions answer it differently — a machine in a remote area with unreliable connectivity has a much stronger case than one in a bank branch. The right thing to say in an interview is that you would ask the business for the acceptable loss figure and set the caps from it, rather than choosing £50 because it sounds reasonable. That reframes a coding question as a risk question, which is what it actually is.
Flashcards
FlashATM withdrawal phases
Reserve (hold at the bank) → dispense (physical) → settle. Failure at the dispense releases the hold. Never a single debit call.
FlashJournal before shutter
Write the transaction reference and phase before opening the shutter. No two-step ordering survives a power cut, so make it recoverable, not atomic. The ambiguous case is settled by a note count.
FlashWhy not greedy notes
Fails when the remaining notes are not whole multiples of each other — only fifties and twenties, asked for £60. Also strands the machine with small notes. Use DP bounded by actual stock; null is a normal answer.
FlashSame account, two ATMs
One conditional update at the bank places the hold, and the daily limit is checked in that same statement. Checking the limit separately is a check-then-act that both machines pass.
FlashATM interview probes
Power cut mid-dispense · two machines one card · note selection · where the PIN lives · how you exercise it without hardware (the answer is: the hardware is behind interfaces).
Scenario Drill
DrillAn operations team reports that one machine's cash count is short by £340 at the end of every busy day, but the journal shows every transaction settled cleanly. Work out what is happening and what you would change.
The journal says every transaction completed, and the physical count disagrees. So either the journal is wrong about something it recorded, or notes are leaving the machine through a path the journal does not know about. Work both branches.
Branch one: notes leaving without a transaction. The obvious candidates are a service engineer's test dispenses, a cassette loaded with the wrong count at the start of the day, or notes stuck together so the machine dispensed two where it counted one. The double-note case is the interesting one and it is common in real machines: the counting sensor sees one note pass, the customer receives two, and the journal is honest but wrong. The tell is that the shortfall is always a multiple of a single denomination — £340 is not, unless the machine holds twenties and tens, so already the number is telling you something.
Branch two: the journal recorded a settle that should have been a release. This is the more worrying possibility because it means the design has a hole. Specifically: if the code writes settled before confirming that bank.settle() actually returned, or if it writes dispensed optimistically before the dispenser confirms, then a partial dispense records as a full one. Check the ordering in the code against the ordering in the journal, because a journal that is written after the fact rather than before is a journal that can lie.
How to tell the branches apart with data you already have. Reconcile per transaction rather than per day. Take the notes dispensed according to each journal entry, sum by denomination, and compare with the cassette sensors' own count per denomination. If one denomination accounts for the whole discrepancy, it is a physical feed problem. If the discrepancy spreads across denominations proportionally, it is a counting or ordering problem in software. This is the step teams skip, and it converts an unfalsifiable "we are short" into a specific claim.
A third possibility worth checking, because it is genuinely common. Notes presented to the customer and not taken should be retracted into a separate reject bin, and the retract should be journalled as a reversal — the customer's account refunded. If the machine retracts notes but the software records the transaction as dispensed, then the machine is long on notes in the reject bin and the customer has been wrongly charged. Note that this shows up as a shortfall in the main cassettes and a surplus in the reject bin, so counting only the cassettes makes it look like loss. The fix is to include the reject bin in the reconciliation and to make retraction a first-class transition with a compensation, rather than a hardware event that the software ignores.
What I would change regardless of which branch it turns out to be.
Reconcile per transaction, automatically, every day, with the difference broken down by denomination and by bin. A daily total is not a diagnostic; a per-denomination breakdown is.
Journal the dispenser's own reported count, not just the bundle the software asked for. The two should be equal, and an alert when they are not turns a slow monthly loss into a same-day signal.
Make retraction an explicit state with an automatic reversal to the customer's account, so a customer who walked away never pays for cash they did not take.
Alert on the trend rather than the threshold. A machine that is short every day is a different problem from one that is short once, and the shape of the number over time will point at a fixed cause much faster than the size of any single day's gap.
The general lesson to close on: the journal was designed to reconstruct what the software intended. A machine that touches the physical world also needs to record what the hardware reported, and the reconciliation between intent and report is where this class of loss becomes visible. Designing that comparison in from the start costs almost nothing; adding it after three months of unexplained shortfall costs a forensic investigation.