Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .~lock.company_data.xlsx#
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
,EC2AMAZ-JB34ON8/NalluriP,EC2AMAZ-JB34ON8,05.11.2025 12:44,file:///C:/Users/NalluriP/AppData/Roaming/LibreOffice/4;
Binary file modified company_data.xlsx
Binary file not shown.
2 changes: 2 additions & 0 deletions debug.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[1015/134334.871:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: The system cannot find the file specified. (0x2)
[1015/134338.675:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: The system cannot find the file specified. (0x2)
54 changes: 54 additions & 0 deletions invoice_sync_report.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"status": "success",
"generated_at": "2025-10-27T16:43:17+00:00",
"added_invoices": [],
"updated_invoices": [
{
"record_id": "5555",
"name": "23-0501",
"source": "excel"
},
{
"record_id": "2",
"name": "2",
"source": "excel"
},
{
"record_id": "1",
"name": "1",
"source": "excel"
},
{
"record_id": "7555",
"name": "23-0501",
"source": "excel"
}
],
"conflicts": [
{
"record_id": "5555",
"excel_name": "23-0501",
"qb_name": "23-0501",
"reason": "data_mismatch"
},
{
"record_id": "2",
"excel_name": "2",
"qb_name": "2",
"reason": "data_mismatch"
},
{
"record_id": "1",
"excel_name": "1",
"qb_name": "1",
"reason": "data_mismatch"
},
{
"record_id": "7555",
"excel_name": "23-0501",
"qb_name": "23-0501",
"reason": "data_mismatch"
}
],
"error": null
}
Binary file added payment_terms_dummy
Binary file not shown.
762 changes: 762 additions & 0 deletions poetry.lock

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions src/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# """QuickBooks Invoice Connector package.

# This package provides tools to:
# - Read company Excel data (account credit vendor invoices)
# - Read and sync data with QuickBooks Desktop via COM
# - Compare Excel vs QuickBooks invoice data
# - Generate JSON reports summarizing sync actions
# """

# from .runner import run_invoice_sync

# __all__ = ["run_invoice_sync"]


"""QuickBooks Invoice Connector package."""

# from .runner import run_invoice_sync # <-- comment out for now

__all__ = []
3 changes: 3 additions & 0 deletions src/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from src.cli import main
if __name__ == "__main__":
main()
31 changes: 31 additions & 0 deletions src/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# stdlib only CLI
from __future__ import annotations
import argparse
from pathlib import Path
from src.runner import run_invoice_sync

def main():
p = argparse.ArgumentParser(
prog="qb-invoice-sync",
description="Sync company Excel invoices with QuickBooks and write a JSON report."
)
p.add_argument("--excel", required=True, help="Path to company .xlsx file")
p.add_argument("--sheet", default="account credit vendor",
help="Worksheet name to use (default: 'account credit vendor')")
p.add_argument("--company", default="", help="QuickBooks company file path (leave blank to use open file)")
p.add_argument("--report", default="invoice_sync_report.json", help="Output JSON report path")
p.add_argument("--dry-run", action="store_true",
help="Compare only: do NOT add Excel-only invoices to QuickBooks")
args = p.parse_args()

report_path = run_invoice_sync(
excel_path=args.excel,
sheet_name=args.sheet,
company_file=(args.company or None),
report_path=args.report,
dry_run=args.dry_run,
)
print(f"Report: {Path(report_path).resolve()}")

if __name__ == "__main__":
main()
102 changes: 102 additions & 0 deletions src/comparer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Comparison helpers for invoices.

This module compares invoice data from Excel and QuickBooks, identifying
which invoices are identical, missing in one source, or have mismatched data.
"""

from __future__ import annotations
from typing import Dict, List
from src.models import Invoice, Conflict, ComparisonReport


def compare_invoices(
excel_invoices: List[Invoice],
qb_invoices: List[Invoice],
) -> ComparisonReport:
"""Compare Excel vs QuickBooks invoices and detect discrepancies.

This function reconciles two datasets — one from Excel, one from QuickBooks —
by comparing their record identifiers and key fields such as invoice number,
amount, and date.

The result categorizes invoices as:
- same_same: identical in both sources
- excel_only: exists only in Excel
- qb_only: exists only in QuickBooks
- conflicts: same ID, but differing data values

**Input Parameters:**
:param excel_invoices: List of Invoice objects read from Excel
:param qb_invoices: List of Invoice objects read from QuickBooks

**Return Value:**
:return: ComparisonReport containing categorized comparison results.
"""

report = ComparisonReport()

# Create fast lookup maps by record_id (Excel Child ID ↔ QB Memo)
excel_by_id: Dict[str, Invoice] = {inv.record_id: inv for inv in excel_invoices}
qb_by_id: Dict[str, Invoice] = {inv.record_id: inv for inv in qb_invoices}

# Combine all record IDs from both sources
all_ids = set(excel_by_id.keys()) | set(qb_by_id.keys())

for rid in all_ids:
e_inv = excel_by_id.get(rid)
q_inv = qb_by_id.get(rid)

# Case 1: Exists in both
if e_inv and q_inv:
same_number = e_inv.invoice_number == q_inv.invoice_number
same_date = e_inv.invoice_date == q_inv.invoice_date
same_amount = abs(e_inv.invoice_amount - q_inv.invoice_amount) < 0.01

if same_number and same_date and same_amount:
report.same_same.append(e_inv)
else:
report.conflicts.append(
Conflict(
record_id=rid,
excel_data=e_inv,
qb_data=q_inv,
reason="data_mismatch",
)
)

# Case 2: Exists only in Excel
elif e_inv and not q_inv:
report.excel_only.append(e_inv)

# Case 3: Exists only in QuickBooks
elif q_inv and not e_inv:
report.qb_only.append(q_inv)

return report


__all__ = ["compare_invoices"]


if __name__ == "__main__":
from datetime import date

# Manual test data
excel_data = [
Invoice("A1", "Test6", "INV001", date(2024, 1, 1), 100.0, "excel"),
Invoice("A2", "Test7", "INV002", date(2024, 1, 2), 200.0, "excel"),
]

qb_data = [
Invoice("A1", "Test8", "INV001", date(2024, 1, 1), 100.0, "quickbooks"),
Invoice("A3", "Test9", "INV003", date(2024, 1, 3), 300.0, "quickbooks"),
]

report = compare_invoices(excel_data, qb_data)

print(
f"Same: {len(report.same_same)} | "
f"Excel-only: {len(report.excel_only)} | "
f"QB-only: {len(report.qb_only)} | "
f"Conflicts: {len(report.conflicts)}"
)
101 changes: 101 additions & 0 deletions src/excel_reader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Excel reader for extracting invoice data using openpyxl."""

from __future__ import annotations
from pathlib import Path
from datetime import datetime
from typing import List
from openpyxl import load_workbook
from src.models import Invoice


def extract_invoices(workbook_path: Path, sheet_name: str = "account credit vendor") -> List[Invoice]:
"""Extract unique invoice data from the given Excel workbook and sheet.

Args:
workbook_path: Path to the Excel file.
sheet_name: Worksheet name to read from.

Returns:
List of unique Invoice objects loaded from Excel.
"""
workbook_path = Path(workbook_path)
if not workbook_path.exists():
raise FileNotFoundError(f"Workbook not found: {workbook_path}")

workbook = load_workbook(filename=workbook_path, read_only=True, data_only=True)
try:
if sheet_name not in workbook.sheetnames:
raise ValueError(f"Worksheet '{sheet_name}' not found. Available sheets: {workbook.sheetnames}")

sheet = workbook[sheet_name]
rows = sheet.iter_rows(values_only=True)

headers = [str(h).strip() if h else "" for h in next(rows, [])]
header_index = {name: i for i, name in enumerate(headers)}

invoices: List[Invoice] = []
seen_numbers = set() # Added — to track duplicates

for row in rows:
if not any(row):
continue # Skip empty rows

# Helper to safely access cell by column name
def _value(column_name: str):
idx = header_index.get(column_name)
if idx is None or idx >= len(row):
return None
return row[idx]

record_id = str(_value("Child ID") or "").strip()
customer = str(_value("Customer") or "").strip()
invoice_number = str(_value("Invoice Number") or _value("Invoice Num") or "").strip()
invoice_date = _value("Invoice Date")
if isinstance(invoice_date, datetime):
invoice_date = invoice_date.date()

try:
invoice_amount = float(_value("Invoice Amount") or 0)
except (TypeError, ValueError):
invoice_amount = 0.0

# Skip incomplete rows
if not invoice_number or not customer:
continue

# Skip duplicates by invoice number
if invoice_number in seen_numbers:
continue
seen_numbers.add(invoice_number)

invoices.append(
Invoice(
record_id=record_id,
customer=customer,
invoice_number=invoice_number,
invoice_date=invoice_date,
invoice_amount=invoice_amount,
source="excel",
)
)
finally:
workbook.close()

return invoices


__all__ = ["extract_invoices"]


if __name__ == "__main__": # Manual run support
import sys

try:
excel_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("company_data.xlsx")
invoices = extract_invoices(excel_path)
for inv in invoices:
print(inv)
except Exception as e:
print(f"Error: {e}")
print("Usage: python -m src.excel_reader <path-to-workbook.xlsx>")
sys.exit(1)
59 changes: 59 additions & 0 deletions src/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Domain models for invoice synchronisation between Excel and QuickBooks."""

from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal, Optional
from datetime import date

# Define literal types for clarity
SourceLiteral = Literal["excel", "quickbooks"]
ConflictReason = Literal["data_mismatch", "missing_in_excel", "missing_in_quickbooks"]


@dataclass(slots=True)
class Invoice:
"""Represents a single invoice from either Excel or QuickBooks."""

record_id: str # Excel "Child ID" ↔ QuickBooks "Memo"
customer: str # Customer name
invoice_number: str # Invoice reference number
invoice_date: date # Invoice date
invoice_amount: float # Invoice total amount
source: SourceLiteral # Origin of this record ("excel" or "quickbooks")

def __str__(self) -> str:
"""Readable string representation of this invoice."""
return (
f"Invoice(id={self.record_id}, customer={self.customer}, "
f"number={self.invoice_number}, date={self.invoice_date}, "
f"amount={self.invoice_amount}, source={self.source})"
)


@dataclass(slots=True)
class Conflict:
"""Describes a mismatch or missing invoice between Excel and QuickBooks."""

record_id: str
excel_data: Optional[Invoice]
qb_data: Optional[Invoice]
reason: ConflictReason


@dataclass(slots=True)
class ComparisonReport:
"""Groups comparison results between Excel and QuickBooks."""

same_same: list[Invoice] = field(default_factory=list)
excel_only: list[Invoice] = field(default_factory=list)
qb_only: list[Invoice] = field(default_factory=list)
conflicts: list[Conflict] = field(default_factory=list)


__all__ = [
"Invoice",
"Conflict",
"ComparisonReport",
"ConflictReason",
"SourceLiteral",
]
Loading
Loading