reconcile: Start module with PayPal reconciliation report.
This commit is contained in:
parent
91cbbc9159
commit
eba4ed45b0
3 changed files with 417 additions and 1 deletions
0
conservancy_beancount/reconcile/__init__.py
Normal file
0
conservancy_beancount/reconcile/__init__.py
Normal file
414
conservancy_beancount/reconcile/paypal.py
Normal file
414
conservancy_beancount/reconcile/paypal.py
Normal file
|
@ -0,0 +1,414 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""paypal.py - Reconcile PayPal account from CSV statement"""
|
||||||
|
# Copyright © 2021 Brett Smith
|
||||||
|
# License: AGPLv3-or-later WITH Beancount-Plugin-Additional-Permission-1.0
|
||||||
|
#
|
||||||
|
# Full copyright and licensing details can be found at toplevel file
|
||||||
|
# LICENSE.txt in the repository.
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import collections
|
||||||
|
import csv
|
||||||
|
import datetime
|
||||||
|
import enum
|
||||||
|
import logging
|
||||||
|
import operator
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import odf.style # type:ignore[import]
|
||||||
|
import odf.table # type:ignore[import]
|
||||||
|
|
||||||
|
from beancount.parser import printer as bc_printer
|
||||||
|
|
||||||
|
from .. import books
|
||||||
|
from .. import cliutil
|
||||||
|
from .. import config as configmod
|
||||||
|
from .. import data
|
||||||
|
from ..ranges import DateRange
|
||||||
|
from ..reports import core
|
||||||
|
|
||||||
|
from typing import (
|
||||||
|
Any,
|
||||||
|
Hashable,
|
||||||
|
Iterable,
|
||||||
|
Iterator,
|
||||||
|
List,
|
||||||
|
Mapping,
|
||||||
|
NamedTuple,
|
||||||
|
Optional,
|
||||||
|
Sequence,
|
||||||
|
TextIO,
|
||||||
|
Tuple,
|
||||||
|
Union,
|
||||||
|
)
|
||||||
|
|
||||||
|
PROGNAME = 'reconcile-paypal'
|
||||||
|
logger = logging.getLogger('conservancy_beancount.reconcile.paypal')
|
||||||
|
|
||||||
|
class ReconcileProblems(enum.IntFlag):
|
||||||
|
NOT_IN_STATEMENT = enum.auto()
|
||||||
|
DUP_IN_STATEMENT = enum.auto()
|
||||||
|
AMOUNT_DIFF = enum.auto()
|
||||||
|
MONTH_DIFF = enum.auto()
|
||||||
|
DATE_DIFF = enum.auto()
|
||||||
|
|
||||||
|
|
||||||
|
class PayPalPosting(NamedTuple):
|
||||||
|
filename: str
|
||||||
|
lineno: int
|
||||||
|
txn_id: str
|
||||||
|
date: datetime.date
|
||||||
|
amount: data.Amount
|
||||||
|
description: str
|
||||||
|
entity: Optional[str]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_books(cls, post: data.Posting) -> 'PayPalPosting':
|
||||||
|
return cls(
|
||||||
|
post.meta.get('filename', ''),
|
||||||
|
post.meta.get('lineno', 0),
|
||||||
|
post.meta.get('paypal-id', '<missing paypal-id>'),
|
||||||
|
post.meta.date,
|
||||||
|
post.units,
|
||||||
|
post.meta.txn.narration,
|
||||||
|
post.meta.get('entity'),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_statement(
|
||||||
|
cls,
|
||||||
|
row: Mapping[str, str],
|
||||||
|
filename: str='<statement>',
|
||||||
|
lineno: int=0,
|
||||||
|
) -> 'PayPalPosting':
|
||||||
|
row_net = Decimal(row['Net'].replace(',', ''))
|
||||||
|
return cls(
|
||||||
|
filename,
|
||||||
|
lineno,
|
||||||
|
row['Transaction ID'],
|
||||||
|
datetime.datetime.strptime(row['Date'], '%m/%d/%Y').date(),
|
||||||
|
data.Amount(row_net, row['Currency']),
|
||||||
|
row['Name'],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def iter_statement(cls, path: Path) -> Iterator['PayPalPosting']:
|
||||||
|
with path.open() as csv_file:
|
||||||
|
# PayPal's CSVs start with a ZWNJ space.
|
||||||
|
# Discard that, assuming it's still there.
|
||||||
|
orig_pos = csv_file.tell()
|
||||||
|
char1 = csv_file.read(1)
|
||||||
|
if not unicodedata.category(char1).startswith('C'):
|
||||||
|
csv_file.seek(orig_pos)
|
||||||
|
in_csv = csv.DictReader(csv_file)
|
||||||
|
filename = str(path)
|
||||||
|
for lineno, row in enumerate(in_csv, 2):
|
||||||
|
yield cls.from_statement(row, filename, lineno)
|
||||||
|
|
||||||
|
def compare(self, other: 'PayPalPosting') -> int:
|
||||||
|
retval = 0
|
||||||
|
if (self.date.year != other.date.year
|
||||||
|
or self.date.month != other.date.month):
|
||||||
|
retval |= ReconcileProblems.MONTH_DIFF
|
||||||
|
retval |= ReconcileProblems.DATE_DIFF
|
||||||
|
elif self.date.day != other.date.day:
|
||||||
|
retval |= ReconcileProblems.DATE_DIFF
|
||||||
|
return retval
|
||||||
|
|
||||||
|
|
||||||
|
class PayPalReconciler:
|
||||||
|
__slots__ = ['books_posts', 'statement_posts']
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.books_posts: List[PayPalPosting] = []
|
||||||
|
self.statement_posts: List[PayPalPosting] = []
|
||||||
|
|
||||||
|
def add_from_books(self, post: PayPalPosting) -> None:
|
||||||
|
self.books_posts.append(post)
|
||||||
|
|
||||||
|
def add_from_statement(self, post: PayPalPosting) -> None:
|
||||||
|
self.statement_posts.append(post)
|
||||||
|
|
||||||
|
def post_problems(self) -> Iterator[Tuple[PayPalPosting, int]]:
|
||||||
|
try:
|
||||||
|
statement_post = self.statement_posts[0]
|
||||||
|
except IndexError:
|
||||||
|
try:
|
||||||
|
statement_post = self.books_posts[0]
|
||||||
|
except IndexError:
|
||||||
|
return
|
||||||
|
for post in self.books_posts:
|
||||||
|
yield (post, statement_post.compare(post))
|
||||||
|
|
||||||
|
def problems(self) -> int:
|
||||||
|
stmt_count = len(self.statement_posts)
|
||||||
|
if stmt_count == 0:
|
||||||
|
return ReconcileProblems.NOT_IN_STATEMENT
|
||||||
|
elif stmt_count > 1:
|
||||||
|
return ReconcileProblems.DUP_IN_STATEMENT
|
||||||
|
else:
|
||||||
|
balance = core.Balance(post.amount for post in self.books_posts)
|
||||||
|
if not (balance - self.statement_posts[0].amount).is_zero():
|
||||||
|
return ReconcileProblems.AMOUNT_DIFF
|
||||||
|
worst_problem = 0
|
||||||
|
for _, problems in self.post_problems():
|
||||||
|
if problems & ~ReconcileProblems.DATE_DIFF:
|
||||||
|
return problems
|
||||||
|
else:
|
||||||
|
worst_problem = worst_problem or problems
|
||||||
|
return worst_problem
|
||||||
|
|
||||||
|
def sort_key(self) -> Hashable:
|
||||||
|
try:
|
||||||
|
post = self.statement_posts[0]
|
||||||
|
except IndexError:
|
||||||
|
post = self.books_posts[0]
|
||||||
|
return post.date
|
||||||
|
|
||||||
|
|
||||||
|
class PayPalReconciliationReport(core.BaseODS[PayPalPosting, str]):
|
||||||
|
COLUMN_WIDTHS = {
|
||||||
|
'Source': 1.5,
|
||||||
|
'Transaction ID': 1.5,
|
||||||
|
'Date': None,
|
||||||
|
'Amount': 1,
|
||||||
|
'Description': 2.5,
|
||||||
|
'Entity': 1.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.bad_sheet = self.start_sheet("Reconciliation")
|
||||||
|
self.good_sheet = self.start_sheet("Matched Postings")
|
||||||
|
|
||||||
|
def init_styles(self) -> None:
|
||||||
|
super().init_styles()
|
||||||
|
self.error_style = self.bgcolor_style('#ffcc99')
|
||||||
|
self.warning_style = self.bgcolor_style('#ffff00')
|
||||||
|
self.endrow_style = self.border_style(core.Border.BOTTOM, '1pt')
|
||||||
|
|
||||||
|
def start_sheet(self, name: str) -> odf.table.Table:
|
||||||
|
self.use_sheet(name)
|
||||||
|
for col_width in self.COLUMN_WIDTHS.values():
|
||||||
|
col_style = None if col_width is None else self.column_style(col_width)
|
||||||
|
self.sheet.addElement(odf.table.TableColumn(stylename=col_style))
|
||||||
|
self.add_row(*(
|
||||||
|
self.string_cell(col_name, stylename=self.style_bold)
|
||||||
|
for col_name in self.COLUMN_WIDTHS
|
||||||
|
))
|
||||||
|
self.lock_first_row()
|
||||||
|
return self.sheet
|
||||||
|
|
||||||
|
def section_key(self, row: PayPalPosting) -> str:
|
||||||
|
return row.txn_id
|
||||||
|
|
||||||
|
def write_row(
|
||||||
|
self,
|
||||||
|
row: PayPalPosting,
|
||||||
|
source_style: Union[None, str, odf.style.Style]=None,
|
||||||
|
problems: int=0,
|
||||||
|
) -> None:
|
||||||
|
source_style = self.merge_styles(source_style, self.style_endtext)
|
||||||
|
if problems & ReconcileProblems.MONTH_DIFF:
|
||||||
|
date_style: Optional[odf.style.Style] = self.error_style
|
||||||
|
elif problems & ReconcileProblems.DATE_DIFF:
|
||||||
|
date_style = self.warning_style
|
||||||
|
else:
|
||||||
|
date_style = None
|
||||||
|
date_style = self.merge_styles(date_style, self.style_date)
|
||||||
|
if problems & ReconcileProblems.AMOUNT_DIFF:
|
||||||
|
amount_style: Optional[odf.style.Style] = self.error_style
|
||||||
|
else:
|
||||||
|
amount_style = None
|
||||||
|
amount_style = self.merge_styles(
|
||||||
|
amount_style, self.currency_style(row.amount.currency),
|
||||||
|
)
|
||||||
|
self.add_row(
|
||||||
|
self.string_cell(f'{row.filename}:{row.lineno}', stylename=source_style),
|
||||||
|
self.string_cell(row.txn_id),
|
||||||
|
self.date_cell(row.date, stylename=date_style),
|
||||||
|
self.currency_cell(row.amount, stylename=amount_style),
|
||||||
|
self.string_cell(row.description),
|
||||||
|
self.string_cell(row.entity or ''),
|
||||||
|
)
|
||||||
|
|
||||||
|
def write_missing(self, source: str, txn_id: str) -> None:
|
||||||
|
self.add_row(
|
||||||
|
self.string_cell(source, stylename=self.error_style),
|
||||||
|
self.string_cell(txn_id),
|
||||||
|
odf.table.TableCell(),
|
||||||
|
odf.table.TableCell(),
|
||||||
|
self.string_cell(f"not found in {source}"),
|
||||||
|
odf.table.TableCell(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def write_all(self, source: Mapping[str, PayPalReconciler]) -> None:
|
||||||
|
for txn_id, reconciler in sorted(
|
||||||
|
source.items(), key=lambda kv: kv[1].sort_key(),
|
||||||
|
):
|
||||||
|
problems = reconciler.problems()
|
||||||
|
if problems & ~ReconcileProblems.DATE_DIFF:
|
||||||
|
self.sheet = self.bad_sheet
|
||||||
|
else:
|
||||||
|
self.sheet = self.good_sheet
|
||||||
|
posts_iter = iter(reconciler.statement_posts)
|
||||||
|
try:
|
||||||
|
statement_post = next(posts_iter)
|
||||||
|
except StopIteration:
|
||||||
|
self.write_missing('statement', txn_id)
|
||||||
|
else:
|
||||||
|
self.write_row(statement_post)
|
||||||
|
for post in posts_iter:
|
||||||
|
stmt_problems = statement_post.compare(post)
|
||||||
|
if post.amount != statement_post.amount:
|
||||||
|
stmt_problems |= ReconcileProblems.AMOUNT_DIFF
|
||||||
|
self.write_row(statement_post, self.error_style, stmt_problems)
|
||||||
|
if self.sheet is self.bad_sheet:
|
||||||
|
probs_iter = reconciler.post_problems()
|
||||||
|
try:
|
||||||
|
post, post_problems = next(probs_iter)
|
||||||
|
except StopIteration:
|
||||||
|
self.write_missing('books', txn_id)
|
||||||
|
else:
|
||||||
|
self.write_row(post, None, problems | post_problems)
|
||||||
|
for post, post_problems in probs_iter:
|
||||||
|
self.write_row(post, None, problems | post_problems)
|
||||||
|
for cell in self.sheet.lastChild.childNodes:
|
||||||
|
cell_style = self.merge_styles(
|
||||||
|
cell.getAttribute('stylename'),
|
||||||
|
self.endrow_style,
|
||||||
|
)
|
||||||
|
cell.setAttribute('stylename', cell_style)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_arguments(arglist: Optional[Sequence[str]]=None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
cliutil.add_version_argument(parser)
|
||||||
|
cliutil.add_loglevel_argument(parser)
|
||||||
|
parser.add_argument(
|
||||||
|
'--account',
|
||||||
|
default='Assets:PayPal',
|
||||||
|
help="""Name of the Beancount account with PayPal postings.
|
||||||
|
Default %(default)r.
|
||||||
|
""")
|
||||||
|
parser.add_argument(
|
||||||
|
'--begin', '--start', '-b',
|
||||||
|
dest='start_date',
|
||||||
|
metavar='DATE',
|
||||||
|
type=cliutil.date_arg,
|
||||||
|
help="""Date to start reconciliation, inclusive, in YYYY-MM-DD format.
|
||||||
|
The default is the first date in the statement.
|
||||||
|
""")
|
||||||
|
parser.add_argument(
|
||||||
|
'--end', '--stop', '-e',
|
||||||
|
dest='stop_date',
|
||||||
|
metavar='DATE',
|
||||||
|
type=cliutil.date_arg,
|
||||||
|
help="""Date to stop reconciliation, exclusive, in YYYY-MM-DD format.
|
||||||
|
The default is the last date in the statement.
|
||||||
|
""")
|
||||||
|
parser.add_argument(
|
||||||
|
'--date-fuzz',
|
||||||
|
metavar='DAYS',
|
||||||
|
type=int,
|
||||||
|
default=30,
|
||||||
|
# Loading an extra month on each end should be more than enough for
|
||||||
|
# real reconciliation. This switch is mostly a debug aid.
|
||||||
|
help=argparse.SUPPRESS,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--output-file', '-O',
|
||||||
|
metavar='PATH',
|
||||||
|
type=Path,
|
||||||
|
help="""Write the report to this file, or stdout when PATH is `-`.
|
||||||
|
The default is `PayPalReconciliation_<StartDate>_<StopDate>.ods`.
|
||||||
|
""")
|
||||||
|
parser.add_argument(
|
||||||
|
'statement',
|
||||||
|
type=Path,
|
||||||
|
help="Path to the PayPal statement CSV",
|
||||||
|
)
|
||||||
|
return parser.parse_args(arglist)
|
||||||
|
|
||||||
|
def main(arglist: Optional[Sequence[str]]=None,
|
||||||
|
stdout: TextIO=sys.stdout,
|
||||||
|
stderr: TextIO=sys.stderr,
|
||||||
|
config: Optional[configmod.Config]=None,
|
||||||
|
) -> int:
|
||||||
|
args = parse_arguments(arglist)
|
||||||
|
cliutil.set_loglevel(logger, args.loglevel)
|
||||||
|
if config is None:
|
||||||
|
config = configmod.Config()
|
||||||
|
config.load_file()
|
||||||
|
|
||||||
|
date_range = DateRange(
|
||||||
|
args.start_date or datetime.date(datetime.MINYEAR, 1, 1),
|
||||||
|
args.stop_date or datetime.date(datetime.MAXYEAR, 12, 1),
|
||||||
|
)
|
||||||
|
reconcilers: Mapping[str, PayPalReconciler] = collections.defaultdict(PayPalReconciler)
|
||||||
|
for stmt_post in PayPalPosting.iter_statement(args.statement):
|
||||||
|
if stmt_post.amount.number and stmt_post.date in date_range:
|
||||||
|
reconcilers[stmt_post.txn_id].add_from_statement(stmt_post)
|
||||||
|
if not reconcilers:
|
||||||
|
logger.warning("no posting data read from statement")
|
||||||
|
|
||||||
|
if args.start_date is None:
|
||||||
|
args.start_date = min(
|
||||||
|
post.date
|
||||||
|
for rec in reconcilers.values()
|
||||||
|
for post in rec.statement_posts
|
||||||
|
)
|
||||||
|
if args.stop_date is None:
|
||||||
|
args.stop_date = max(
|
||||||
|
post.date
|
||||||
|
for rec in reconcilers.values()
|
||||||
|
for post in rec.statement_posts
|
||||||
|
) + datetime.timedelta(days=1)
|
||||||
|
|
||||||
|
returncode = os.EX_OK
|
||||||
|
books_loader = config.books_loader()
|
||||||
|
if books_loader is None:
|
||||||
|
entries, load_errors, options = books.Loader.load_none(config.config_file_path())
|
||||||
|
returncode = cliutil.ExitCode.NoConfiguration
|
||||||
|
else:
|
||||||
|
date_fuzz = datetime.timedelta(days=args.date_fuzz)
|
||||||
|
entries, load_errors, options = books_loader.load_fy_range(
|
||||||
|
args.start_date - date_fuzz, args.stop_date + date_fuzz,
|
||||||
|
)
|
||||||
|
if load_errors:
|
||||||
|
returncode = cliutil.ExitCode.BeancountErrors
|
||||||
|
for error in load_errors:
|
||||||
|
bc_printer.print_error(error, file=stderr)
|
||||||
|
|
||||||
|
date_range = DateRange(args.start_date, args.stop_date)
|
||||||
|
for bean_post in data.Posting.from_entries(entries):
|
||||||
|
if bean_post.account != args.account:
|
||||||
|
continue
|
||||||
|
paypal_post = PayPalPosting.from_books(bean_post)
|
||||||
|
if paypal_post.txn_id in reconcilers or (
|
||||||
|
paypal_post.amount.number and paypal_post.date in date_range
|
||||||
|
):
|
||||||
|
reconcilers[paypal_post.txn_id].add_from_books(paypal_post)
|
||||||
|
|
||||||
|
report = PayPalReconciliationReport()
|
||||||
|
report.write_all(reconcilers)
|
||||||
|
if args.output_file is None:
|
||||||
|
args.output_file = Path('PayPalReconciliation_{}_{}.ods'.format(
|
||||||
|
args.start_date.isoformat(),
|
||||||
|
args.stop_date.isoformat(),
|
||||||
|
))
|
||||||
|
logger.info("Writing report to %s", args.output_file)
|
||||||
|
ods_file = cliutil.bytes_output(args.output_file, stdout)
|
||||||
|
report.save_file(ods_file)
|
||||||
|
return returncode
|
||||||
|
|
||||||
|
entry_point = cliutil.make_entry_point(__name__, PROGNAME)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
exit(entry_point())
|
4
setup.py
4
setup.py
|
@ -5,7 +5,7 @@ from setuptools import setup
|
||||||
setup(
|
setup(
|
||||||
name='conservancy_beancount',
|
name='conservancy_beancount',
|
||||||
description="Plugin, library, and reports for reading Conservancy's books",
|
description="Plugin, library, and reports for reading Conservancy's books",
|
||||||
version='1.15.5',
|
version='1.16.0',
|
||||||
author='Software Freedom Conservancy',
|
author='Software Freedom Conservancy',
|
||||||
author_email='info@sfconservancy.org',
|
author_email='info@sfconservancy.org',
|
||||||
license='GNU AGPLv3+',
|
license='GNU AGPLv3+',
|
||||||
|
@ -35,6 +35,7 @@ setup(
|
||||||
'conservancy_beancount.pdfforms',
|
'conservancy_beancount.pdfforms',
|
||||||
'conservancy_beancount.pdfforms.extract',
|
'conservancy_beancount.pdfforms.extract',
|
||||||
'conservancy_beancount.plugin',
|
'conservancy_beancount.plugin',
|
||||||
|
'conservancy_beancount.reconcile',
|
||||||
'conservancy_beancount.reports',
|
'conservancy_beancount.reports',
|
||||||
'conservancy_beancount.tools',
|
'conservancy_beancount.tools',
|
||||||
],
|
],
|
||||||
|
@ -52,6 +53,7 @@ setup(
|
||||||
'pdfform-extract = conservancy_beancount.pdfforms.extract:entry_point',
|
'pdfform-extract = conservancy_beancount.pdfforms.extract:entry_point',
|
||||||
'pdfform-extract-irs990scheduleA = conservancy_beancount.pdfforms.extract.irs990scheduleA:entry_point',
|
'pdfform-extract-irs990scheduleA = conservancy_beancount.pdfforms.extract.irs990scheduleA:entry_point',
|
||||||
'pdfform-fill = conservancy_beancount.pdfforms.fill:entry_point',
|
'pdfform-fill = conservancy_beancount.pdfforms.fill:entry_point',
|
||||||
|
'reconcile-paypal = conservancy_beancount.reconcile.paypal:entry_point',
|
||||||
'split-ods-links = conservancy_beancount.tools.split_ods_links:entry_point',
|
'split-ods-links = conservancy_beancount.tools.split_ods_links:entry_point',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in a new issue