Convert an expense CSV to one currency

Convert an expense CSV to one currency with Python Decimal, recording each dated reference rate and preserving the source amounts.

PythonLast tested Download and run ↓
On this page

Run the example

Download Python

expense-currency-csv.python.py

Download the file, then run these commands in its folder using a macOS or Linux terminal. You need Python 3.9 or later and a secret API key.

Terminal
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install parseapi
export PARSEAPI_KEY='YOUR_SECRET_API_KEY'
python3 expense-currency-csv.python.py

The first run uses the included sample. The CSV instructions below show how to save your own results. Review rows can produce exit code 2 even when the output was written successfully.

Need a key? Set up this example

Convert the expense column

Prepare a multi-currency expense CSV for comparison or an import. This Python script converts each amount to the chosen currency using a dated reference rate, and records that rate beside the original amount.

1. Run your expense file

Save this as expenses.csv:

csv
id,amount,currency,date
001,12.50,EUR,2026-08-28
002,5.25,EUR,2026-08-28
003,1200,JPY,2026-08-29
004,-8.00,EUR,2026-08-28

Run the downloaded script:

Terminal
python3 expense-currency-csv.python.py expenses.csv --quote USD --output expenses-usd.csv

Use decimal-point amounts, three-letter currency codes, and real YYYY-MM-DD dates through today. Negative amounts work for refunds. Currency symbols, grouping separators, and ambiguous dates need cleanup first; the script never guesses their meaning.

2. Fetch the dated rate

Python
if digits is None:
    digits = quote_digits(parse.currency(quote), quote)
# The amount stays in Decimal locally. The API supplies a reusable rate.
data = parse.currency.rate(base, quote, date=day)
answer = rate_answer(data, base, quote, day)

The Currency API supplies target precision and a rate on or before the requested date. Expenses with the same currency pair and date reuse one lookup. Amounts and descriptions stay in the script.

Keep parse_rate_date: a weekend expense can use an earlier published rate. Review rows with no historical rate instead of substituting today's rate.

3. Multiply and round once

For an illustrative EUR/USD rate of 1.125 dated 2026-08-28, selected output columns are:

csv
id,parse_rate,parse_rate_date,parse_converted,parse_status
001,1.125,2026-08-28,14.06,converted
004,1.125,2026-08-28,-9.00,converted

The rate here is an example for checking the rounding calculation. Your output records the actual reference rate and date returned by the API.

Python
def converted(amount, base, quote, rate, rate_date, digits):
    # Keep decimal amounts exact, multiply once, then round the row once.
    try:
        with localcontext() as context:
            context.prec = 50
            rounded = (amount * rate).quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_EVEN)
    except InvalidOperation:
        return outcome("error", "unexpected_response")
    if rounded == 0:
        rounded = abs(rounded)
    return outcome("converted", http_status=200, parse_base=base, parse_quote=quote,
                   parse_rate=format(rate, "f"), parse_rate_date=rate_date,
                   parse_converted=format(rounded, "f"), parse_digits=digits)

The script uses Decimal, then rounds each row to the target currency's decimal places with ROUND_HALF_EVEN. An exact tie rounds to the nearest even final digit. Adapt that policy if your application requires another rounding rule. Python Decimal documentation.

The output includes parse_converted, base and target currencies, the applied rate, its date, decimal precision, and status/error columns. converted means an estimate was written. Missing inputs, unsupported formats, unavailable rates, and failed requests retain their original rows with a review status and empty estimate.

These are reference estimates; actual card charges, fees, and required reporting rates can differ.

The complete script preserves original CSV cells and order. Required headers are amount, currency, and date; use 1-200 rows within 64 KiB, distinct nonempty headers, and no existing output columns. Calls use a ten-second timeout without retries. Account/rate errors stop new lookups.

The output filename must be new. Omit the input filename to use the included sample. Without --output, CSV goes to standard output; never redirect onto the input. Exit 0 means all rows converted, 2 means the output includes review rows, and 1 means a startup or file error.

Complete example

The complete Python source. The script reads your API key from PARSEAPI_KEY.

View and copy the complete source
expense-currency-csv.python.py
import argparse
import csv
import io
import re
import sys
from pathlib import Path

from parseapi import ParseAPI, ParseAPIError

from datetime import date as CalendarDate, datetime, timezone
from decimal import Decimal, InvalidOperation, ROUND_HALF_EVEN, localcontext

DESCRIPTION = "Convert an expense CSV using dated reference exchange rates."
REQUIRED = ["amount", "currency", "date"]
COLUMNS = ["parse_base", "parse_quote", "parse_rate", "parse_rate_date", "parse_converted",
           "parse_digits", "parse_status", "parse_error", "parse_http_status"]
SAMPLE = """id,amount,currency,date,note
001,12.50,EUR,2026-08-28,Lunch
002,5.25,eur,2026-08-28,Same pair and day
003,1200,JPY,2026-08-29,Weekend expense
004,-8.00,EUR,2026-08-28,Refund
005,,EUR,2026-08-28,Missing amount
006,10.00,EUR,03/04/2026,Date needs interpretation
"""

# example:read
def read_rows(stream):
    reader = csv.reader(stream, strict=True)
    fields = next(reader, None)
    if (not fields or not set(REQUIRED).issubset(fields)
            or any(not field.strip() for field in fields)
            or len(fields) != len(set(fields))):
        raise ValueError("Use distinct, nonempty headers including " + ", ".join(REQUIRED) + ".")
    if set(fields) & set(COLUMNS):
        raise ValueError("The input already contains output columns. Use the original CSV.")
    rows = []
    for cells in reader:
        if len(cells) != len(fields):
            raise ValueError("Every row must have the same number of cells as the header.")
        rows.append(dict(zip(fields, cells)))
        if len(rows) > 200:
            raise ValueError("Use 1 to 200 data rows for this example.")
    if not rows:
        raise ValueError("The CSV needs at least one data row.")
    return fields, rows
# /example:read


def outcome(status, error="", http_status="", **values):
    return {**dict.fromkeys(COLUMNS, ""), "parse_status": status,
            "parse_error": error, "parse_http_status": http_status, **values}


def write_rows(stream, fields, rows):
    writer = csv.DictWriter(stream, fieldnames=fields + COLUMNS)
    writer.writeheader()
    writer.writerows(rows)

def iso_day(value):
    if not isinstance(value, str) or not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value):
        raise ValueError("Use an ISO calendar date.")
    return CalendarDate.fromisoformat(value)


# example:amount
def expense(row):
    amount, base, day = row["amount"].strip(), row["currency"].strip().upper(), row["date"].strip()
    if not amount or not base or not day:
        return None, "missing_input"
    # Decimal point only, with up to 15 whole digits and 6 decimal places.
    if not re.fullmatch(r"[+-]?[0-9]{1,15}(?:\.[0-9]{1,6})?", amount):
        return None, "review_amount"
    if not re.fullmatch(r"[A-Z]{3}", base):
        return None, "review_currency"
    try:
        date = iso_day(day)
    except ValueError:
        return None, "review_date"
    if date > datetime.now(timezone.utc).date():
        return None, "future_date"
    return (Decimal(amount), base, day), None
# /example:amount


def quote_digits(data, quote):
    if (not isinstance(data, dict) or data.get("currency") != quote
            or type(data.get("digits")) is not int or not 0 <= data["digits"] <= 4):
        raise ValueError("unexpected_currency_response")
    return data["digits"]


def rate_answer(data, base, quote, day):
    if (not isinstance(data, dict) or data.get("base") != base or data.get("quote") != quote
            or type(data.get("rate")) not in (int, float)):
        raise ValueError("unexpected_rate_response")
    rate = Decimal(str(data["rate"]))
    if not rate.is_finite() or rate <= 0 or iso_day(data.get("date")) > iso_day(day):
        raise ValueError("unexpected_rate_response")
    return rate, data["date"]


# example:result
def converted(amount, base, quote, rate, rate_date, digits):
    # Keep decimal amounts exact, multiply once, then round the row once.
    try:
        with localcontext() as context:
            context.prec = 50
            rounded = (amount * rate).quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_EVEN)
    except InvalidOperation:
        return outcome("error", "unexpected_response")
    if rounded == 0:
        rounded = abs(rounded)
    return outcome("converted", http_status=200, parse_base=base, parse_quote=quote,
                   parse_rate=format(rate, "f"), parse_rate_date=rate_date,
                   parse_converted=format(rounded, "f"), parse_digits=digits)
# /example:result


def enrich(rows, parse, quote="USD"):
    quote = quote.strip().upper()
    if not re.fullmatch(r"[A-Z]{3}", quote):
        raise ValueError("Use a three-letter quote currency.")
    results, cache, stopped = [], {}, False
    digits, metadata_error = None, None
    for row in rows:
        values, issue = expense(row)
        if issue:
            results.append({**row, **outcome(issue)})
            continue
        amount, base, day = values
        identity = (base, quote, day)
        if identity in cache:
            answer = cache[identity]
        elif stopped:
            answer = outcome("not_attempted")
        elif metadata_error is not None:
            answer = metadata_error
        else:
            try:
                # example:request
                if digits is None:
                    digits = quote_digits(parse.currency(quote), quote)
                # The amount stays in Decimal locally. The API supplies a reusable rate.
                data = parse.currency.rate(base, quote, date=day)
                answer = rate_answer(data, base, quote, day)
                # /example:request
            except ParseAPIError as error:
                status = "rate_unavailable" if error.status == 404 else "error"
                answer = outcome(status, error.code, error.status)
                stopped = error.status in (401, 403, 429)
            except (ValueError, InvalidOperation):
                answer = outcome("error", "unexpected_response")
            except Exception:
                answer = outcome("error", "request_failed")
            if digits is None:
                metadata_error = answer
            cache[identity] = answer
        if isinstance(answer, tuple):
            rate, rate_date = answer
            result = converted(amount, base, quote, rate, rate_date, digits)
        else:
            result = answer
        results.append({**row, **result})
    return results

def main():
    parser = argparse.ArgumentParser(description=DESCRIPTION)
    parser.add_argument("input", nargs="?", help="UTF-8 CSV filename, or omit to run the sample")
    parser.add_argument("--output", help="New CSV filename, or omit for standard output")
    parser.add_argument("--quote", default="USD", help="Three-letter target currency, default USD")
    args = parser.parse_args()
    try:
        if not re.fullmatch(r"[A-Za-z]{3}", args.quote.strip()):
            raise ValueError("Use a three-letter quote currency.")
        if args.output and (Path(args.output).exists() or Path(args.output).is_symlink()):
            raise ValueError("The output path already exists. Choose a new filename.")
        if args.input:
            path = Path(args.input)
            if path.stat().st_size > 65536:
                raise ValueError("Use a CSV no larger than 64 KiB.")
            with path.open(encoding="utf-8-sig", newline="") as stream:
                fields, rows = read_rows(stream)
        else:
            fields, rows = read_rows(io.StringIO(SAMPLE))
        # Reads PARSEAPI_KEY. Reuse one client without automatic retries.
        with ParseAPI(timeout=10.0, retries=0) as parse:
            results = enrich(rows, parse, args.quote)
        if args.output:
            # Exclusive creation protects against a file appearing during the lookups.
            with Path(args.output).open("x", encoding="utf-8", newline="") as stream:
                write_rows(stream, fields, results)
        else:
            write_rows(sys.stdout, fields, results)
    except (ValueError, OSError, csv.Error) as error:
        print(f"Could not complete: {error}", file=sys.stderr)
        return 1
    review = sum(row["parse_status"] != "converted" for row in results)
    print(f"{len(results)} rows written, {review} need review.", file=sys.stderr)
    return 2 if review else 0


if __name__ == "__main__":
    sys.exit(main())

Need a key? Set up this server example. For every field and parameter, see Currency API reference.

Build something else

All tutorials →