Project 1: Expense Tracker CLI

প্রথম পূর্ণ project, নিজের হাতে লেখা

রোডম্যাপের মাস ২পড়তে প্রায় ১৬ মিনিট

দুই মাসে যা শিখেছো, সব এক জায়গায় লাগানোর সময়। এই project টা নিজের হাতে লিখবে, AI কে দিয়ে না। আটকে গেলে অধ্যায় আবার পড়ো, বা AI কে concept জিজ্ঞেস করো, কিন্তু code টা তোমার আঙুল দিয়ে যাবে। এই একটা project শেষ করলে Python নিয়ে আত্মবিশ্বাস অনেক বেড়ে যাবে, আর এটাই পরের মাসগুলোয় database আর API এর ভিত্তি হবে।

#কী বানাবো

একটা command line app, যেখানে দৈনিক খরচ লিখে রাখা যায়:

Terminal
uv run expense add 250 "দুপুরের খাবার" --category food
uv run expense add 60 "রিকশা" --category transport --date 2026-10-28
uv run expense list --month 2026-10
uv run expense report --month 2026-10
uv run expense delete 3
uv run expense export --month 2026-10 --out october.csv
Text
$ uv run expense report --month 2026-10

অক্টোবর ২০২৬ এর খরচ
-----------------------------
food          ৳ 4,250.00   (৪৮%)
transport     ৳ 1,860.00   (২১%)
bills         ৳ 2,700.00   (৩১%)
-----------------------------
মোট           ৳ 8,810.00
সবচেয়ে বড় খরচ: বিদ্যুৎ বিল (৳ 1,450.00)

#Requirement

যা করবে:

  1. খরচ যোগ করা: পরিমাণ, বিবরণ, category, তারিখ (না দিলে আজকের)
  2. মাস অনুযায়ী list দেখা
  3. মাসের category ভিত্তিক report
  4. id দিয়ে মুছে ফেলা
  5. CSV তে export
  6. Data JSON file এ থাকবে, app বন্ধ করলেও হারাবে না

যেভাবে করবে (non-functional):

  • টাকা পয়সায় integer হিসেবে রাখা (float না)
  • ভুল input এ পরিষ্কার বাংলা message, traceback না
  • Save করার মাঝে crash হলেও data নষ্ট হবে না (atomic save)
  • পুরো type hint, pyright এ শূন্য error
  • গুরুত্বপূর্ণ logic এ test, coverage ৮০% এর বেশি

#Structure

Text
expense-tracker/
├── pyproject.toml
├── README.md
├── src/expense_tracker/
│   ├── __init__.py
│   ├── models.py        ← Expense, Category (dataclass/enum), Money format
│   ├── errors.py        ← ExpenseError পরিবার
│   ├── storage.py       ← JsonStorage: load, save (atomic)
│   ├── service.py       ← ExpenseService: add, list, delete, report (logic)
│   ├── export.py        ← CSV
│   └── cli.py           ← argparse, output সাজানো, error ধরা
└── tests/
    ├── conftest.py
    ├── test_models.py
    ├── test_service.py
    └── test_storage.py
cli.py argument, output, error service.py নিয়ম আর হিসাব storage.py JSON এ পড়া-লেখা models.py Expense, Category export.py CSV লেখা
Service জানে না data JSON এ নাকি database এ থাকে। পরের মাসে শুধু storage বদলে PostgreSQL এ যাবো, বাকি সব অপরিবর্তিত থাকবে।

#ধাপে ধাপে

#ধাপ ১: Model

Python
# models.py
from dataclasses import dataclass
from datetime import date
from enum import StrEnum


class Category(StrEnum):
    FOOD = "food"
    TRANSPORT = "transport"
    BILLS = "bills"
    SHOPPING = "shopping"
    HEALTH = "health"
    OTHER = "other"


@dataclass
class Expense:
    id: int
    amount: int            # পয়সায়
    title: str
    category: Category
    spent_on: date


def format_taka(paisa: int) -> str:
    return f"৳ {paisa / 100:,.2f}"


def parse_taka(text: str) -> int:
    """'250' বা '250.50' কে পয়সায় বদলায়।"""
    ...   # তুমি লেখো: Decimal ব্যবহার করো, ঋণাত্মক বা শূন্য হলে InvalidAmountError

#ধাপ ২: Error

Python
# errors.py
class ExpenseError(Exception):
    """সব expense error এর মূল। cli এটা ধরে সুন্দর message দেখাবে।"""

class InvalidAmountError(ExpenseError): ...
class ExpenseNotFoundError(ExpenseError): ...
class StorageError(ExpenseError): ...

#ধাপ ৩: Storage

Python
# storage.py
from pathlib import Path
from typing import Protocol


class Storage(Protocol):
    def load(self) -> list[Expense]: ...
    def save(self, expenses: list[Expense]) -> None: ...


class JsonStorage:
    def __init__(self, path: Path) -> None:
        self.path = path

    def load(self) -> list[Expense]:
        # file না থাকলে খালি list
        # JSON ভাঙা থাকলে StorageError (from e)
        ...

    def save(self, expenses: list[Expense]) -> None:
        # atomic save: .tmp এ লিখে os.replace
        ...

Data file কোথায় রাখবে? User এর home folder এ: Path.home() / ".expense-tracker" / "data.json"। Project folder এ না।

#ধাপ ৪: Service

Python
# service.py
class ExpenseService:
    def __init__(self, storage: Storage) -> None:
        self.storage = storage

    def add(self, amount: int, title: str, category: Category, spent_on: date) -> Expense: ...
    def list_month(self, year: int, month: int) -> list[Expense]: ...
    def delete(self, expense_id: int) -> None: ...      # না পেলে ExpenseNotFoundError
    def report(self, year: int, month: int) -> "MonthlyReport": ...

নতুন id কীভাবে দেবে? সবচেয়ে বড় id + ১। (ভাবো: মুছে ফেলার পর কী হবে? পুরনো id আবার ব্যবহার করা কি ঠিক?)

#ধাপ ৫: CLI

Python এর argparse দিয়ে sub-command:

Python
# cli.py
import argparse
import sys


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="expense", description="দৈনিক খরচের হিসাব")
    sub = parser.add_subparsers(dest="command", required=True)

    add = sub.add_parser("add", help="নতুন খরচ")
    add.add_argument("amount")
    add.add_argument("title")
    add.add_argument("--category", default="other", choices=[c.value for c in Category])
    add.add_argument("--date", type=date.fromisoformat, default=date.today())

    lst = sub.add_parser("list", help="মাসের খরচ")
    lst.add_argument("--month", required=True, help="যেমন 2026-10")

    # report, delete, export তুমি যোগ করো
    return parser


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    service = ExpenseService(JsonStorage(default_path()))
    try:
        match args.command:
            case "add":
                exp = service.add(parse_taka(args.amount), args.title, Category(args.category), args.date)
                print(f"যোগ হলো #{exp.id}: {exp.title} {format_taka(exp.amount)}")
            case "list":
                ...
    except ExpenseError as e:
        print(f"সমস্যা: {e}", file=sys.stderr)
        return 1
    return 0

pyproject.toml:

TOML
[project.scripts]
expense = "expense_tracker.cli:main"

#ধাপ ৬: Test

Python
# tests/conftest.py
import pytest
from expense_tracker.storage import JsonStorage
from expense_tracker.service import ExpenseService


@pytest.fixture
def service(tmp_path):
    return ExpenseService(JsonStorage(tmp_path / "data.json"))

যেগুলো অবশ্যই test করবে:

  • parse_taka: "250", "250.5", "250.55", "0", "-5", "abc", "1,250"
  • মাসের list এ অন্য মাসের খরচ আসছে না
  • Report এ শতাংশের যোগফল ১০০ (rounding সাবধান)
  • না থাকা id মুছলে ExpenseNotFoundError
  • Save করে load করলে একই data ফিরে আসে
  • ভাঙা JSON file থাকলে StorageError
  • main(["add", "abc", "x"]) এর return value 1

#Bonus চ্যালেঞ্জ

  1. বাজেট: expense budget set food 5000 আর মাসের ৮০% পার হলে সতর্কবার্তা
  2. রঙিন output: rich package দিয়ে সুন্দর table
  3. Undo: শেষ কাজ বাতিল করা
  4. বাংলা সংখ্যায় output এর option

#শেষ করার checklist

#নিজেকে যাচাই করো

Service কেন সরাসরি JsonStorage ব্যবহার না করে Storage Protocol এর উপর নির্ভর করে?

যাতে storage বদলানো যায় (পরে PostgreSQL) service না ছুঁয়ে, আর test এ চাইলে memory তে চলা নকল storage দেওয়া যায়।

মুছে ফেলা খরচের id আবার নতুন খরচকে দেওয়া কেন ঝুঁকিপূর্ণ?

পুরনো কোনো জায়গায় (export করা CSV, note) সেই id এর উল্লেখ থাকলে সেটা ভুল খরচকে নির্দেশ করবে। Database ও তাই id পুনরায় ব্যবহার করে না। সবসময় এ পর্যন্ত সবচেয়ে বড় id এর পরের সংখ্যা দেওয়া নিরাপদ, বা আলাদা counter রাখা।

Report এ শতাংশ round করলে যোগফল ৯৯% বা ১০১% কেন হতে পারে?

প্রতিটা ভাগ আলাদা করে round হয়, ছোট ছোট rounding এর পার্থক্য জমে। সমাধান: শেষ category তে বাকিটা দেওয়া (১০০ থেকে বাকিগুলোর যোগফল বাদ), বা largest remainder পদ্ধতি।

চিহ্ন দিলে sidebar আর রোডম্যাপে এই অধ্যায়ের station ভরে যাবে।