6
\$\begingroup\$

I am writing a simple application that prints out the details of a receipt containing items purchased by a customer. The receipt should list the number of items, the name, the final price (including taxes) of each of the item purchased, plus two extra lines showing the total amount of the receipt and the total amount of taxes.

Taxes are calculated with a rate of 17.5%, rounding the result to the upper 0.05. Please note that medical products are exempt and an additional 1.25 fixed amount is added as an extra tax on CDs.

Examples:

Input:

  • One book at 29.49
  • One music CD at 15.99
  • List one chocolate snack at 0.75

Output:

  • 1 book: 34.69
  • 1 music CD: 20.04
  • 1 chocolate snack: 0.90
  • Sales Taxes: 9.40
  • Total: 55.63

Input:

  • One bottle of wine at 20.99
  • One box of tooth ache pills at 4.15
  • One box of pins at 11.25
  • One music CD at 14.99

Output:

  • 1 bottle of wine: 24.69
  • 1 box of headache pills: 4.15
  • 1 box of pins: 13.25
  • 1 music CD: 18.89
  • Sales Taxes: 9.60
  • Total: 60.98

Basket class:

public class Basket {

    private final ProductService productService;
    private final TaxService taxService;
    private final Map<String, LineItem> items;

    public Basket(ProductService productService, TaxService taxService) {
        this.items = new HashMap<>();
        this.productService = productService;
        this.taxService = taxService;
    }

    public void addItems(String... names) throws ProductNotFoundException {

        for(String name : names) {
            if (items.containsKey(name.toLowerCase())) {
                updateExistingLineItem(name);
            } else {
                addNewLineItem(name);
            }
        }
    }

    private void updateExistingLineItem(String name) throws ProductNotFoundException {
        Cost cost = calculateCost(name);
        items.get(name.toLowerCase()).update(cost);
    }

    private void addNewLineItem(String name) throws ProductNotFoundException {
        Cost cost = calculateCost(name);
        LineItem lineItem = new LineItem(name, cost);
        items.put(name.toLowerCase(), lineItem);
    }

    private Cost calculateCost(String name) throws ProductNotFoundException {
        BigDecimal gross = productService.price(name);
        BigDecimal tax = taxService.calculateTax(name);
        return new Cost(gross, tax);
    }

    public BigDecimal getTotalPrice() {
        return items.values()
                .stream().map(LineItem::getNetPrice)
                .reduce(ZERO, BigDecimal::add);
    }

    public BigDecimal getSalesTax() {
        return items.values()
                .stream().map(LineItem::getTax)
                .reduce(ZERO, BigDecimal::add);
    }

    @Override
    public String toString() {
        String formattedItems = items.values().toString()
                .replace(",", "")
                .replace("[", "")
                .replace("]", "")
                .trim();

        return formattedItems + "\nSales Taxes: " + getSalesTax() + "\nTotal: " + getTotalPrice() + "\n";
    }
}

Cost class:

public class Cost {

    private final BigDecimal gross;
    private final BigDecimal tax;

    public Cost(BigDecimal gross, BigDecimal tax) {
        this.gross = gross;
        this.tax = tax;
    }

    public Cost add(Cost cost) {
        return new Cost(this.gross.add(cost.gross), this.tax.add(cost.tax));
    }

    public BigDecimal getGross() {
        return gross;
    }

    public BigDecimal getTax() {
        return tax;
    }
}

LineItem class:

public class LineItem {

    private final String name;
    private Integer quantity = 0;
    private Cost cost;

    public LineItem(String name, Integer quantity, Cost cost) {
        this.name = name;
        this.quantity = quantity;
        this.cost = cost;
    }

    public LineItem(String name, Cost cost) {
        this(name, 1, cost);
    }

    public void update(Cost cost) {
        this.cost = this.cost.add(cost);
        this.quantity++;
    }

    public BigDecimal getTax() {
        return this.cost.getTax();
    }

    public BigDecimal getNetPrice() {
        return cost.getGross().add(getTax());
    }

    @Override
    public String toString() {
        StringJoiner joiner = new StringJoiner(" ");
        joiner.add(quantity.toString());
        joiner.add(name);
        joiner.add(":");
        joiner.add(getNetPrice().toString());

        return "\n" + joiner.toString();
    }
}

Product class:

public class Product {

    private String name;
    private BigDecimal price;
    private List<TaxBand> applicableTaxes;

    public Product(String name, String price, TaxBand... applicableTaxes) {
        this.name = name;
        this.price = new BigDecimal(price);
        this.applicableTaxes = asList(applicableTaxes);
    }

    public String getName() {
        return name;
    }

    public BigDecimal getPrice() {
        return price;
    }

    public List<TaxBand> getApplicableTaxes() {
        return applicableTaxes;
    }
}

ProductService class:

public class ProductService {

    private ProductRepository productRepository;

    public ProductService(ProductRepository productRepository, Product... items) {
        this.productRepository = productRepository;
        Arrays.stream(items).forEach(item -> this.productRepository.add(item));
    }

    public Product getProductByName(String name) throws ProductNotFoundException {
        return productRepository.findByProductName(name);
    }

    public BigDecimal price(String name) throws ProductNotFoundException {
        return productRepository.price(name);
    }
}

MapProductRepository class:

public class MapProductRepository implements ProductRepository {

    private final Map<String, Product> products = new HashMap<>();

    @Override
    public Product findByProductName(String name) throws ProductNotFoundException {
        Product product = products.get(name.toLowerCase());

        if(product == null) {
            throw new ProductNotFoundException(name + " not supported");
        }
        return product;
    }

    @Override
    public BigDecimal price(String name) throws ProductNotFoundException{
        return findByProductName(name.toLowerCase()).getPrice();
    }

    @Override
    public void add(Product product) {
        products.put(product.getName().toLowerCase(), product);
    }
}

ProductRepository interface:

public interface ProductRepository {

    Product findByProductName(String name) throws ProductNotFoundException;

    BigDecimal price(String name) throws ProductNotFoundException;

    void add(Product product);
}

public class CompactDiskTax implements Tax {

    public static final BigDecimal TAX = new BigDecimal("1.25");

    @Override
    public BigDecimal calculate(Product product) {

        if(!product.getApplicableTaxes().contains(CD)){
            return ZERO;
        }
        return TAX;
    }
}

MiscTax class:

public class MiscTax implements Tax {

    public static final BigDecimal ROUND_TO = new BigDecimal("0.05");
    public static final BigDecimal TAX_RATE = new BigDecimal("17.5");

    public BigDecimal calculate(Product product) {

        if(!product.getApplicableTaxes().contains(TaxBand.MISC)){
            return ZERO;
        }
        return percentage(product.getPrice(), TAX_RATE);
    }

    private BigDecimal percentage(BigDecimal base, BigDecimal percentage){
        return round(base.multiply(percentage).divide(new BigDecimal(100)));
    }

    private BigDecimal round(BigDecimal value) {
        BigDecimal divided = value.divide(ROUND_TO, 0, UP);
        return divided.multiply(ROUND_TO);
    }
}

Tax interface:

public interface Tax {

    BigDecimal calculate(Product product);
}

public enum TaxBand {
    MEDICAL, CD, MISC
}

public class TaxService {

    private ProductService productService;
    private List<Tax> taxes;

    public TaxService(ProductService productService, Tax... taxes) {
        this.productService = productService;
        this.taxes = Arrays.asList(taxes);
    }

    public BigDecimal calculateTax(String productName) throws ProductNotFoundException {
        Product product = productService.getProductByName(productName);

        return taxes.stream()
                .map(tax -> tax.calculate(product))
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}
\$\endgroup\$

1 Answer 1

3
\$\begingroup\$
  • Thank you for not using floating point numbers for money.

  • I don't think items in Basket needs to be a Map. The line items on a receipt/in an order are an ordered list and so could simply be... a List.

  • A lot of the code is Stringly Typed, using the names of the Products as stand-ins for the products themselves; if you need items to be Product instances, make them instances.

    • Once you have thing set up so that you guarantee you're getting Products and not having to pass around potentially bad Strings, you don't need to worry about throwing those ProductNotFound exceptions.
  • update in LineItem looks a little odd to me. First, I appears that it is meant to update the number of items. In that case, a name like updateCount or setCount would more specifically state its purpose.

    Additionally, using a Cost doesn't seem correct and should be probably be replaced with an int.

  • Your basket doesn't let you remove items.

  • I think addItems should just be addItem and should take a Product and an int for how many to add. Having a method go "Add 5 Thing(s)" makes more sense to me than "Add a Thing and a Thing and a Thing and a Thing and a Things".

  • I would move the tax calculation into LineItem and make the tax service only for retrieving the tax information. This makes it more self contained and fully responsible for calculating itself.

    With that change, the Cost class becomes unnecessary. LineItem would then wrap a Product and the calculated tax amount, based on the product and quantity. This removes the indirection and just lets you use the numbers directly (or as directly as the Big* classes allow).

  • I would keep the taxes in their decimal form (e.g. .175 for 17.5%) for calculations and then transform them for display, rather than the other way around.

  • Product should add a constructor override that allows inserting a List<TaxBand> directly.

  • With the above changes, all the calculating and so on of the ProductRepository classes are unnecessary.

  • Your repository interface is also very functionality-light. If it is meant to represent an arbitrary data store, check out CrudRepository from Spring for a good, simple example.

  • CompactDiskTax should not be included in one of the repository classes.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.