How-to
Automating Reporting WorkflowsDeep dive

Consolidate Employee Expense Claims into a Report

Turn a folder of employee expense claim forms into one checked report with pandas and openpyxl: read header cells and the claim table from each form, convert foreign-currency lines, apply policy rules such as category limits, missing receipts and duplicate claims, summarise by employee and cost centre, and write a workbook with an exceptions sheet for the approver.

Each month, every employee who travelled fills in the expense claim template — name, employee number and cost centre at the top, a table of receipts below — and emails it to finance. Someone then opens thirty workbooks, copies the lines into a master sheet, converts the euro and dollar receipts, checks each line against the expenses policy, and totals it all up for payroll. It is slow, it is where copy-paste errors creep in, and the policy checks are the first thing skipped when the deadline is close. A Python script can do all of it in seconds: read every form, combine the lines, convert currencies, apply the policy rules consistently, and produce a report with the totals to reimburse and a list of exceptions for the approver. It belongs to Building Finance and Operations Reports.

Prerequisites

Bash
pip install pandas openpyxl

The claims/ folder holds one form per employee in the company template: the claimant's name, employee ID and cost centre in cells B2–B4, and from row 7 a table with date, category, description, amount, currency and whether a receipt is attached (Y/N). The policy used below is an example; replace its numbers with your own.

Step 1 — Read every form: header cells and the claim table

The form mixes two shapes: single values in fixed cells at the top and a table underneath. Read the header with openpyxl and the table with pandas, then attach the header values to every line:

Python
from pathlib import Path
import pandas as pd
from openpyxl import load_workbook

HEADER_CELLS = {"Employee": "B2", "Employee ID": "B3", "Cost centre": "B4"}

def read_claim(path):
    wb = load_workbook(path, read_only=True, data_only=True)
    ws = wb["Claim"]
    header = {k: ws[cell].value for k, cell in HEADER_CELLS.items()}
    wb.close()
    lines = pd.read_excel(path, sheet_name="Claim", header=6, dtype={"Receipt": str})
    lines = lines.dropna(how="all")
    return lines.assign(**header, Source=path.name)

claims = pd.concat([read_claim(p) for p in sorted(Path("claims").glob("*.xlsx"))], ignore_index=True)
claims["Date"] = pd.to_datetime(claims["Date"])
print(len(claims), "lines from", claims["Source"].nunique(), "forms")
print(claims[["Employee", "Date", "Category", "Amount", "Currency"]].head(3).to_string(index=False))

header=6 tells pandas the column headings are on row 7 (counted from zero), skipping the header block above. dropna(how="all") removes blank rows at the end of a form, which templates often have. Keeping the source file name on each line means every exception in the report can be traced back to the form it came from. If some forms might be on an older template, check the heading row matches the expected columns before reading — Find the Header Row in an Excel Sheet with a Shifting Layout covers locating headers dynamically.

One claim form becomes rows in the combined table The form has the claimant's name, employee ID and cost centre in cells B2 to B4 and a table of receipts from row 7. Each table row becomes a row in the combined table with the header values repeated and the source file name added. A B 1 Cell Value 2 B2 Name B. Okafor 3 B4 Centre CC-310 4 Travel 212 EUR 5 Hotel 310 EUR Employee Centre Amount B. Okafor CC-310 212 B. Okafor CC-310 310 claim form combined lines header cells + read_excel(header=6)

Step 2 — Convert to the reporting currency

Claims in euros and dollars are reimbursed in pounds at a rate set by finance for the month. Keep the rates in a small table and convert with a merge, so a missing rate is caught rather than silently producing NaN:

Python
rates = pd.DataFrame({"Currency": ["GBP", "EUR", "USD"], "Rate to GBP": [1.0, 0.86, 0.75]})
claims = claims.merge(rates, on="Currency", how="left", validate="many_to_one")
missing = claims.loc[claims["Rate to GBP"].isna(), "Currency"].unique()
if len(missing):
    raise ValueError(f"no rate for {list(missing)}")
claims["GBP"] = (claims["Amount"] * claims["Rate to GBP"]).round(2)
print(claims.groupby("Currency")["GBP"].sum().round(2).to_dict())

validate="many_to_one" makes pandas raise an error if the rate table accidentally lists a currency twice, which would otherwise duplicate claim lines. Rounding each converted line to pennies before totalling matches how the payment will be made. For daily or transaction-date rates rather than one rate per month, use an as-of merge as in Convert Multi-Currency Transactions with an FX Rate Table.

Step 3 — Apply the expenses policy

The policy rules become vectorised checks, each producing a readable reason. Lines can fail several rules, and every reason is kept:

Python
POLICY = {
    "limits": {"Meals": 75.00, "Hotel": 180.00, "Taxi": 60.00},   # per line, GBP
    "receipt_over": 25.00,
    "allowed": {"Travel", "Meals", "Hotel", "Taxi"},
}

checks = {
    "category not allowed": ~claims["Category"].isin(POLICY["allowed"]),
    "over category limit": claims["GBP"] > claims["Category"].map(POLICY["limits"]).fillna(float("inf")),
    "receipt missing": (claims["Receipt"].str.upper() != "Y") & (claims["GBP"] > POLICY["receipt_over"]),
    "possible duplicate": claims.duplicated(["Employee ID", "Date", "Category", "Amount", "Currency"], keep=False),
    "weekend": claims["Date"].dt.dayofweek >= 5,
}
claims["Issues"] = [
    "; ".join(name for name, mask in checks.items() if mask.iloc[i]) for i in range(len(claims))
]
flagged = claims[claims["Issues"] != ""]
print(flagged[["Employee", "Date", "Category", "GBP", "Issues"]].to_string(index=False))

The rules are deliberately simple and transparent, and the run shows what each one catches: Ana's £142 client dinner is over the meal limit, receipts are missing for Ben's dinner and Chloé's mileage, Chloé's online course appears twice and is not an allowed category, and two meals fall on Saturdays. Ben's Lyon hotel is flagged too — at £266.60 it is over the £180 limit, but it covers two nights, so it is well within a nightly limit. That is a rule-design finding rather than a policy breach: the form should record the number of nights so the check can be per night. A flag is not a rejection — the weekend lunch may be perfectly legitimate — but it routes the line to a human instead of straight to payroll. Keeping the policy as data in one dictionary makes the annual policy update a one-line change, and the same table can be printed into the report so everyone sees which rules were applied.

Policy checks and what they caught Five checks run on every line. The category limits caught Ana Ruiz's client dinner and Ben Okafor's two-night hotel, the receipt rule caught Ben Okafor's dinner and Chloé Martin's mileage, the duplicate check caught Chloé's online course listed twice, the category rule caught software, and the weekend rule caught the Saturday lines. rule caught over category limit Meals £75, Hotel £180 dinner £142, 2-night hotel receipt missing over £25 without receipt dinner, mileage possible duplicate same day, category, amount online course × 2 category not allowed Travel, Meals, Hotel, Taxi software weekend Saturday or Sunday Sat 19 Sep, Sat 26 Sep

Step 4 — Summarise for approval and payment

Approvers want a line per employee; finance wants the cost by cost centre and category. Lines with issues are held back from the amount to pay until someone approves them:

Python
claims["Status"] = claims["Issues"].map(lambda s: "Review" if s else "OK")
by_employee = (claims.pivot_table(index=["Employee ID", "Employee", "Cost centre"], columns="Status",
                                  values="GBP", aggfunc="sum", fill_value=0)
                     .reindex(columns=["OK", "Review"], fill_value=0)
                     .rename(columns={"OK": "Payable now", "Review": "Held for review"})
                     .reset_index())
by_cc = claims.pivot_table(index="Cost centre", columns="Category", values="GBP", aggfunc="sum",
                           fill_value=0, margins=True, margins_name="Total")
print(by_employee.to_string(index=False))
print(by_cc.round(2).to_string())

Each employee's total is split between what can be paid now and what is held. The cost-centre table, with margins=True adding row and column totals, is what the budget owners see. Both are computed from the same converted lines, so they add up to the same grand total — a check worth asserting in the script.

Step 5 — Write the report

The workbook has the summary first, then the exceptions the approver must look at, then every line for reference:

Python
from openpyxl.styles import Font, PatternFill

with pd.ExcelWriter("expense_report_2026-09.xlsx", engine="openpyxl") as xw:
    by_employee.to_excel(xw, sheet_name="By employee", index=False)
    flagged[["Employee", "Source", "Date", "Category", "Description", "Amount", "Currency", "GBP", "Issues"]] \
        .to_excel(xw, sheet_name="Exceptions", index=False)
    by_cc.round(2).to_excel(xw, sheet_name="By cost centre")
    claims.drop(columns=["Rate to GBP"]).to_excel(xw, sheet_name="All lines", index=False)
    pd.DataFrame([(k, str(v)) for k, v in POLICY.items()], columns=["Rule", "Setting"]) \
        .to_excel(xw, sheet_name="Policy", index=False)
    for ws in xw.book.worksheets:
        for cell in ws[1]:
            cell.font = Font(bold=True)
        ws.freeze_panes = "A2"
        for col in ws.columns:
            ws.column_dimensions[col[0].column_letter].width = max(12, min(40, max(len(str(c.value or "")) for c in col) + 2))
    for row in xw.book["Exceptions"].iter_rows(min_row=2):
        row[-1].fill = PatternFill("solid", fgColor="FFEB9C")

total = claims["GBP"].sum()
print(f"total claimed £{total:,.2f} = payable £{by_employee['Payable now'].sum():,.2f} + held £{by_employee['Held for review'].sum():,.2f}")

The Exceptions sheet lists each flagged line with the form it came from, the original amount and currency, and the reasons in a highlighted column — the approver's to-do list. The Policy sheet records the rules in force, so a report opened in six months still explains why a line was held. The final line proves the split adds up: what is payable now plus what is held equals everything claimed. Emailing the report to approvers is covered in Send Each Recipient Their Own Excel Report.

From claim forms to payment Claim forms are read and combined, converted to pounds, checked against the policy, split into lines payable now and lines held for review, and written to a report with summary, exceptions, all lines and the policy in force. Read forms header cells + table Convert month rates to GBP Policy checks limits, receipts, duplicates Report payable now · held · exceptions

Common pitfalls

  • Reading the table without skipping the header block. Set header= to the heading row.
  • Silent NaN from a missing exchange rate. Merge with a rate table and fail on gaps.
  • Rejecting instead of flagging. Policy checks route lines for review; people decide.
  • Hard-coded limits scattered through the code. Keep the policy in one table and print it in the report.
  • Totals that don't reconcile. Assert payable plus held equals the total claimed.

Conclusion

Consolidating expense claims is a read-combine-check-summarise job: read the header cells and claim table from every form, convert foreign-currency lines with a validated rate table, apply the expenses policy as vectorised checks that produce readable reasons, and split each employee's total into payable now and held for review. A report with a summary, an exceptions sheet, every line and the policy in force gives approvers what they need and finance an auditable record.

Frequently asked questions

How do I combine many Excel expense forms into one table? Loop over the files, read the fixed header cells with openpyxl and the claim table with pandas read_excel using the header row, add the header values and file name as columns, and concatenate the results.

How do I check expense claims against a policy in Python? Express each rule as a boolean mask on the DataFrame, such as amount above a category limit or no receipt above a threshold, and join the names of the failing rules into an issues column.

How do I find duplicate expense claims? Use duplicated on employee, date, category, amount and currency with keep=False, which marks every line in a group of identical claims.

How do I convert expense claims to one currency? Merge a rate table on the currency column with validate many_to_one, raise an error if any rate is missing, and multiply the amount by the rate, rounding each line.

More on report layouts & templates

More on report layouts & templates →