STPM ICT 958 study materials
STPM ICT 958 study resources

Three Sen Missing from a Canteen Wallet

STPM ICT 958 · Put your knowledge to work in context.

All case studies

Three Sen Missing from a Canteen Wallet

A school canteen uses a Python program to manage prepaid student wallets. Balances are stored as floats and printed to two decimal places. The cashier reports three problems. A student buying items priced RM0.70, RM0.20 and RM0.10 in that order reached a balance of 2.9999999999999996, which printed as RM3.00, yet the next purchase of exactly RM3.00 was refused with 'insufficient balance'. A fine of RM0.05 per day for 30 overdue days gave 1.4999999999999998 and printed RM1.49 on the receipt. A top-up menu written as while True: with no exit branch trapped a trainee who typed q. The purchase code is:

if balance >= price:
    balance = balance - price

The auditor has asked for exact ringgit-and-sen accounting. The canteen processes about 400 transactions a day and reconciles the wallet total against cash collected each evening.

1. Explain the cause of the balance and fine errors, and explain why rounding only the printed value does not fix them. Propose a correct numeric representation with corrected purchase logic.

Read the worked answer

Cause. Binary floating point cannot represent 0.10, 0.20, 0.70 or 0.05 exactly; each is stored as the nearest double, slightly above or below. Adding and subtracting accumulates a tiny error, so RM3.00 becomes 2.9999999999999996. The comparison balance >= price uses that raw value, so a purchase of exactly RM3.00 fails even though the display says RM3.00. The fine case fails the other way: 30 x 0.05 lands just below 1.50, and formatting with :.2f prints 1.49 because the underlying value is 1.4999999999999998.

Why display rounding is not a fix. Formatting changes the output only. The stored value and the comparison still carry the error, so the decision ('may this student buy?') stays wrong, and the error grows with every transaction over a term.

Fix - integers in sen:

balance_sen = 300          # RM3.00
price_sen   = 300
if balance_sen >= price_sen:
    balance_sen -= price_sen
print(f'RM{balance_sen // 100}.{balance_sen % 100:02d}')

Alternative - decimal.Decimal: keep amounts as Decimal('3.00'), quantise to two places with ROUND_HALF_UP at each posting, and never mix in a float literal. Integers are preferable here because sen is inherently a whole number and cannot drift. Whichever is used, the rule is: convert at the boundary (input and display) and keep one representation inside the program.

2. Rewrite the top-up loop so that it handles invalid input, rejects non-positive and over-precise amounts, and exits cleanly.

Read the worked answer
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP

while True:
    raw = input('Top-up amount in RM (q to quit): ').strip()
    if raw.lower() == 'q':
        break
    try:
        amount = Decimal(raw)
    except InvalidOperation:
        print('Not a number. Example: 5.00')
        continue
    if amount != amount.quantize(Decimal('0.01')):
        print('Use at most two decimal places.')
        continue
    amount_sen = int(amount * 100)
    if amount_sen <= 0:
        print('Amount must be more than RM0.00.')
        continue
    if amount_sen > 5000:
        print('Maximum top-up is RM50.00.')
        continue
    balance_sen += amount_sen
    print(f'New balance RM{balance_sen // 100}.{balance_sen % 100:02d}')

Reasoning behind each guard: the sentinel q is checked before parsing so it is not rejected as bad input; parsing is wrapped because Decimal('abc') raises InvalidOperation; the precision check rejects 3.005 rather than silently rounding money the cashier did not intend; <= 0 blocks zero and negative amounts that would otherwise drain a wallet; an upper bound limits a mistyped '5000' being taken as RM5,000. The continue statements keep the loop alive after a mistake, and break is the only exit, so the loop terminates only on a deliberate quit.

3. Design a test set for the purchase logic, including boundary cases, and state the expected result of each.

Read the worked answer

Use integer sen throughout so expected values are exact.

# Balance Price Expected Reason
1 300 300 Allowed, balance 0 Equality boundary - the original bug
2 300 301 Refused, balance unchanged One sen short
3 300 299 Allowed, balance 1 Just inside the limit
4 300 0 Rejected at input validation Free item must go through a separate path, not the wallet
5 300 -50 Rejected at input validation Negative price would credit the student
6 300 100 x3 in sequence Allowed each time, balance 0 Reproduces 0.70 + 0.20 + 0.10 accumulating to exactly 100 sen
7 0 10 Refused Empty wallet
8 100 100 Allowed, balance 0 Second boundary at a low value

Also test the sequence and the fine calculation separately: 30 days at 5 sen must be exactly 150 sen, and the printed string must be RM1.50. Add one reconciliation check per day: the sum of all wallet balances plus cash collected must equal opening float plus all top-ups to the sen. Any mismatch fails the build.

Why these and not just typical values: boundaries are where comparisons fail, and the equality case is exactly the one the original float code got wrong. A test suite that only uses 50 sen prices would never have exposed the defect.