Reconcile Bank vs Internal

active statusProductivity & AutomationWasu6/18/2026
Export as Markdown

Reconcile a bank statement file against an internal records file (both Excel or CSV). Produces a single .xlsx report with 4 sheets: Summary Dashboard, Matched Transactions, Unmatched (Bank only), and Unmatched (Internal only). Trigger phrase: "reconcile" or "run reconciliation". Always read this SKILL.md and the xlsx SKILL.md before writing any code.

Triggersreconcile
0 copies0 worked0% successv1 version0 feedback

Version 1

Bank Statement vs Internal Records — Reconciliation Skill

Triggers

  • "reconcile" or "run reconciliation" → ask for 2 file inputs, then produce Excel report

Step 0 — Collect inputs (ALWAYS do this first)

Before writing any code, confirm both files are available. Check uploaded_files in context.

If fewer than 2 files are uploaded, ask the user:

"กรุณาอัปโหลด 2 ไฟล์ครับ:

  1. Bank Statement — ไฟล์ที่ได้จากธนาคาร (.xlsx หรือ .csv)
  2. Internal Records — ไฟล์บันทึกภายในของบริษัท (.xlsx หรือ .csv)"

Do not proceed until both files are present.

If exactly 2 files are uploaded, confirm which is which:

"ยืนยันก่อนนะครับ:

  • [filename_1] = Bank Statement ✓
  • [filename_2] = Internal Records ✓ ถูกต้องไหมครับ หรือสลับกัน?"

Proceed only after user confirms.


Step 1 — Read both files

Use bash_tool to read each file. Always read /mnt/skills/public/xlsx/SKILL.md first.

# Detect file type and read accordingly
python3 << 'EOF'
import pandas as pd, sys, os

bank_path = "/mnt/user-data/uploads/<bank_filename>"
internal_path = "/mnt/user-data/uploads/<internal_filename>"

def read_file(path):
    ext = os.path.splitext(path)[1].lower()
    if ext == ".csv":
        return pd.read_csv(path)
    else:
        return pd.read_excel(path)

df_bank = read_file(bank_path)
df_internal = read_file(internal_path)

print("=== BANK COLUMNS ===")
print(df_bank.columns.tolist())
print(df_bank.dtypes)
print(df_bank.head(3).to_string())

print("\n=== INTERNAL COLUMNS ===")
print(df_internal.columns.tolist())
print(df_internal.dtypes)
print(df_internal.head(3).to_string())
EOF

After reading, identify and map these key columns in each file:

Logical fieldBank column (detected)Internal column (detected)
Transaction datee.g. วันที่ / Datee.g. date / txn_date
Reference / Doc noe.g. เลขที่ / Refe.g. ref_no / doc_no
Descriptione.g. รายการ / Desce.g. description
Amounte.g. จำนวนเงินe.g. amount
Debit / Credite.g. ถอน / ฝากe.g. debit / credit

If columns are ambiguous, print a mapping summary and ask the user to confirm before continuing.


Step 2 — Clean & normalise both dataframes

# Standardise column names to lowercase snake_case
# Parse dates → datetime, coerce errors
# Strip whitespace from string columns
# Round amounts to 2 decimal places
# Unify debit/credit into a single signed `amount` column:
#   credit = positive, debit = negative
# Drop fully empty rows
# Create match_key = str(date.date()) + "|" + str(round(abs(amount), 2))
#   (used for fuzzy matching — date + amount, ignoring description)

Step 3 — Run reconciliation logic (Python)

Match strategy (in order)

  1. Exact match — same match_key (date + amount) AND same reference/doc number → MATCHED
  2. Amount+Date match — same match_key but different/missing reference → MATCHED (no ref)
  3. Amount match ±1 day — amount matches, date differs by ≤ 1 calendar day → NEAR MATCH
  4. Unmatched bank — bank rows with no match in internal → BANK ONLY
  5. Unmatched internal — internal rows with no match in bank → INTERNAL ONLY
# Use pandas merge for exact + amount+date matches
# For ±1 day near matches: iterate unmatched rows, check if amount exists
#   in opposite dataframe within date ± 1 day window
# Track which rows are consumed — no double-matching
# Assign match_status column to every row

Computed summary KPIs

After matching, compute:

KPIFormula
bank_totalSUM of all bank amounts (absolute)
internal_totalSUM of all internal amounts (absolute)
differencebank_total − internal_total
matched_countrows with MATCHED or MATCHED (no ref)
matched_amountSUM of matched bank amounts
near_match_countrows with NEAR MATCH
unmatched_bank_countBANK ONLY rows
unmatched_bank_amountSUM of BANK ONLY amounts
unmatched_internal_countINTERNAL ONLY rows
unmatched_internal_amountSUM of INTERNAL ONLY amounts
match_ratematched_count / total_bank_rows

Step 4 — Build Excel report with openpyxl

Font: Arial 10. Do NOT use pandas to_excel (no formatting control).

General formatting rules

  • Row 1 = header: bold, white text (FFFFFF), dark blue fill (1F3864), center-aligned, frozen
  • Data rows: alternating white / light gray (F2F2F2)
  • Auto-fit column widths (min 10, max 55)
  • Currency columns: #,##0.00
  • Date columns: YYYY-MM-DD
  • Percentage columns: 0.00%
  • Thai text: UTF-8 — openpyxl handles natively

Sheet 1 — "Summary" (Dashboard)

Layout: key-value table in columns A–B, no tabular data.

Section A — File info

LabelValue
Bank File<filename>
Internal File<filename>
Report Generatedtoday's date
Date Range (Bank)min–max date in bank file
Date Range (Internal)min–max date in internal

Section B — Reconciliation result

LabelValueFormat
Bank Totalbank_total#,##0.00
Internal Totalinternal_total#,##0.00
Differencedifference#,##0.00 — red if ≠ 0, green if 0
Total Bank Rowstotal bank rows#,##0
Total Internal Rowstotal int rows#,##0

Section C — Match breakdown

LabelCountAmount
✅ Matchedmatched_countmatched_amount
⚠️ Near Match (±1 day)near_match_countnear_match_amount
❌ Bank Onlyunmatched_bank_countunmatched_bank_amount
❌ Internal Onlyunmatched_internal_countunmatched_internal_amount
Match Ratematch_rate (0.00% format)
  • Section headers: bold, 1F3864 fill, white text
  • "Difference" value cell: green fill EAF3DE if 0, red fill FCEBEB if non-zero
  • Match Rate: green if ≥ 95%, amber if 80–94%, red if < 80%

Sheet 2 — "Matched"

All rows with match_status IN (MATCHED, MATCHED (no ref), NEAR MATCH).

Columns: match_status | bank_date | bank_ref | bank_description | bank_amount | internal_date | internal_ref | internal_description | internal_amount | amount_diff

  • amount_diff = bank_amount − internal_amount (should be 0 for exact matches)
  • match_status cell fill:
    MATCHED          → #EAF3DE (green)
    MATCHED (no ref) → #E6F1FB (blue)
    NEAR MATCH       → #FAEEDA (amber)
    
  • Sort: bank_date ASC, then bank_amount DESC

Sheet 3 — "Bank Only (Unmatched)"

All bank rows with match_status = 'BANK ONLY'.

Columns: bank_date | bank_ref | bank_description | bank_amount | remark

  • All rows: light red fill FCEBEB
  • remark column: pre-fill with "ไม่พบในระบบภายใน" for every row
  • Last row: Total row — bold, FAEEDA fill, SUM of bank_amount
  • Sort: bank_date ASC

Sheet 4 — "Internal Only (Unmatched)"

All internal rows with match_status = 'INTERNAL ONLY'.

Columns: internal_date | internal_ref | internal_description | internal_amount | remark

  • All rows: light red fill FCEBEB
  • remark column: pre-fill with "ไม่พบใน Bank Statement" for every row
  • Last row: Total row — bold, FAEEDA fill, SUM of internal_amount
  • Sort: internal_date ASC

Step 5 — File naming & output

Save to:

/mnt/user-data/outputs/Reconcile_Bank_vs_Internal_YYYYMMDD.xlsx

where YYYYMMDD = today's date (datetime.date.today().strftime('%Y%m%d')).

Then call present_files with the output path.

After presenting, print a plain-text summary in chat:

✅ Reconciliation complete

Bank Total      : ฿ X,XXX,XXX.XX
Internal Total  : ฿ X,XXX,XXX.XX
Difference      : ฿ X,XXX.XX  ← 0 = balanced

Matched         : XX rows  (XX.X%)
Near Match      : XX rows
Bank Only       : XX rows  ← requires follow-up
Internal Only   : XX rows  ← requires follow-up

Edge cases to handle

SituationHandling
One file has header on row 2+Detect by checking if row 1 is all strings with no numbers; skip rows accordingly
Amount column has currency symbols (฿, $, ,)Strip with str.replace before casting to float
Date format is DD/MM/YYYY or MM/DD/YYYYTry multiple formats with pd.to_datetime(dayfirst=True)
Debit/Credit in separate columnsCombine: amount = credit − debit
Duplicate rows in either fileFlag duplicates in a duplicate_flag column; still attempt matching
Empty file or < 2 rows of dataAbort and tell user the file appears empty

KPI reference (for context only)

KPITarget
Match Rate≥ 95%
Difference= 0 (fully balanced)
Bank Only= 0 rows
Internal Only= 0 rows

Key notes

  • Match key = date + amount (not description — descriptions often differ between bank and internal)
  • Near match window = ±1 calendar day (accounts for value-date vs posting-date differences)
  • All amounts normalised to signed float before matching (positive = inflow, negative = outflow)
  • No row is matched more than once (first-match-wins to avoid double-counting)