Automate Expense Tracking with Python

Parse bank SMS and emails into a Money Manager-ready TSV file.

Introduction

30 Second Summary

Every time your bank sends you a transaction alert, you probably glance at it and forget it exists. Weeks later, you have no idea where your money actually went.

In this project, you will build a fully automated expense tracking pipeline that captures bank SMS and emails from your iPhone, syncs them to your Mac via iCloud Drive, and generates a ready-to-import file for the Money Manager app.

What You'll Build

Run one command on your Mac and watch a stack of raw bank messages transform into a clean, organized expense file you can import into Money Manager with a single tap.

By the end of this project, you'll have:

  • An iOS Shortcuts automation that silently captures every bank transaction SMS and email to iCloud Drive the instant it arrives, without you lifting a finger.
  • A Python script that parses multiple Indian bank message formats using regex, extracting amount, merchant, date, and account from each transaction.
  • A deduplicated TSV import file formatted exactly how Money Manager expects it, so your expenses appear with correct accounts and amounts in one tap.
  • Secret Mission: Add automatic category detection that maps merchants like SWIGGY to Food and UBER to Transport.

Are there any prerequisites?

You need a Mac with Python 3.9+ installed, an iPhone running iOS 15 or later, the Money Manager app by RealByte (free version), and iCloud Drive enabled on both devices. No paid APIs or pip installs are needed.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters to you.

Set Up the Project Structure

The foundation of your automation pipeline is a Python script that knows exactly where to find captured bank messages and where to save the processed output.

In this step, you'll add the imports and configuration that wire your script to the iCloud Drive log file and define which bank accounts to track.

In this step, get ready to:
  • Add Python standard library imports for regex, CSV handling, and file paths.
  • Define configuration constants pointing to your iCloud Drive log file and output locations.
  • Map your bank account identifiers to Money Manager account names.
Add the imports and path configuration

Your script needs tools from Python's standard library to match bank message patterns, write output files, and navigate the file system.

  • Press Cmd+Space (macOS) to open Spotlight Search.
  • Type Terminal and press Enter to open it.
  • Navigate to your project folder by running this command:
cd ~/ExpenseTracker
  • Open expense_parser.py in your text editor by running:
open expense_parser.py

What does this open?

The open command launches the file in your Mac's default text editor (usually TextEdit). If you have VS Code or another code editor installed and set as your default, it opens there instead.

  • Add the following imports and configuration constants to the empty expense_parser.py file:
import re
import csv
import os
from datetime import datetime
from pathlib import Path

# === CONFIGURATION ===

ICLOUD_DRIVE_PATH = Path.home() / "Library" / "Mobile Documents" / "com~apple~CloudDocs" / "ExpenseTracker"
LOG_FILE = ICLOUD_DRIVE_PATH / "transactions_log.txt"
OUTPUT_FILE = Path(__file__).parent / "import.tsv"
STATE_FILE = Path(__file__).parent / ".last_processed_line"

What does this code do?

The imports bring in Python's built-in tools: re for regex pattern matching, csv for writing tab-separated files, and Path for building cross-compatible file paths.

The four constants define your pipeline's file locations. LOG_FILE points to the iCloud Drive file where your iPhone writes captured bank messages. OUTPUT_FILE is where the final TSV for Money Manager gets saved. STATE_FILE tracks which log entries have already been processed so you never import the same transaction twice.

  • Save the file.
  • Switch back to Terminal and verify the imports work by running:
python3 expense_parser.py

You should see the terminal return to the prompt with no output and no errors. Silence means success here.

Seeing a ModuleNotFoundError or SyntaxError?

  • Double-check that your import lines match the code above exactly. A missing space or typo in from datetime import datetime is a common culprit.
  • Make sure you are running python3 (not python) in the ~/ExpenseTracker folder where your file lives.
  • All five modules (re, csv, os, datetime, pathlib) are part of Python's standard library. No pip install is needed.

Help me debug this import error

Define the bank account mapping

Money Manager requires each transaction to have an account name that matches exactly what you configured in the app. This dictionary maps the bank codes your script detects in SMS messages to your Money Manager account names.

  • In expense_parser.py, add the following code below the STATE_FILE line:
# Map your bank account identifiers to Money Manager account names
ACCOUNT_MAP = {
    "HDFC": "HDFC Bank",
    "ICICI": "ICICI Bank",
    "SBI": "SBI",
    "AXIS": "Axis Bank",
    "KOTAK": "Kotak Bank",
}

What does this code do?

ACCOUNT_MAP translates short bank identifiers (like HDFC) into the full account names you see inside Money Manager (like HDFC Bank). When the parser detects which bank sent a message, it uses this map to tag the transaction with the right account.

If your Money Manager account names differ from these defaults, you can update the values on the right side to match your setup.

  • Save expense_parser.py.
  • Before you run the script, do you think it will produce any output or stay silent?
  • Verify everything still runs cleanly:
python3 expense_parser.py

The terminal returns to the prompt silently. Your script has all its dependencies and configuration loaded without errors.

  • Confirm the iCloud Drive path resolves correctly by running:
python3 -c "from expense_parser import LOG_FILE; print(LOG_FILE)"

You should see the full expanded path printed, ending in ExpenseTracker/transactions_log.txt.

Seeing an ImportError on expense_parser?

  • Make sure your terminal is in the ~/ExpenseTracker directory. Run pwd to check.
  • If you see No module named 'expense_parser', run cd ~/ExpenseTracker first, then retry.

Help me fix this module import issue

✔️ Awesome, I've got everything!

Great. Double check you have saved expense_parser.py and that the terminal prints the correct iCloud Drive path.

ⓧ I'd like to double check the full code

Here is the complete expense_parser.py at this point:

import re
import csv
import os
from datetime import datetime
from pathlib import Path

# === CONFIGURATION ===

ICLOUD_DRIVE_PATH = Path.home() / "Library" / "Mobile Documents" / "com~apple~CloudDocs" / "ExpenseTracker"
LOG_FILE = ICLOUD_DRIVE_PATH / "transactions_log.txt"
OUTPUT_FILE = Path(__file__).parent / "import.tsv"
STATE_FILE = Path(__file__).parent / ".last_processed_line"

# Map your bank account identifiers to Money Manager account names
ACCOUNT_MAP = {
    "HDFC": "HDFC Bank",
    "ICICI": "ICICI Bank",
    "SBI": "SBI",
    "AXIS": "Axis Bank",
    "KOTAK": "Kotak Bank",
}

Your script now knows where to read incoming bank messages and where to write the processed output. Next up, you'll teach it to actually read those messages and extract the transaction details using regex patterns.

Parse Bank SMS with Regex

Your project folder and iCloud Drive sync are ready. Now you need to teach your script to read bank SMS messages.

Raw bank messages are unstructured text blobs. Each bank uses a different format, and Money Manager cannot understand any of them. You need to extract the structured data (amount, merchant, date, account) from each bank's specific message format using regex pattern matching.

In this step, get ready to:
  • Build regex patterns that extract transaction data from HDFC, ICICI, and SBI bank SMS formats.
  • Build a parse function that tries each bank's pattern and returns structured transaction data.
  • Add a test mode that runs the parser against sample messages and prints formatted results.
Build regex patterns for each bank format

Indian banks follow consistent SMS templates. HDFC says "Rs.450 debited at SWIGGY on 20-07-25", ICICI says "Acct XX5678 debited for Rs 1200 on 20-Jul-25", and SBI says "debited by Rs 2500 on 20/07/25". Each pattern is different, but predictable enough for regex.

  • In expense_parser.py, find the closing } of the ACCOUNT_MAP dictionary.
  • Add the following regex patterns below it, covering HDFC and ICICI bank formats:
# === BANK SMS REGEX PATTERNS ===

SMS_PATTERNS = [
    {
        "bank": "HDFC",
        "pattern": re.compile(
            r"Rs\.?(?P<amount>[\d,]+\.?\d*)\s+(?:debited|spent)"
            r".*?(?:at|to)\s+(?P<merchant>.+?)\s+on\s+(?P<date>\d{2}-\d{2}-\d{2,4})",
            re.IGNORECASE | re.DOTALL,
        ),
        "date_format": "%d-%m-%y",
    },
    {
        "bank": "ICICI",
        "pattern": re.compile(
            r"Acct\s+(?:XX|xx)(?P<acct_last4>\d+)\s+debited.*?"
            r"Rs\s+(?P<amount>[\d,]+\.?\d*)\s+on\s+(?P<date>\d{2}-\w{3}-\d{2,4})"
            r".*?(?:;|to)\s*(?P<merchant>.+?)(?:\.|UPI|IMPS|NEFT)",
            re.IGNORECASE | re.DOTALL,
        ),
        "date_format": "%d-%b-%y",
    },
]

What do these patterns do?

Each entry in SMS_PATTERNS is a dictionary with three keys. The bank key identifies which bank the pattern belongs to. The pattern key is a compiled regex that uses named groups (?P<amount>) to extract the transaction amount, merchant name, and date from the raw SMS text.

The date_format key tells Python's strptime how to interpret the date string each bank uses. HDFC writes dates as 20-07-25 (day-month-year), while ICICI uses 20-Jul-25 (day-month abbreviation-year).

SBI uses yet another format. You need a third pattern to cover it.

  • In expense_parser.py, find the closing ] of the SMS_PATTERNS list.
  • Find this line:
        "date_format": "%d-%b-%y",
    },
]
  • Add the SBI pattern before the closing ], so the end of the list now looks like this:
        "date_format": "%d-%b-%y",
    },
    {
        "bank": "SBI",
        "pattern": re.compile(
            r"debited\s+by\s+Rs\s*(?P<amount>[\d,]+\.?\d*)"
            r".*?on\s+(?P<date>\d{2}[/-]\d{2}[/-]\d{2,4})"
            r".*?(?:to|trf\s+to)\s+(?P<merchant>.+?)(?:\s+Ref|$)",
            re.IGNORECASE | re.DOTALL,
        ),
        "date_format": "%d/%m/%y",
    },
]
  • Save expense_parser.py.
  • Confirm your regex patterns compile without errors by running this command:
python3 -c "from expense_parser import SMS_PATTERNS; print(len(SMS_PATTERNS), 'patterns loaded')"

You should see 3 patterns loaded printed in your terminal.

Seeing a SyntaxError?

Check that every opening parenthesis in re.compile( has a matching closing ) with a comma after it. Multi-line regex strings must be on consecutive lines with no gap between them.

Also confirm each dictionary entry ends with }, (brace-comma) and the list ends with ].

help me debug a SyntaxError in my SMS_PATTERNS regex list

Build the parse function

The patterns can now match raw text, but you need helper functions to convert the captured strings into usable Python values. You also need sample messages to test against.

  • In expense_parser.py, add the following test data below the SMS_PATTERNS list:
# === TEST DATA ===

SAMPLE_MESSAGES = [
    {
        "source": "SMS",
        "text": "2025-07-20 10:30:00|SMS|Rs.450.00 debited from HDFC Bank a/c XX1234 at SWIGGY on 20-07-25. Avl bal: Rs.15000",
    },
    {
        "source": "SMS",
        "text": "2025-07-20 12:00:00|SMS|ICICI Bank Acct XX5678 debited for Rs 1200.50 on 20-Jul-25; UBER INDIA credited. UPI:654572087588.",
    },
    {
        "source": "SMS",
        "text": "2025-07-20 14:15:00|SMS|Your SBI a/c X1234 debited by Rs 2500.00 on 20/07/25 to AMAZON PAY Ref No 12345.",
    },
]

What is this test data format?

Each sample mimics the format your iOS Shortcut will write to the log file: a timestamp, a pipe separator, the source tag (SMS or EMAIL), another pipe, then the raw message body. This three-part format lets the parser know which set of regex patterns to try.

  • Below SAMPLE_MESSAGES, add two helper functions that convert captured regex strings into Python types:
def parse_amount(amount_str):
    """Remove commas and convert to float."""
    return float(amount_str.replace(",", ""))


def parse_date(date_str, date_format):
    """Parse date string to datetime object, handling 2-digit and 4-digit years."""
    try:
        return datetime.strptime(date_str.strip(), date_format)
    except ValueError:
        for fmt in ["%d-%m-%Y", "%d-%m-%y", "%d/%m/%Y", "%d/%m/%y", "%d-%b-%Y", "%d-%b-%y"]:
            try:
                return datetime.strptime(date_str.strip(), fmt)
            except ValueError:
                continue
    return None

Why does parse_date try multiple formats?

Bank messages sometimes use 2-digit years (25) and sometimes 4-digit years (2025). The fallback loop tries common Indian date formats so the parser is resilient to slight variations in how your bank formats the date.

  • Save expense_parser.py.
  • Verify the helpers work by running this quick test:
python3 -c "from expense_parser import parse_amount; print(parse_amount('1,200.50'))"

You should see 1200.5 printed. The comma is stripped and the string becomes a usable float.

Now you need the main parsing function that ties everything together. It splits each log line, picks the right pattern set, and tries each regex until one matches.

  • Below parse_date, add the parse_message() function:
def parse_message(raw_line):
    """Parse a single log line into a transaction dictionary."""
    parts = raw_line.strip().split("|", 2)
    if len(parts) < 3:
        return None

    timestamp_str, source, message_body = parts
    source = source.strip().upper()

    patterns = SMS_PATTERNS if source == "SMS" else []

    for pattern_info in patterns:
        match = pattern_info["pattern"].search(message_body)
        if match:
            groups = match.groupdict()
            amount = parse_amount(groups["amount"])
            merchant = groups["merchant"].strip()
            date = parse_date(groups["date"], pattern_info["date_format"])

            if date is None:
                continue

            return {
                "date": date,
                "amount": amount,
                "merchant": merchant,
                "bank": pattern_info["bank"],
                "account": ACCOUNT_MAP.get(pattern_info["bank"], pattern_info["bank"]),
                "source": source,
                "raw": message_body,
            }

    return None

What does parse_message do?

It splits each log line on the | pipe character to separate the timestamp, source tag, and message body. Based on the source (SMS for now), it picks the matching pattern list.

Then it loops through each bank's regex. When a pattern matches, it extracts the named groups (amount, merchant, date) and returns a clean dictionary. If no pattern matches, it returns None.

  • Save expense_parser.py.
  • Verify the full parse chain works on your first sample message:
python3 -c "from expense_parser import parse_message, SAMPLE_MESSAGES; r = parse_message(SAMPLE_MESSAGES[0]['text']); print(f'Rs.{r[\"amount\"]} at {r[\"merchant\"]}')"

You should see Rs.450.0 at SWIGGY printed. Your parser successfully extracted the amount and merchant from a raw HDFC SMS.

Seeing None or a KeyError?

If you get None, the HDFC regex did not match the sample text. Double-check that your HDFC pattern string matches the code above exactly. A common mistake is a missing \s+ or a misplaced named group.

help me debug why parse_message returns None for the HDFC sample

Add the test mode

Parsing one message at a time via the terminal is tedious. A dedicated test mode will run all samples at once and display a formatted summary so you can verify accuracy at a glance.

  • Below parse_message(), add the run_test() function:
def run_test():
    """Run parser against sample messages and display results."""
    print("=" * 60)
    print("EXPENSE PARSER TEST MODE")
    print("=" * 60)

    transactions = []
    for sample in SAMPLE_MESSAGES:
        result = parse_message(sample["text"])
        if result:
            transactions.append(result)
            print(f"\n  Source: {result['source']}")
            print(f"  Bank:   {result['bank']}")
            print(f"  Date:   {result['date'].strftime('%d/%m/%Y')}")
            print(f"  Amount: Rs. {result['amount']:.2f}")
            print(f"  Merchant: {result['merchant']}")
            print(f"  Account:  {result['account']}")
        else:
            print(f"\n  FAILED TO PARSE: {sample['text'][:60]}...")

What does run_test display?

For each sample message, run_test() calls parse_message() and prints the extracted fields in a readable format. If a message fails to parse, it prints the first 60 characters so you can identify which bank pattern needs fixing.

  • At the very bottom of expense_parser.py, add the main entry point that routes command-line flags to the right function:
if __name__ == "__main__":
    import sys

    if len(sys.argv) > 1:
        if sys.argv[1] == "--test":
            run_test()
        else:
            print("Usage: python3 expense_parser.py [--test | --generate]")
    else:
        print("Usage: python3 expense_parser.py [--test | --generate]")
        print("  --test      Run parser against sample messages")
        print("  --generate  Parse log file and generate TSV for import")

What does the main block do?

The if __name__ == "__main__" guard ensures this code only runs when you execute the script directly (not when importing it). It checks for a --test flag and routes to run_test(). The --generate flag will be wired up in a later step.

  • Save expense_parser.py.

Before you run the test, predict: do you think all 3 sample messages will parse successfully, or will one fail?

  • Run the parser in test mode:
python3 expense_parser.py --test

You should see all 3 messages parsed with their structured data displayed. The output shows Source, Bank, Date, Amount, Merchant, and Account for each transaction.

Seeing FAILED TO PARSE for one of the messages?

Check which bank failed in the output. The most common issue is a mismatch between the regex pattern and the sample text. Make sure your ICICI pattern includes the (?:;|to) alternation (ICICI uses a semicolon before the merchant name).

For SBI, confirm your pattern starts with debited\s+by\s+Rs and uses [/-] to match both slash and dash date separators.

help me fix my regex pattern that fails to parse one of the bank SMS samples

✔️ Awesome, I've got everything!

Great. Double check you have saved expense_parser.py and that all 3 messages parse correctly.

ⓧ I'd like to double check the full code

Your expense_parser.py is built from multiple chunks. Scroll up through your file and confirm the following sections are present in order:

  • Imports and CONFIGURATION section (from Step 1).
  • ACCOUNT_MAP dictionary (from Step 1).
  • SMS_PATTERNS list with 3 bank entries (HDFC, ICICI, SBI).
  • SAMPLE_MESSAGES list with 3 SMS test entries.
  • parse_amount() and parse_date() helper functions.
  • parse_message() function.
  • run_test() function.
  • if __name__ == "__main__" block at the very bottom.

If anything is missing or out of order, add it in the correct position and re-run python3 expense_parser.py --test to confirm all 3 messages parse.

Your parser now handles three different bank SMS formats and extracts clean, structured data from each. Next up, you will add email parsing as a fallback and build deduplication logic to prevent double-counting when both SMS and email arrive for the same transaction.

Add Email Parsing and Deduplication

Your parser handles SMS messages from three banks. But not every transaction triggers an SMS. Some banks only send an email notification for certain transactions (like international purchases or recurring subscriptions).

There is a catch. When the same transaction generates both an SMS and an email, importing both into Money Manager would double-count that expense. You need a deduplication layer that catches these repeats before they corrupt your expense data.

In this step, get ready to:
  • Add email regex patterns and route email messages to them.
  • Build a deduplication function to catch repeated transactions.
  • Update test mode to verify deduplication works.
Add email regex patterns

Bank emails use different phrasing than SMS messages. HDFC emails say "a transaction of Rs. 450.00 has been made at SWIGGY" while their SMS says "Rs.450.00 debited...at SWIGGY". You need separate regex patterns tuned to each format.

  • In expense_parser.py, find the # === TEST DATA === comment. Add this new section directly above it:
# === EMAIL REGEX PATTERNS ===

EMAIL_PATTERNS = [
    # HDFC email transaction alert
    {
        "bank": "HDFC",
        "pattern": re.compile(
            r"(?:transaction|purchase).*?Rs\.?\s*(?P<amount>[\d,]+\.?\d*)"
            r".*?(?:at|to|towards)\s+(?P<merchant>.+?)\s+on\s+(?P<date>\d{2}[/-]\d{2}[/-]\d{2,4})",
            re.IGNORECASE | re.DOTALL,
        ),
        "date_format": "%d/%m/%Y",
    },
    # ICICI email alert
    {
        "bank": "ICICI",
        "pattern": re.compile(
            r"debited.*?INR\s+(?P<amount>[\d,]+\.?\d*)"
            r".*?on\s+(?P<date>\d{2}-\w{3}-\d{4})"
            r".*?(?:Info:|details:)\s*(?P<merchant>.+?)(?:\.|$)",
            re.IGNORECASE | re.DOTALL,
        ),
        "date_format": "%d-%b-%Y",
    },
]

What does this code do?

Each email pattern targets the specific phrasing that bank uses in transaction alert emails.

  • HDFC emails use words like "transaction" or "purchase" followed by Rs. and the amount, then the merchant after "at", "to", or "towards".
  • ICICI emails use INR instead of Rs and place merchant details after an "Info:" or "details:" label.
  • Email dates use 4-digit years (%Y) while SMS uses 2-digit (%y), because emails have more room for detail.

Now you need to tell parse_message() to use these new patterns when it encounters an email.

  • In expense_parser.py, scroll down to the parse_message() function.
  • Find this line:
    patterns = SMS_PATTERNS if source == "SMS" else []
  • Replace [] with EMAIL_PATTERNS, so the line now looks like this:
    patterns = SMS_PATTERNS if source == "SMS" else EMAIL_PATTERNS

The parser can now route email content to the correct patterns. Next, you need a test email message to confirm it works.

  • Scroll up to the SAMPLE_MESSAGES list. Find the last entry (the SBI SMS) and add this new entry after it, inside the list:
    {
        "source": "EMAIL",
        "text": "2025-07-20 10:31:00|EMAIL|Dear Customer, a transaction of Rs. 450.00 has been made at SWIGGY on 20/07/2025 using your HDFC Bank Credit Card.",
    },

Why is this a deliberate duplicate?

This email describes the exact same SWIGGY Rs. 450 transaction from the first SMS sample. Same amount, same merchant, same date. The only difference is the source (EMAIL vs SMS) and the timestamp (10:31 vs 10:30).

This is the real-world scenario you are solving. When your bank fires both an SMS and an email for one purchase, your parser will catch both unless you deduplicate.

  • Save expense_parser.py.
  • Run the test mode to confirm email parsing works by running this command:
python3 expense_parser.py --test

You should now see 4 parsed messages instead of 3. The new fourth entry shows Source: EMAIL, Bank: HDFC, Merchant: SWIGGY.

Only seeing 3 results?

  • Check that your 4th SAMPLE_MESSAGES entry has "source": "EMAIL" and the text starts with the pipe-delimited format: 2025-07-20 10:31:00|EMAIL|Dear Customer....
  • Make sure you changed else [] to else EMAIL_PATTERNS in parse_message(). Without this routing, email messages hit an empty pattern list.
  • Confirm the new entry is inside the SAMPLE_MESSAGES list brackets and has a trailing comma after the closing brace.

Help me debug my email parsing setup.

Build the deduplication logic

You can see the problem in your test output. The SWIGGY Rs. 450 transaction appears twice: once from SMS, once from EMAIL. If you imported both into Money Manager, you would show Rs. 900 spent at Swiggy when you only spent Rs. 450.

  • In expense_parser.py, find the run_test() function definition. Add these two new functions directly above it:
def normalize_merchant(merchant):
    """Normalize merchant name for deduplication comparison."""
    return re.sub(r"[^a-z0-9]", "", merchant.lower())


def deduplicate(transactions):
    """Remove duplicate transactions (same date, amount, and similar merchant)."""
    unique = []
    seen = set()
    duplicates_removed = 0

    for txn in transactions:
        # Create a dedup key: date + amount + normalized merchant
        key = (
            txn["date"].strftime("%Y-%m-%d"),
            f"{txn['amount']:.2f}",
            normalize_merchant(txn["merchant"]),
        )

        if key not in seen:
            seen.add(key)
            unique.append(txn)
        else:
            duplicates_removed += 1

    return unique, duplicates_removed

What does this code do?

  • normalize_merchant() strips everything except lowercase letters and numbers. This way "SWIGGY", "Swiggy India Pvt Ltd", and "swiggy" all become swiggy for comparison purposes.
  • deduplicate() builds a unique key from (date, amount, normalized merchant) for each transaction. If two transactions share the same key, only the first one is kept.
  • The function returns both the unique list and a count of how many duplicates were removed, so you can report what happened.
  • Save expense_parser.py.
Update test mode with deduplication results

The deduplication function exists but nothing calls it yet. You need to wire it into run_test() so the test output shows whether duplicates were caught.

  • In expense_parser.py, scroll to the bottom of the run_test() function. Find the last line inside it (the else block with the "FAILED TO PARSE" print statement).
  • Add the following code at the end of run_test(), below the for loop, still inside the function:
    print("\n" + "=" * 60)
    print("DEDUPLICATION")
    print("=" * 60)

    unique, removed = deduplicate(transactions)
    print(f"\n  Total parsed:      {len(transactions)}")
    print(f"  Duplicates removed: {removed}")
    print(f"  Unique transactions: {len(unique)}")

What does this code do?

After parsing all sample messages, this block passes the full list of transactions through deduplicate() and prints a summary. You will see exactly how many transactions survived and how many were caught as repeats.

  • Save expense_parser.py.

Before you run this, think about what you expect to see. You have 4 sample messages, but the HDFC SWIGGY transaction appears as both an SMS and an email. How many unique transactions should the deduplication report?

  • Run the test mode by running this command:
python3 expense_parser.py --test

You should see 4 parsed messages (3 SMS + 1 EMAIL), followed by a DEDUPLICATION section reporting: 1 duplicate removed, 3 unique transactions.

Seeing 0 duplicates removed?

  • Make sure your EMAIL sample message uses the same amount (450.00) and merchant (SWIGGY) as the first SMS sample. The dedup key is built from these values.
  • Check that the email date (20/07/2025) resolves to the same calendar day as the SMS date (20-07-25). Both should parse to 20 July 2025.
  • Confirm normalize_merchant() uses the regex r"[^a-z0-9]" so both "SWIGGY" and "SWIGGY" normalize to the same string.

Help me debug why deduplication is not catching duplicates.

✔️ Awesome, I've got everything!

Great. Double check you have saved expense_parser.py and your test output shows 1 duplicate removed.

ⓧ I'd like to double check the full code

Scroll through your file and confirm these sections are present in order:

  • Imports and CONFIGURATION section (unchanged from previous step).
  • ACCOUNT_MAP dictionary (unchanged).
  • SMS_PATTERNS list with HDFC, ICICI, SBI patterns (unchanged).
  • EMAIL_PATTERNS list with HDFC and ICICI email patterns (added this step).
  • SAMPLE_MESSAGES with 4 entries: 3 SMS + 1 EMAIL (extended this step).
  • parse_amount() and parse_date() helpers (unchanged).
  • parse_message() with else EMAIL_PATTERNS routing (updated this step).
  • normalize_merchant() and deduplicate() functions (added this step).
  • run_test() with deduplication summary at the end (updated this step).
  • if __name__ == "__main__": block (unchanged).

Your parser now handles both SMS and email bank messages, and automatically catches duplicates. Next up, you will turn these parsed transactions into a file that Money Manager can actually import.

Generate the Money Manager TSV

Your parser extracts clean, structured transaction data from messy bank messages. But Money Manager cannot read Python dictionaries.

Money Manager only imports data in a specific TSV (tab-separated values) format with exact column ordering and date formatting. If a single column is out of place or the date format is wrong, the import fails silently.

In this step, you will build the function that transforms your parsed transactions into that exact format. You will also wire up a complete pipeline that reads from iCloud Drive, deduplicates, and writes the ready-to-import file.

In this step, get ready to:
  • Build a TSV generator that outputs transactions in Money Manager's required column format.
  • Add a state-tracking mechanism so the script only processes new messages each time you run it.
  • Wire up a --generate flag that runs the full pipeline end-to-end.
Build the TSV generation function

Money Manager requires tab-separated data in this exact column order: Date, Account, Category, Subcategory, Note, Amount, Income/Expense, Description. Dates must be formatted as mm/dd/yyyy and the Account field must match the names configured inside the app.

  • In expense_parser.py, scroll to the bottom of the file and find the deduplicate() function.
  • Add the following generate_tsv() function below deduplicate() (above run_test()):
def generate_tsv(transactions, output_path):
    """Generate Money Manager compatible TSV file."""
    with open(output_path, "w", newline="", encoding="utf-8") as f:
        writer = csv.writer(f, delimiter="\t")
        for txn in transactions:
            row = [
                txn["date"].strftime("%m/%d/%Y"),  # Date: mm/dd/yyyy
                txn["account"],                     # Account
                "Uncategorized",                    # Category
                "",                                 # Subcategory
                txn["merchant"],                    # Note
                f"{txn['amount']:.2f}",            # Amount
                "Expense",                          # Income/Expense
                f"Auto-imported from {txn['source']}",  # Description
            ]
            writer.writerow(row)

    print(f"TSV written to: {output_path}")
    print(f"Total transactions: {len(transactions)}")

What does this code do?

  • csv.writer(f, delimiter="\t") creates a writer that separates values with tabs instead of commas. This is what makes it a TSV file.
  • Each row maps your parsed transaction fields to Money Manager's required columns in the exact order the app expects.
  • Category is hardcoded to "Uncategorized" for now. The Secret Mission adds automatic categorization later.
  • The date is reformatted from Python's datetime to mm/dd/yyyy using strftime("%m/%d/%Y").

Why does Account need to match exactly?

Money Manager validates the Account field against the accounts you have configured inside the app. If your TSV says "HDFC Bank" but your app has "HDFC", the import will fail.

The ACCOUNT_MAP dictionary at the top of your file is where you control this mapping. Update its values to match your Money Manager account names exactly.

Add the state-tracking functions

Every time you run the script, it should only process new messages. Without tracking, you would re-import old transactions and create duplicates inside Money Manager.

  • Add the following three functions below generate_tsv() (still above run_test()):
def get_last_processed_line():
    """Get the line number of the last processed entry."""
    if STATE_FILE.exists():
        return int(STATE_FILE.read_text().strip())
    return 0


def save_last_processed_line(line_number):
    """Save the last processed line number."""
    STATE_FILE.write_text(str(line_number))


def read_log_file():
    """Read unprocessed lines from the iCloud Drive log file."""
    if not LOG_FILE.exists():
        print(f"Log file not found: {LOG_FILE}")
        return []

    all_lines = LOG_FILE.read_text(encoding="utf-8").strip().splitlines()
    last_processed = get_last_processed_line()
    new_lines = all_lines[last_processed:]

    return new_lines, len(all_lines)

What does this code do?

  • get_last_processed_line() reads a small state file (.last_processed_line) to find where you left off. If the file does not exist yet, it returns 0 (start from the beginning).
  • save_last_processed_line() writes the total line count after a successful run, so the next run skips already-processed entries.
  • read_log_file() reads only the unprocessed lines from your iCloud Drive log file by slicing from the last processed position onward.
  • Save expense_parser.py.
Wire up the --generate pipeline

Now you need an orchestration function that ties everything together: read the log, parse messages, deduplicate, generate the TSV, and save state.

  • Add the first part of run_generate() below read_log_file() (above run_test()):
def run_generate():
    """Parse log file and generate TSV for Money Manager import."""
    print("Reading log file...")
    result = read_log_file()

    if not result:
        return

    new_lines, total_lines = result

    if not new_lines:
        print("No new transactions to process.")
        return

    print(f"Found {len(new_lines)} new log entries.")

    # Parse all messages
    transactions = []
    failed = 0
    for line in new_lines:
        if line.strip():
            txn = parse_message(line)
            if txn:
                transactions.append(txn)
            else:
                failed += 1

    print(f"Parsed: {len(transactions)} | Failed: {failed}")
  • Continue the run_generate() function by adding this code directly below the line you just pasted:
    # Deduplicate
    unique, removed = deduplicate(transactions)
    print(f"Duplicates removed: {removed}")

    if unique:
        generate_tsv(unique, OUTPUT_FILE)
        save_last_processed_line(total_lines)
    else:
        print("No transactions to export.")

What does this code do?

  • The function reads only unprocessed lines from the iCloud Drive log, then feeds each line through parse_message().
  • After parsing, it deduplicates and writes the TSV only if there are unique transactions to export.
  • save_last_processed_line(total_lines) records the high-water mark so the next run starts where this one left off.
  • If the log is empty or has no new entries, it exits early with a helpful message.

Now connect this function to the --generate flag in the __main__ block.

  • Scroll to the bottom of your file and find this code inside the if __name__ == "__main__": block:
    if len(sys.argv) > 1:
        if sys.argv[1] == "--test":
            run_test()
        else:
            print("Usage: python3 expense_parser.py [--test | --generate]")
  • Replace that entire if len(sys.argv) > 1: block with the following, which adds the --generate branch:
    if len(sys.argv) > 1:
        if sys.argv[1] == "--test":
            run_test()
        elif sys.argv[1] == "--generate":
            run_generate()
        else:
            print("Usage: python3 expense_parser.py [--test | --generate]")
  • Save expense_parser.py.

Your log file is currently empty, so the first run will confirm the pipeline works without crashing.

  • Test the empty-log path by running:
python3 expense_parser.py --generate

You should see:

Reading log file...
No new transactions to process.

Seeing an error instead?

  • If you see NameError: name 'run_generate' is not defined, make sure the run_generate() function is defined above the if __name__ block, not inside it.
  • If you see Log file not found, your transactions_log.txt file may not be in the right location. Check that it exists at ~/Library/Mobile Documents/com~apple~CloudDocs/ExpenseTracker/transactions_log.txt.

Help me debug this error

Now add some test transactions to the log file so you can see the full pipeline produce real output.

  • Write sample bank messages into your log file by running:
echo '2025-07-20 10:30:00|SMS|Rs.450.00 debited from HDFC Bank a/c XX1234 at SWIGGY on 20-07-25. Avl bal: Rs.15000' >> ~/Library/Mobile\ Documents/com~apple~CloudDocs/ExpenseTracker/transactions_log.txt
echo '2025-07-20 12:00:00|SMS|ICICI Bank Acct XX5678 debited for Rs 1200.50 on 20-Jul-25; UBER INDIA credited. UPI:654572087588.' >> ~/Library/Mobile\ Documents/com~apple~CloudDocs/ExpenseTracker/transactions_log.txt
echo '2025-07-20 14:15:00|SMS|Your SBI a/c X1234 debited by Rs 2500.00 on 20/07/25 to AMAZON PAY Ref No 12345.' >> ~/Library/Mobile\ Documents/com~apple~CloudDocs/ExpenseTracker/transactions_log.txt

Before you run the script again, what do you think the output will show? How many transactions will it parse, and will any duplicates be removed?

  • Run the generator again:
python3 expense_parser.py --generate

You should see output like this:

Reading log file...
Found 3 new log entries.
Parsed: 3 | Failed: 0
Duplicates removed: 0
TSV written to: /Users/.../expense-tracker/import.tsv
Total transactions: 3
  • Confirm the TSV file was created by running:
cat import.tsv

You should see three tab-separated rows with columns: Date (mm/dd/yyyy format), Account, Category (Uncategorized), Subcategory (empty), Note (merchant name), Amount, Expense, and Description.

Why is the state file important?

Running the script created a .last_processed_line file in your project folder containing the number 3. If you run python3 expense_parser.py --generate again right now, it will say "No new transactions to process" because it knows those 3 lines are already handled.

New bank messages that arrive after this point will be appended below line 3 in the log. Only those new lines will be picked up on the next run.

TSV looks garbled or columns are wrong?

  • If columns appear merged, open import.tsv in a plain text editor (not Excel). Tabs may be invisible in some viewers.
  • If dates show the wrong format, check that strftime("%m/%d/%Y") uses a capital Y (4-digit year) and lowercase m (zero-padded month).
  • If you see 0 parsed transactions, verify your echo commands used single quotes (not double quotes that might expand variables).

Help me fix my TSV output

✔️ Awesome, I've got everything!

Great. Double check you have saved expense_parser.py and that import.tsv exists in your project folder.

ⓧ I'd like to double check the full code

Your expense_parser.py is now quite long. Scroll through and confirm you have these sections in order:

  • Imports (re, csv, os, datetime, pathlib).
  • Configuration constants (ICLOUD_DRIVE_PATH, LOG_FILE, OUTPUT_FILE, STATE_FILE, ACCOUNT_MAP).
  • SMS_PATTERNS and EMAIL_PATTERNS regex lists.
  • SAMPLE_MESSAGES test data (4 entries).
  • parse_amount(), parse_date(), parse_message().
  • normalize_merchant(), deduplicate().
  • generate_tsv() (new this step).
  • get_last_processed_line(), save_last_processed_line(), read_log_file() (new this step).
  • run_generate() (new this step).
  • run_test().
  • if __name__ == "__main__": block with --test and --generate branches.

Your parser now has a complete output pipeline. Run one command and your bank messages become a Money Manager-ready file. Next up, you will set up the iOS Shortcuts automation that captures bank messages to the log file automatically, so you never have to copy-paste a message by hand.

Set Up iOS Shortcuts Automation

Your Python script can parse bank messages, deduplicate them, and generate a ready-to-import TSV file. There is one missing piece: those bank messages still need to get into the transactions_log.txt file somehow.

Without automation, you would need to manually copy-paste every bank SMS and email into that file. In this step, you will create iOS Shortcuts automations that fire the instant a bank message arrives, capturing it to iCloud Drive with zero effort on your part.

In this step, get ready to:
  • Create an SMS automation triggered by your bank sender codes.
  • Add actions that format and append messages to the iCloud Drive log file.
  • Create an email automation as a fallback for missed SMS.
Create the SMS message automation

Indian banks send transaction alerts from short codes like AD-HDFCBK, VM-ICICIB, and JM-SBIINB. You will create a personal automation that triggers every time a message arrives from one of these senders.

  • On your iPhone, open the Shortcuts app.
  • Tap the Automation tab at the bottom of the screen.
  • Tap the + button in the upper-right corner.
  • Tap Create Personal Automation.
  • Scroll down to the Communication section and tap Message.
  • Tap the Sender field and type your first bank sender code, e.g. HDFCBK.
  • Add your other bank sender codes (ICICIB, SBIINB, or whichever banks you use) by tapping Sender again and adding each one.
  • Tap Next.

What are bank sender codes?

In India, transactional SMS messages arrive from alphanumeric short codes like AD-HDFCBK rather than a phone number. You can check the sender code of a recent bank SMS in your Messages app. Use the part after the hyphen (e.g. HDFCBK) as the sender filter in Shortcuts.

You should now see the action editor screen, ready for you to add actions. You will configure those in the next substep.

Add the capture actions

The automation needs to do three things: grab the message content, format it with a timestamp and source tag, then append it to the shared log file. Your Python parser expects each line in the format YYYY-MM-DD HH:MM:SS|SMS|<message body>.

  • Tap Add Action.
  • Search for Format Date and tap it to add it.
  • Tap the Date Format field and select Custom.
  • In the Format String field, type yyyy-MM-dd HH:mm:ss.

Why a custom date format?

Shortcuts uses Unicode date format patterns. The pattern yyyy-MM-dd HH:mm:ss produces timestamps like 2025-07-20 10:30:00 which is exactly what your parse_message() function expects at the start of each log line.

  • Tap the search field at the bottom and search for Text.
  • Tap the Text action (the plain text action with a yellow icon) to add it.
  • Tap inside the Text action's text field.
  • Tap Select Variable and choose the Formatted Date Magic Variable (the blue token under the Format Date action).
  • After the Formatted Date token, type |SMS| (pipe, SMS, pipe).
  • Tap Select Variable again and choose Shortcut Input (this is the message content passed in by the trigger).

Your Text action should now show: [Formatted Date]|SMS|[Shortcut Input] as a combination of blue tokens and typed text.

  • Tap the search field at the bottom and search for Append to Text File.
  • Tap the action to add it.
  • The action's input should automatically be set to the Text from the previous action. If not, tap the input field and select the Text Magic Variable.
  • Tap where it says Shortcuts (the default folder location).
  • Tap Replace from the dropdown menu.
  • A Files browser opens. Navigate to iCloud Drive and select the ExpenseTracker folder, then tap Open.
  • In the File Path field, type transactions_log.txt.
  • Make sure Make New Line is toggled on (this ensures each entry starts on a fresh line).

What does this automation do end-to-end?

When a message arrives from one of your bank sender codes, Shortcuts grabs the message text, stamps it with the current date and time, tags it as SMS, and appends the whole line to the shared log file in iCloud Drive. The file syncs to your Mac automatically.

  • Tap Next to see the automation summary.
  • Turn off Ask Before Running (you may see this as a toggle or be prompted to tap Don't Ask).
  • Tap Done.

Your first automation now appears on the Automation screen. It will run immediately and silently whenever a bank SMS arrives.

Automation still shows a notification prompt?

On some iOS versions, Message automations may display a "Tap to run" notification when the phone is locked, even with "Run Immediately" enabled. This is a known iOS quirk. The automation still captures the message after you tap the notification.

If you want fully silent operation, make sure your iPhone is updated to the latest iOS version available.

help me debug my iOS Shortcuts automation not running silently

Create the email automation

Some banks only send email alerts for certain transactions, or SMS delivery can be delayed. An email automation acts as a fallback, catching transactions your SMS automation might miss. The only difference is the trigger type and the source tag (EMAIL instead of SMS).

  • Return to the Automation tab in Shortcuts.
  • Tap the + button in the upper-right corner.
  • Tap Create Personal Automation.
  • Under Communication, tap Email.
  • Tap the Sender field and type your bank's alert email address, e.g. alerts@hdfcbank.net.
  • Add other bank email addresses (e.g. the addresses your ICICI or SBI banks send alerts from).
  • Tap Next.

Now add the same three actions as before, with one change in the Text action.

  • Add a Format Date action with Custom format yyyy-MM-dd HH:mm:ss.
  • Add a Text action.
  • Build the text as: [Formatted Date]|EMAIL|[Shortcut Input] (use EMAIL instead of SMS this time).
  • Add an Append to Text File action targeting the same ExpenseTracker folder and file path transactions_log.txt.
  • Ensure Make New Line is toggled on.
  • Tap Next.
  • Turn off Ask Before Running (or tap Don't Ask).
  • Tap Done.

You should now see two automations listed on your Automation screen: one for Message and one for Email.

Why two separate automations?

The EMAIL and SMS tags let your parse_message() function choose the right set of regex patterns for each message type. Email alerts often contain more detail than SMS, so they need different patterns.

When both an SMS and an email arrive for the same transaction, your deduplicate() function matches them by date, amount, and merchant, keeping only one entry.

Test the automation end-to-end

The real test is confirming that a message on your iPhone appears in the log file on your Mac. Before you run the verification command, think about what you expect to see in the file.

  • Send yourself a test SMS from another phone (or ask someone to text you). The message should contain the word "debited" and an amount, for example: Rs.100.00 debited from your account at TESTSHOP on 21-07-25.
  • Wait about 30 seconds for iCloud Drive to sync.

No second phone available?

You can also test by temporarily adding the sender's number (your own or a friend's) to the automation's Sender field, then sending yourself any message. Remove the test number from the Sender field after verification.

Before you run the next command, predict: do you think the file will contain the raw SMS text, or a formatted line with a timestamp and the SMS tag?

  • Switch back to the Terminal on your Mac and check the log file by running:
cat ~/Library/Mobile\ Documents/com~apple~CloudDocs/ExpenseTracker/transactions_log.txt

You should see a line like this in the output:

2025-07-21 14:32:05|SMS|Rs.100.00 debited from your account at TESTSHOP on 21-07-25

The timestamp, pipe-delimited SMS tag, and message body confirm your automation is working. The exact timestamp and message text will differ based on when you sent your test.

File is still empty on your Mac?

Check these common issues:

  • Open the Files app on your iPhone, navigate to iCloud Drive > ExpenseTracker, and open transactions_log.txt. If it is empty there too, the automation did not fire.
  • Make sure the sender of the test SMS matches the sender code in your automation's Sender field exactly.
  • Verify that the Append to Text File action points to the ExpenseTracker folder (not the default Shortcuts folder).
  • If the file has content on iPhone but not on Mac, wait a minute for iCloud to sync or open Finder > iCloud Drive to force a refresh.

help me troubleshoot my iOS Shortcuts automation not appending to the log file

Your capture pipeline is live. Every bank transaction SMS and email will now silently flow from your iPhone into the log file on your Mac. Next up, you will tackle the Secret Mission and add automatic expense categorization so your TSV imports come with categories pre-filled.

Secret mission

Auto-Categorize Your Expenses

Your pipeline parses transactions and generates a TSV, but every row lands with "Uncategorized" in the Category column. Money Manager needs categories for useful expense reports. Can you build a keyword-matching system that automatically maps merchants like SWIGGY to "Food & Dining" and UBER to "Transport"?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally using Python's standard library and iCloud Drive, so there are no ongoing costs.

Resources you used:

  • Project folder on your Desktop containing expense_parser.py, .last_processed_line, and import.tsv.
  • iCloud Drive ExpenseTracker folder containing transactions_log.txt.
  • Two iOS Shortcuts personal automations (SMS trigger and Email trigger).

Keep everything running

No action needed. Choose this if you want to keep capturing bank messages and running the parser whenever you need a fresh import file.

  • Your iOS automations will continue capturing bank SMS and email to transactions_log.txt in iCloud Drive.
  • The log file will grow over time. You can periodically clear processed entries or archive old logs.
  • Run python3 expense_parser.py --generate any time you want a fresh TSV for Money Manager.

Pause - I'll come back to this later

Disable the automations so they stop capturing messages, but keep all files intact for when you return.

  • On your iPhone, open the Shortcuts app and tap Automation.
  • Tap your SMS automation.
  • Turn off Enable This Automation, then tap Done.
  • Repeat for your Email automation.
  • Your project files and iCloud Drive folder remain untouched. Re-enable the automations when you want to resume.

Delete - I don't want to use this again

Remove all project files and automations to start fresh.

  • On your iPhone, open the Shortcuts app and tap Automation.
  • Swipe left on your SMS automation, then tap Delete.
  • Swipe left on your Email automation, then tap Delete.
  • Delete the iCloud Drive folder by running this command in Terminal on your Mac:
rm -rf ~/Library/Mobile\ Documents/com~apple~CloudDocs/ExpenseTracker
  • Delete your project folder on the Desktop by running:
rm -rf ~/Desktop/[[PROJECT_FOLDER="your project folder name"]]

Folder name may differ

Replace the placeholder with whatever you named your project folder in Step 1. You can check the name by running ls ~/Desktop to see your folders.

  • If you imported any test data into Money Manager, open the app and delete those entries manually from your transaction list.

Nice Work!

Nice Work!

Nice work! You just built a fully automated expense tracking pipeline that turns raw bank messages into organized, importable financial data without any manual entry.

You've learned how to:

  • Build custom regex parsers that extract structured transaction data (amount, merchant, date, account) from multiple Indian bank SMS and email formats.
  • Implement deduplication logic that prevents double-counting when both SMS and email arrive for the same transaction.
  • Generate Money Manager-compatible TSV files with correct column ordering and date formatting for one-tap import.
  • Configure iOS Shortcuts automations that silently capture bank messages to iCloud Drive in real-time.
  • (Secret Mission) Added automatic merchant-to-category mapping using keyword pattern matching for hands-free expense categorization.

Ready to quiz yourself?