Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info

# Virtual environments
.venv

.vscode/
.coverage

# macOS files
.DS_Store
20 changes: 20 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
PHONY: test lint format typecheck qa

test:
@echo "Running tests..."
python -m pytest tests

lint:
@echo "Running linters..."
ruff check --fix .

format:
@echo "Running code formatter..."
ruff format .

typecheck:
@echo "Running type checks..."
ty check

qa: lint format typecheck
@echo "All quality checks passed!"
60 changes: 59 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,62 @@ Here are a couple of thoughts about the domain that could influence your respons

* What might happen if the client needs to change the random divisor?
* What might happen if the client needs to add another special case (like the random twist)?
* What might happen if sales closes a new client in France?
* What might happen if sales closes a new client in France?

---

# Implementation


## Install

### Prerequirements

* Python 3.11
* [UV package manager](https://docs.astral.sh/uv/getting-started/installation/)

### Setup

```bash

uv venv -p 3.11

source .venv/bin/activate

uv sync

```

### Run

```bash

python cr.py --input data.csv

# In order to see the result
cat result.out

# Or define the output filename as well
python cr.py --input data.csv --output data.out

cat data.out

```


### Test

```bash

python -m pytest ./tests

```

### Typechecker/Lint/Format


```bash

make qa

```
41 changes: 41 additions & 0 deletions cashregister/command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import argparse
from pathlib import Path
from typing import Tuple

parser = argparse.ArgumentParser()
parser.add_argument(
"--input",
type=str,
required=True,
help="input file with transactions (owed, paid), ex.: 2.12,3.00",
)
parser.add_argument(
"--output",
type=str,
default="result.out",
help="output file which will store the change descrimination, ex.: 3 quarters,1 dime,3 pennies",
)


def parse_cli() -> Tuple[str, str]:
"""Get input and output file from command-line, and verify if the files exist.

Raises:
ValueError: input file does not exist
ValueError: output file already exists

Returns:
Tuple[str, str]: input and output filename
"""
cli_args = parser.parse_args()
input = Path(cli_args.input)
output = Path(cli_args.output)

if not input.exists():
raise ValueError(f"Input file does not exist: {input}")

# TODO: review
# if output.exists():
# raise ValueError(f"Output file already exist: {output}")

return (input.name, output.name)
30 changes: 30 additions & 0 deletions cashregister/denomination/change.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from abc import abstractmethod
from typing import Generic, TypeVar


TDenomination = TypeVar("TDenomination")


class Change(Generic[TDenomination]):
def __init__(self, value: float, denomination: TDenomination):
"""Abstract class to define generic denomination and convert it to string

Args:
value (float): change value
denomination (TDenomination): generic denomination

Raises:
NotImplementedError: require __str__ implementation to convert generic denomination to string
"""
self.value = value
self.denomination = denomination

@abstractmethod
def recalculate_value(self) -> float:
"""Recalculate the value using current denomination"""
pass

@abstractmethod
def __str__(self):
"""Convert denomination to string"""
raise NotImplementedError()
33 changes: 33 additions & 0 deletions cashregister/denomination/converter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from abc import abstractmethod
from typing import Tuple


class IValueConverter:
@abstractmethod
def get_identifier(self) -> str:
"""Get the identifier which is related to the conversion

Raises:
NotImplementedError: require the implementation

Returns:
str: the identifier of conversion
"""
raise NotImplementedError

@abstractmethod
def convert(
self, value: float
) -> Tuple[int, float]: # denomination value and remaining
"""Convert change value to a denomination

Args:
value (float): change value

Raises:
NotImplementedError: require the implementation

Returns:
Tuple[int, float]: denomination value and remaining
"""
raise NotImplementedError()
68 changes: 68 additions & 0 deletions cashregister/denomination/denominator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from abc import abstractmethod
from typing import Generic, TypeVar

from cashregister.denomination.change import Change
from cashregister.denomination.converter import IValueConverter
from cashregister.handler.transaction import Transaction

TOutputChange = TypeVar("TOutputChange", bound=Change)


class Denominator(Generic[TOutputChange]):
@abstractmethod
def get_converters(self) -> list[IValueConverter]:
"""Get all converters available to apply the transformation/conversion.

Returns:
list[IValueConverter]: list of converters, the execution will based on this order.

Raises:
NotImplementedError: require the implementation
"""
raise NotImplementedError

@abstractmethod
def convert_to_change(self, value: float, result: dict[str, int]) -> TOutputChange:
"""Convert the transformation result to a typed change
Args:
value[float]: change value.
result[dict[str,int]]: it is dictionary with convert identifer and denomination value
Returns:
TOutputChange: typed change denomination.

Raises:
NotImplementedError: require the implementation.
"""
raise NotImplementedError

def process(self, tx: Transaction) -> TOutputChange:
"""Process the change denomination through executing the converters in sequence.
Args:
tx[Transaction]: object with owed value and paid value.
Return:
TOutputChange: type change denomination.
"""
assert tx.paid > tx.owed, (
f"Paid: {tx.paid} must be greater than owed: {tx.owed}"
)

orig_change = change = round(tx.paid - tx.owed, 2)

denominator_value = {}
for converter in self.get_converters():
(value, change) = converter.convert(round(change, 2))

denominator = converter.get_identifier()
if denominator in denominator_value:
raise ValueError(
f"Converters contains duplicated denominator, got: {denominator}::{converter.__class__}"
)

if value > 0:
denominator_value[denominator] = value

if change == 0:
# Exit early since there is not remaining value
break

return self.convert_to_change(value=orig_change, result=denominator_value)
75 changes: 75 additions & 0 deletions cashregister/denomination/dollar/change.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from dataclasses import dataclass, field

from cashregister.denomination.change import Change
from cashregister.denomination.dollar.converter import (
DimeConverter,
NickelConverter,
PennyConverter,
QuarterConverter,
)


@dataclass
class DollarDenomination:
dollar: int | None = field(default=None)
quarter: int | None = field(default=None)
dime: int | None = field(default=None)
nickel: int | None = field(default=None)
penny: int | None = field(default=None)

def __post_init__(self):
assert not self.dollar or self.dollar > 0, (
"dollar needs to be greater than zero"
)
assert not self.quarter or self.quarter > 0, (
"quarter needs to be greater than zero"
)
assert not self.dime or self.dime > 0, "dime needs to be greater than zero"
assert not self.nickel or self.nickel > 0, (
"nickels needs to be greater than zero"
)
assert not self.penny or self.penny > 0, "pennies needs to be greater than zero"

assert self.dollar or self.quarter or self.dime or self.nickel or self.penny, (
"one denomination needs to be defined"
)


class DollarChange(Change[DollarDenomination]):
def __init__(self, value: float, denomination: DollarDenomination):
super().__init__(value, denomination)

def recalculate_value(self) -> float:
total = 0.0
total += self.denomination.dollar or 0.0
total += (self.denomination.quarter or 0.0) * QuarterConverter.QUARTER
total += (self.denomination.dime or 0.0) * DimeConverter.DIME
total += (self.denomination.nickel or 0.0) * NickelConverter.NICKEL
total += (self.denomination.penny or 0.0) * PennyConverter.PENNY
return round(total, 2)

def __str__(self):
components = []

if self.denomination.dollar is not None and self.denomination.dollar > 0:
components.append(
f"{self.denomination.dollar} dollar{('s' if self.denomination.dollar > 1 else '')}"
)
if self.denomination.quarter is not None and self.denomination.quarter > 0:
components.append(
f"{self.denomination.quarter} quarter{('s' if self.denomination.quarter > 1 else '')}"
)
if self.denomination.dime is not None and self.denomination.dime > 0:
components.append(
f"{self.denomination.dime} dime{('s' if self.denomination.dime > 1 else '')}"
)
if self.denomination.nickel is not None and self.denomination.nickel > 0:
components.append(
f"{self.denomination.nickel} nickel{('s' if self.denomination.nickel > 1 else '')}"
)
if self.denomination.penny is not None and self.denomination.penny > 0:
components.append(
f"{self.denomination.penny} {('pennies' if self.denomination.penny > 1 else 'penny')}"
)

return ",".join(components)
Loading