statement_reconciler: Add initial support for Citizens Bank

This commit is contained in:
Ben Sturmfels 2025-08-15 20:05:56 +10:00
parent e2f90d86bb
commit e21142f0ba
Signed by: bsturmfels
GPG key ID: 023C05E2C9C068F0
3 changed files with 52 additions and 1 deletions

View file

@ -305,6 +305,39 @@ def read_chase_csv(f: TextIO) -> list:
)
def validate_citizens_csv(sample: str) -> None:
required_cols = {'Effective Date', 'Amount', 'Account Number', 'Transaction Description'}
reader = csv.DictReader(io.StringIO(sample))
if reader.fieldnames and not required_cols.issubset(reader.fieldnames):
sys.exit(
f"This Citizens CSV doesn't seem to have the columns we're expecting, including: {', '.join(required_cols)}. Please use an unmodified statement direct from the institution."
)
def standardize_citizens_record(row: Dict, line: int) -> Dict:
"""Turn an Chase CSV row into a standard dict format representing a transaction."""
return {
'date': datetime.datetime.strptime(row['Effective Date'], '%m/%d/%Y').date(),
'amount': parse_amount(row['Amount']),
'payee': ('{originator} {beneficiary}'.format(
originator=row['Originator Name'].strip(),
beneficiary=row['Beneficiary Name'].strip(),
) or '')[:50],
'check_id': '',
'line': line,
}
def read_citizens_csv(f: TextIO) -> list:
reader = csv.reader(f)
headers = next(reader)
# CSV duplicates the "Effective Date" field, so we can't use DictReader.
headers[21] = 'Effective Date 2'
reader = (dict(zip(headers, row)) for row in csv.reader(f))
# The reader.line_num is the source line number, not the spreadsheet row
# number due to multi-line records.
return sort_records(
[standardize_citizens_record(row, i) for i, row in enumerate(reader, 2)]
)
def standardize_beancount_record(row) -> Dict: # type: ignore[no-untyped-def]
"""Turn a Beancount query result row into a standard dict representing a transaction."""
return {
@ -826,6 +859,9 @@ def main(
elif 'Chase' in args.account:
validate_csv = validate_chase_csv
read_csv = read_chase_csv
elif 'Citizens' in args.account:
validate_csv = validate_citizens_csv
read_csv = read_citizens_csv
else:
sys.exit("This account provided doesn't match one of AMEX, FR or Chase.")

View file

@ -1,6 +1,6 @@
[metadata]
name = conservancy_beancount
version = 1.21.0
version = 1.22.0
author = Software Freedom Conservancy
author_email = info@sfconservancy.org
description = Plugin, library, and reports for reading Conservancys books

View file

@ -17,6 +17,7 @@ from conservancy_beancount.reconcile.statement_reconciler import (
payee_match,
read_amex_csv,
read_fr_csv,
read_citizens_csv,
remove_duplicate_words,
remove_payee_junk,
round_to_month,
@ -421,6 +422,20 @@ def test_handles_fr_csv():
assert read_fr_csv(io.StringIO(CSV)) == expected
def test_handles_citizens_csv():
CSV = """Effective Date,Bank ID,Account Number,Account Name,Transaction Description,Status,Debit/Credit,Amount,Bank Reference,Customer Reference,Transaction Detail,BAI Code,Type,Additional Information,Currency,Image,Advice Reference,Transaction Number,Originator Id,Originator Name,Company Entry Description,Effective Date,Related Reference,Value Date of Payment,Transaction Reference,SEC Type,Settlement Reference,Payer Account Name,Payer Account,Payer Address 1,Payer Address 2,Payer Address 3,Payer Address 4,Payer Bank Code,Payer Bank Name,Payer Bank Address 1,Payer Bank Address 2,Payer Bank Address 3,Sender Bank Id,Sender Bank,Sender Address 1,Sender Address 2,Sender Address 3,Sender Address 4,Beneficiary Name,Beneficiary Account,Beneficiary Address 1,Beneficiary Address 2,Beneficiary Address 3,Beneficiary Address 4,Beneficiary Bank Code,Beneficiary Bank Name,Beneficiary Bank Address 1,Beneficiary Bank Address 2,Beneficiary Bank Address 3,Reference For Beneficiary,Foreign Amount,Foreign Currency,Exchange Rate,Payment Details 1,Payment Details 2,Payment Details 3,Payment Details 4,Payment Details 5,Payment Details 6,Bank Reference,Bank Reference 2,Bank Reference 3,Bank Reference 4,Bank Reference 5,Bank Reference 6,Receiving Bank Id,Receiving Bank,Receiving Bank Address 1,Receiving Bank Address 2,Receiving Bank Address 3,Receiving Bank Address 4,Intermediary Bank Id,Intermediary Bank Name,Intermediary Bank Address 1,Intermediary Bank Address 2,Intermediary Bank Address 3,Intermediary Bank Address 4,Ordering Bank,Ordering Bank Address 1,Ordering Bank Address 2,Ordering Bank Address 3,Ordering Bank Address 4,Instructing Bank,Terminal Location,Terminal City,Terminal State,TIME\n05/30/2025,021313103,4030961273,SOFTWARE FREEDOM CONSERVANCY INC,PREAUTHORIZED ACH DEBIT,Cleared,Debit,-275.64,,,SEC : CCD ORIG NAME : PAYCHEX EIB CO. ENTRY DESC: INVOICE RECIP NAME: SOFTWARE FREEDOM CONSE INDIVIDUAL ID: X12201000015079 EFFECTIVE DATE: 250530 CO DESCRIPTION DATE: 250530 PAR#: 025149005728064 ADVICEREF: 025149005728064 ,455,ACH,View Details,USD,, 025149005728064, 025149005728064,, PAYCHEX EIB , INVOICE , 250530 ,,,, CCD ,,,,,,,,,,,,,,,,,,, SOFTWARE FREEDOM CONSE ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,\n"""
expected = [
{
'date': datetime.date(2025, 5, 30),
'amount': decimal.Decimal('-275.64'),
'payee': 'PAYCHEX EIB SOFTWARE FREEDOM CONSE',
'check_id': '',
'line': 2,
},
]
assert read_citizens_csv(io.StringIO(CSV)) == expected
def test_format_output():
statement = [S1]
books = [B1]