Skip to content

Practice Lab 15 — Payment processing abstraction

Difficulty: Advanced · Estimated time: 5–6 hours · Module 8: Abstract Classes and Interfaces

The first lab where you are given requirements and no classes. The interface you invent decides how hard the next payment method will be, and the checklist will ask you to prove that with a count.


At a glance

Difficulty Advanced
Estimated time 5–6 hours
Module Module 8 — Abstract Classes and Interfaces
Concepts practised Abstract classes, pure virtual functions, interface design, contracts, dependency on abstractions, capability interfaces, compile-time enforcement
Prerequisite Practice Lab 14
Class design yours — requirements, constraints and expected behaviour only

Scenario

A ticket shop takes payments four ways: card, bank transfer, a store credit balance, and — new this year — a gift voucher. Each method has a different authorisation step, a different fee, and a different set of refusals: a card can be declined, a transfer can be pending, credit can be insufficient, and a voucher can be expired or already spent.

Refunds exist for all four, but a voucher refund re-issues a new voucher rather than returning money — and that is the requirement that will test whether your interface was designed for payments or for cards.

The shop's developer adds a payment method every few months. The measure of your design is how much of the checkout code has to change the next time that happens.


Problem statement

Design and implement a payment system for the ticket shop. You are given the requirements and the behaviour expected of each method; the classes, their names, their operations and their split are yours.

Requirements:

  1. Four payment methods, each with its own authorisation behaviour and its own failure reasons.
  2. A checkout that takes some amount and some method, and does not know which method it has.
  3. Every method reports: whether it was authorised, a reference for the transaction, and the fee it charged.
  4. A refund path for every method, with the voucher case behaving as described.
  5. A fee summary across a day's payments, computed per method and in total.
  6. A method that cannot refund must not be forced to pretend it can.
  7. Adding a fifth method must be a new type plus one line of registration — you must demonstrate this with a count of files touched, not assert it.
  8. Nothing in the checkout may test which method it is holding: no switch, no typeid, no dynamic_cast.

Deliverable in payment-design.md: the interface (its operations and their contracts), the classes that implement it, the design decision about refunds, and the registration decision.


Learning objectives

By the end of this lab you will be able to:

  • Design an interface from requirements, deciding what every implementer must be able to do.
  • Split a wide interface into capabilities when one implementer cannot honour part of it.
  • State the contract of each operation: what it promises, what it refuses, and what it leaves unchanged.
  • Keep the checkout dependent on your abstraction and not on any implementation, and prove it.
  • Measure the cost of adding a method, and use the number to judge your own design.

Functional requirements

  1. Amounts are whole pence, positive for a payment, and the checkout refuses a non-positive amount.
  2. Every method's authorisation returns a result that carries: authorised or not, the reason if refused, a reference, and the fee in pence.
  3. The four methods' fee rules differ and are documented: for example card 2% of the amount, transfer a flat fee, credit free, voucher free.
  4. Refusals are distinguishable by reason, and the checkout prints the reason without inspecting the type.
  5. Refunds return the same amount less a documented policy, and a voucher refund produces a new voucher value that the program prints.
  6. The day's summary prints a line per method with its count and total fees, and a total.
  7. The checkout's operation takes only an amount, and holds its method as an abstraction.
  8. A method that cannot refund is excluded from the refund operation by the type system, not by a refusal at run time.
  9. Every operation leaves the system in a consistent state when it refuses: nothing is recorded as authorised, and no voucher is consumed.
  10. Every interface has a virtual destructor, and destroying methods through the abstraction is clean.

Constraints

  • Your own file layout, with a documented build command.
  • No switch, typeid or dynamic_cast on payment methods — searched for and recorded.
  • No templates, no smart pointers in the interface (Module 11 will revisit ownership of methods).
  • Money in integer pence.
  • Every refusable operation must have a documented refusal contract in a comment at its declaration.
  • No warnings.

Starter task

In payment-design.md, before writing any code:

  1. List the operations every method must support. For each, write its contract: what it promises, what it refuses, what it changes.
  2. Identify the operation that a voucher cannot honour as stated, and decide: split the interface, refuse at run time, or something else. Argue two options and choose.
  3. Draw the dependency: which type the checkout's constructor or operation parameter has, and which types include which headers.
  4. Predict the file count for adding a fifth method in your design, and write it down. The extension task will check it.
  5. Write the fifth method you will add later, so that you cannot design only for the four you know.

Implementation tasks

  1. Write the interface and its capabilities, with contracts in comments.
  2. Implement the four methods, each with its refusal reasons.
  3. Implement the checkout, the fee summary and the refund path.
  4. Write main: pay with each method, refuse each method at least once, refund each method, print the summary.
  5. Add the fifth method from your starter task, count the files you touched, and record the number against your prediction.
  6. Destroy the methods through the abstraction and record the narration or absence of it.

Test cases

# Action Expected result
1 Pay 5000 with a card Authorised, reference printed, fee 100
2 Pay with a card that is declined Refused, with the refusal reason; no fee charged
3 Pay 5000 by transfer Authorised, pending state visible if you modelled one, flat fee
4 Pay with insufficient credit Refused with that reason, distinguishable from a decline
5 Pay with an expired voucher Refused with that reason
6 Refund the ticket paid by voucher A new voucher's value is printed; no money returned
7 Refund the card payment Money path, with the documented policy
8 Fee summary over five payments Per-method lines that sum to the printed total
9 Checkout with a non-positive amount Refused before any method is consulted
10 Add the fifth method One new type plus registration; the file count is recorded
11 Destroy methods through the abstraction No warning, no leak, narration where you added it
12 Search the code for switch/typeid/dynamic_cast Nothing found

Boundary cases to run deliberately: an amount of 1 penny; a fee that would round to zero; a refund of an amount larger than the original; a voucher whose remaining value is part of the amount.


Expected behaviour

checkout 5000 by card      -> authorised ref C-0001 fee 100
checkout 5000 by card      -> refused: issuer declined
checkout 5000 by transfer  -> authorised ref T-0001 fee 250
checkout 5000 by credit    -> refused: insufficient credit (balance 3200)
checkout 5000 by voucher   -> refused: voucher V-9 expired on 20260331
refund C-0001 5000         -> refunded 4900, policy: 2% retained
refund V-8 5000 (voucher)  -> replaced with voucher V-10 worth 5000
fees today: card 100 | transfer 250 | credit 0 | voucher 0 | total 350
adding a fifth method touched 2 files

Extension challenge

Add a partial payment: paying part of an amount with one method and the remainder with another.

Record in payment-design.md:

  • Whether your interface supports it unchanged, and what had to be added.
  • Where the rule lives that decides whether a combination is allowed (a voucher followed by a card, for example).
  • Which of your operations' contracts had to be strengthened or weakened, and the effect on the existing implementations — as a file count.
  • The new failure reasons, and how the checkout distinguishes them without inspecting types.

Debugging challenge

The checkout silently charges a fee for a declined card:

struct PaymentResult {
    bool authorised;
    int fee_pence;
    std::string reference;
    // the refusal reason is a member of the card class, not of the result
};

PaymentResult Checkout::pay(int amount, PaymentMethod& method) {
    PaymentResult result = method.authorise(amount);
    fees_today_ += result.fee_pence;      // always
    if (!result.authorised) {
        return result;
    }
    register_payment(result);
    return result;
}

Reproduce it — decline a card, print the fee summary — and answer:

  1. What is wrong with the result type, and which requirement does the missing member belong to?
  2. Why does the checkout need the refusal reason when it must not know the method's type?
  3. Rewrite pay so that a refusal cannot be recorded as a fee, and say which line you removed and why the compiler would have caught the mistake if the type were right.
  4. Which test case in the table catches this?

Reflection questions

  1. Name the operation your interface could not honestly require. Where did it go, and what does the checkout do about it?
  2. Which of your operations has a contract that is easy to violate accidentally, and how did you make that harder?
  3. The checkout holds an abstraction. Where does it get the method from, and could you replace that method with a double in a test? What stops you today?
  4. Your fifth method touched how many files? If the answer is more than three, what would have to change?
  5. A voucher refund re-issues a voucher. Is that a refund, or a different operation? Answer in two sentences, with the consequences for the interface.
  6. Which operation would you add next for a real shop, and which existing implementations would have to change?

Completion checklist

  • [ ] It builds from a documented command with -Wall -Wextra -Werror and no warnings
  • [ ] payment-design.md records each operation's contract and the refunds decision
  • [ ] No switch, typeid or dynamic_cast on methods — searched for and recorded
  • [ ] Four methods implemented, each with distinguishable refusal reasons
  • [ ] The checkout takes only an amount and a method abstraction
  • [ ] A method that cannot refund is excluded by the type system, not by a run-time refusal
  • [ ] Every interface has a virtual destructor
  • [ ] The fifth method's file count is recorded against the prediction
  • [ ] All twelve test cases run, with output recorded
  • [ ] Boundary cases run, including a 1-penny amount and a refund larger than the original
  • [ ] The debugging challenge was explained before being fixed

Related: Lab index · Module 8 — Abstract Classes and Interfaces · Previous: Practice Lab 14 · Next: Practice Lab 16 — Media player interface with contract tests