#!/usr/bin/env python3 """ Monzo Bank Statement Generator Pixel-perfect replica with editable fields and transaction management """ import sys import os from datetime import datetime, date from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QTableWidget, QTableWidgetItem, QFileDialog, QMessageBox, QGroupBox, QDateEdit, QDateTimeEdit, QHeaderView, QComboBox, QFrame, QScrollArea, QSplitter, QSpinBox, QDoubleSpinBox ) from PyQt6.QtCore import Qt, QDate, QDateTime from PyQt6.QtGui import QFont, QColor, QPalette from reportlab.lib.pagesizes import letter from reportlab.pdfgen import canvas from reportlab.lib.utils import ImageReader from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont import io # ── PDF page dimensions (points, same as original) ────────────────────────── PAGE_W, PAGE_H = 612, 792 LOGO_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "monzo_logo.png") def hex2rgb(h): h = h.lstrip("#") return tuple(int(h[i:i+2], 16) / 255 for i in (0, 2, 4)) # ── Font registration ───────────────────────────────────────────────────────── def _register_fonts(): from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont # Script directory — fonts are bundled alongside the .py file BASE = os.path.dirname(os.path.abspath(__file__)) # Prefer bundled fonts (work on all platforms) # Fall back to system paths on Linux/macOS if bundles are missing candidates = { "CarlitoBold": [ os.path.join(BASE, "Carlito-Bold.ttf"), "/usr/share/fonts/truetype/crosextra/Carlito-Bold.ttf", ], "Carlito": [ os.path.join(BASE, "Carlito-Regular.ttf"), "/usr/share/fonts/truetype/crosextra/Carlito-Regular.ttf", ], "LibSans": [ os.path.join(BASE, "LiberationSans-Regular.ttf"), "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", ], "LibSansBold": [ os.path.join(BASE, "LiberationSans-Bold.ttf"), "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", ], } for name, paths in candidates.items(): for path in paths: if os.path.exists(path): pdfmetrics.registerFont(TTFont(name, path)) break else: raise FileNotFoundError( f"Font '{name}' not found. Please place the .ttf files " f"in the same folder as this script:\n" f" Carlito-Bold.ttf, Carlito-Regular.ttf,\n" f" LiberationSans-Bold.ttf, LiberationSans-Regular.ttf" ) _register_fonts() # ── PDF Generation ─────────────────────────────────────────────────────────── def generate_pdf(data: dict, output_path: str): """Generate a pixel-perfect Monzo statement PDF.""" from reportlab.lib.utils import simpleSplit c = canvas.Canvas(output_path, pagesize=(PAGE_W, PAGE_H)) # Helper: convert pdfplumber 'top' coord to ReportLab y def y(top): return PAGE_H - top # ── Logo ────────────────────────────────────────────────────────────────── # Original: x0=59, top=60.35, width=155.25, height=34.7 if os.path.exists(LOGO_PATH): c.drawImage(LOGO_PATH, 59, y(95.05), width=155.25, height=34.7, preserveAspectRatio=True) # ── "Statement" — Carlito-Bold 18pt, baseline at top=67.0 ──────────────── c.setFont("CarlitoBold", 18) c.setFillColorRGB(0, 0, 0) c.drawRightString(552.6, y(85.0), "Statement") # ── Date range — LiberationSans-Bold 12pt, top=93.7 ────────────────────── c.setFont("LibSansBold", 12) c.setFillColorRGB(0, 0, 0) c.drawRightString(552.6, y(105.7), f"{data['date_from']} - {data['date_to']}") # ── Name — LiberationSans-Bold 10pt, top=121.1 ─────────────────────────── c.setFont("LibSansBold", 10) c.drawString(59.5, y(131.1), data['name'].upper()) # ── Address — LiberationSans 10pt, first line top=134.6, leading=11.5 ──── c.setFont("LibSans", 10) addr_lines = data['address'].split('\n') addr_top = 134.6 for line in addr_lines: c.drawString(59.5, y(addr_top + 10), line) addr_top += 11.5 # ── Right col: £420.75 (total balance) — LibSansBold 10pt, top=121.1 ──── total_balance = data['total_balance'] c.setFont("LibSansBold", 10) c.drawRightString(552.6, y(131.1), f"\xa3{total_balance:.2f}") # "Total balance" — LibSans 10pt, top=134.6 c.setFont("LibSans", 10) c.setFillColorRGB(0, 0, 0) c.drawRightString(552.6, y(144.6), "Total balance") # "(Including pots)" — LibSans 7pt, top=148.2, gray c.setFont("LibSans", 7) c.setFillColorRGB(0.6, 0.6, 0.6) c.drawRightString(552.6, y(155.2), "(Including pots)") c.setFillColorRGB(0, 0, 0) # ── £420.75 (balance held with Monzo) — LibSansBold 10pt, top=182.5 ───── c.setFont("LibSansBold", 10) c.drawRightString(552.6, y(192.5), f"\xa3{total_balance:.2f}") # "Balance held with Monzo" — LibSans 7pt, top=196.2 c.setFont("LibSans", 7) c.drawRightString(552.6, y(203.2), "Balance held with Monzo") # ── -£273.75 — LiberationSans 14pt, top=222.6 ──────────────────────────── c.setFont("LibSans", 14) c.drawRightString(552.6, y(236.6), f"-\xa3{abs(data['total_outgoings']):.2f}") # "Total outgoings" — LibSans 10pt, top=239.0 c.setFont("LibSans", 10) c.drawRightString(552.6, y(249.0), "Total outgoings") # ── +£206.09 — Carlito (regular) 14pt, top=266.1 ───────────────────────── c.setFont("Carlito", 14) c.drawRightString(552.6, y(280.1), f"+\xa3{abs(data['total_incomes']):.2f}") # "Total incomes" — LibSans 10pt, top=283.0 c.setFont("LibSans", 10) c.drawRightString(552.6, y(293.0), "Total incomes") # ── "Issued on:" — Carlito-Bold 11pt, top=310.2 ────────────────────────── c.setFont("CarlitoBold", 11) c.setFillColorRGB(0, 0, 0) c.drawRightString(552.6, y(321.2), f"Issued on: {data['issued_on']}") # ── Separator line — light gray 1.5pt, top=338.15 ──────────────────────── c.setStrokeColorRGB(0.851, 0.851, 0.851) c.setLineWidth(1.5) c.line(54, y(338.15), 558, y(338.15)) # ── Table header — Carlito-Bold 11pt ───────────────────────────────────── # "Date" and "Description" sit on the bottom line (top=378.9) # "(GBP)" sits on a line above (top≈368), "Amount"/"Balance" on bottom line c.setFont("CarlitoBold", 11) c.setFillColorRGB(0, 0, 0) c.drawString(59.5, y(389.9), "Date") c.drawString(149.5, y(389.9), "Description") # "(GBP)" label on the line above c.drawRightString(449.3, y(378.4), "(GBP)") c.drawRightString(552.6, y(378.4), "(GBP)") # "Amount" / "Balance" on the bottom line c.drawRightString(449.3, y(389.9), "Amount") c.drawRightString(552.6, y(389.9), "Balance") # Header underline — medium gray 1.5pt, top=393.45 c.setStrokeColorRGB(0.502, 0.502, 0.502) c.setLineWidth(1.5) c.line(54, y(393.45), 558, y(393.45)) # ── Transaction rows ────────────────────────────────────────────────────── # Exact row baseline tops from pdfplumber: # date col: 400.3, 427.1, 453.9, 480.7 (LibSans 11pt) # desc col: 399.6, 426.5, 453.3, 480.1 (LibSans 12pt) # amount col: 399.6/425.2, ... (LibSans 12pt / Carlito 14pt for positives) # balance col: 399.6, 426.5, ... (LibSans 12pt) # Row baseline for date: top=400.3, step=26.8 DATE_TOP = 400.3 DESC_TOP = 399.6 ROW_STEP = 26.8 transactions = data['transactions'] for i, txn in enumerate(transactions): date_y = y(DATE_TOP + i * ROW_STEP + 11) desc_y = y(DESC_TOP + i * ROW_STEP + 12) # Date — LibSans 11pt c.setFont("LibSans", 11) c.setFillColorRGB(0, 0, 0) c.drawString(59.5, date_y, txn['date']) # Description — LibSans 12pt c.setFont("LibSans", 12) c.drawString(149.5, desc_y, txn['description']) # Amount — LibSans 12pt for negatives, Carlito 14pt for positives amount = txn['amount'] if amount < 0: amt_str = f"-{abs(amount):.2f}" c.setFont("LibSans", 12) c.drawRightString(449.3, desc_y, amt_str) else: amt_str = f"{amount:.2f}" # Carlito 14pt sits slightly higher — match original top=425.2 offset carlito_y = y(DESC_TOP + i * ROW_STEP + 14) c.setFont("Carlito", 14) c.drawRightString(449.3, carlito_y, amt_str) # Balance — LibSans 12pt c.setFont("LibSans", 12) c.drawRightString(552.6, desc_y, f"{txn['balance']:.2f}") # Dashed separator — placed at DESC_TOP + i*26.88 + 20.88 (exact from original) sep_pt = DESC_TOP + i * ROW_STEP + 20.88 c.setStrokeColorRGB(0.749, 0.749, 0.749) c.setLineWidth(0.5) c.setDash([0.5, 1], 0) c.line(54, y(sep_pt), 558, y(sep_pt)) c.setDash([], 0) # Extra separator after last row at DESC_TOP + n*26.88 + 20.88 extra_sep = DESC_TOP + len(transactions) * ROW_STEP + 20.88 c.setStrokeColorRGB(0.749, 0.749, 0.749) c.setLineWidth(0.5) c.setDash([0.5, 1], 0) c.line(54, y(extra_sep), 558, y(extra_sep)) c.setDash([], 0) # ── Disclaimer — LibSans 9pt, first line top=518.5, leading=10.4 ───────── disclaimer = ( "Monzo Bank Limited (https://monzo.com) is a company registered in England " "No. 9446231. Registered Office: 38 Finsbury Square, London, EC 2A 1PX. " "Monzo Bank Ltd is authorized by the Prudential Regulation Authority and " "regulated by the Financial Conduct Authority and the Prudential Regulation " "Authority. Our Financial Services Register number is 730427." ) c.setFont("LibSans", 9) c.setFillColorRGB(0, 0, 0) # Use the exact line-break positions from the original (width=499pt) disc_lines = simpleSplit(disclaimer, "LibSans", 9, 499) disc_y = y(518.5 + 9) # baseline = top + font_size for line in disc_lines: c.drawString(59.5, disc_y, line) disc_y -= 10.4 # ── Page footer — LibSans 9pt, top=702.9 ───────────────────────────────── c.setFont("LibSans", 9) c.drawRightString(553.4, y(711.9), "Page 1 of 1") c.save() # ── Patch PDF metadata (CreationDate / ModDate) via pypdf ──────────────── if data.get('creation_datetime'): from pypdf import PdfReader, PdfWriter from pypdf.generic import NameObject, create_string_object dt = data['creation_datetime'] tz_offset = dt.utcoffset() if tz_offset is None: tz_str = "Z" else: total_secs = int(tz_offset.total_seconds()) sign = "+" if total_secs >= 0 else "-" total_secs = abs(total_secs) tz_str = f"{sign}{total_secs//3600:02d}'{(total_secs%3600)//60:02d}'" pdf_date = dt.strftime("D:%Y%m%d%H%M%S") + tz_str reader = PdfReader(output_path) writer = PdfWriter() writer.append(reader) writer.add_metadata({ "/CreationDate": pdf_date, "/Creator": "Monzo Bank Limited", "/Producer": "Monzo Bank Limited", }) # Explicitly remove ModDate so it doesn't appear in file properties info = writer._info if info and "/ModDate" in info: del info.indirect_reference.pdf.objects[info.indirect_reference] # Safer approach: overwrite info dict directly from pypdf.generic import DictionaryObject, NameObject, create_string_object new_info = DictionaryObject() new_info[NameObject("/CreationDate")] = create_string_object(pdf_date) new_info[NameObject("/Creator")] = create_string_object("Monzo Bank Limited") new_info[NameObject("/Producer")] = create_string_object("Monzo Bank Limited") writer._info = writer._add_object(new_info) import tempfile, shutil tmp = output_path + ".tmp" with open(tmp, "wb") as f: writer.write(f) shutil.move(tmp, output_path) # ── UI ──────────────────────────────────────────────────────────────────────── STYLE = """ QMainWindow { background: #f5f5f5; } QGroupBox { font-weight: bold; font-size: 12px; border: 1px solid #ddd; border-radius: 6px; margin-top: 8px; padding-top: 8px; background: white; } QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; color: #333; } QLineEdit, QDateEdit, QDoubleSpinBox, QTextEdit { border: 1px solid #ccc; border-radius: 4px; padding: 4px 8px; font-size: 12px; background: white; } QLineEdit:focus, QDateEdit:focus, QDoubleSpinBox:focus, QTextEdit:focus { border-color: #e85d2a; } QPushButton { border-radius: 4px; padding: 6px 16px; font-size: 12px; font-weight: bold; } QPushButton#primary { background: #e85d2a; color: white; border: none; } QPushButton#primary:hover { background: #d04e1f; } QPushButton#secondary { background: #f0f0f0; color: #333; border: 1px solid #ccc; } QPushButton#secondary:hover { background: #e0e0e0; } QPushButton#danger { background: #fff0f0; color: #cc0000; border: 1px solid #ffcccc; } QPushButton#danger:hover { background: #ffe0e0; } QTableWidget { border: 1px solid #ddd; border-radius: 4px; font-size: 12px; background: white; gridline-color: #eee; } QTableWidget::item { padding: 4px 8px; } QHeaderView::section { background: #f8f8f8; border: none; border-bottom: 1px solid #ddd; padding: 6px 8px; font-weight: bold; font-size: 11px; } QLabel#sectionLabel { font-size: 11px; color: #666; } """ class MonzoGenerator(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Monzo Statement Generator") self.setMinimumSize(860, 700) self.resize(960, 780) self.setStyleSheet(STYLE) self._build_ui() self._load_defaults() def _build_ui(self): central = QWidget() self.setCentralWidget(central) root = QVBoxLayout(central) root.setContentsMargins(16, 16, 16, 16) root.setSpacing(12) # Title bar title_row = QHBoxLayout() icon_lbl = QLabel("🏦") icon_lbl.setFont(QFont("", 20)) title_lbl = QLabel("Monzo Statement Generator") title_lbl.setFont(QFont("", 16, QFont.Weight.Bold)) title_row.addWidget(icon_lbl) title_row.addWidget(title_lbl) title_row.addStretch() gen_btn = QPushButton("⬇ Generate PDF") gen_btn.setObjectName("primary") gen_btn.setFixedHeight(36) gen_btn.clicked.connect(self._generate) title_row.addWidget(gen_btn) root.addLayout(title_row) # Two-column layout splitter = QSplitter(Qt.Orientation.Horizontal) root.addWidget(splitter) # Left panel left = QWidget() left_layout = QVBoxLayout(left) left_layout.setContentsMargins(0, 0, 8, 0) left_layout.setSpacing(10) splitter.addWidget(left) # ── Personal Info ──────────────────────────────────────────────────── info_box = QGroupBox("Personal Information") info_form = QVBoxLayout(info_box) info_form.setSpacing(8) def field_row(label, widget): row = QHBoxLayout() lbl = QLabel(label) lbl.setObjectName("sectionLabel") lbl.setFixedWidth(90) row.addWidget(lbl) row.addWidget(widget) return row self.name_edit = QLineEdit() self.name_edit.setPlaceholderText("e.g. JOHN SMITH") info_form.addLayout(field_row("Full Name:", self.name_edit)) # Multi-line address — press Enter for each new line addr_row = QHBoxLayout() addr_lbl = QLabel("Address:") addr_lbl.setObjectName("sectionLabel") addr_lbl.setFixedWidth(90) addr_lbl.setAlignment(Qt.AlignmentFlag.AlignTop) addr_lbl.setContentsMargins(0, 4, 0, 0) from PyQt6.QtWidgets import QTextEdit self.address_edit = QTextEdit() self.address_edit.setPlaceholderText( "One line per row, e.g.:\n123 High Street\nLondon, EC1A 1BB\nUnited Kingdom" ) self.address_edit.setFixedHeight(80) self.address_edit.setAcceptRichText(False) addr_row.addWidget(addr_lbl) addr_row.addWidget(self.address_edit) info_form.addLayout(addr_row) left_layout.addWidget(info_box) # ── Date Info ──────────────────────────────────────────────────────── date_box = QGroupBox("Statement Dates") date_form = QVBoxLayout(date_box) date_form.setSpacing(8) self.date_from = QDateEdit() self.date_from.setCalendarPopup(True) self.date_from.setDisplayFormat("MMM d, yyyy") date_form.addLayout(field_row("From:", self.date_from)) self.date_to = QDateEdit() self.date_to.setCalendarPopup(True) self.date_to.setDisplayFormat("MMM d, yyyy") date_form.addLayout(field_row("To:", self.date_to)) self.issued_on = QDateEdit() self.issued_on.setCalendarPopup(True) self.issued_on.setDisplayFormat("MMM d, yyyy") date_form.addLayout(field_row("Issued On:", self.issued_on)) # PDF metadata CreationDate sep = QFrame() sep.setFrameShape(QFrame.Shape.HLine) sep.setStyleSheet("color: #eee;") date_form.addWidget(sep) meta_lbl = QLabel("PDF File Metadata") meta_lbl.setObjectName("sectionLabel") meta_lbl.setStyleSheet("font-weight: bold; color: #555; padding: 2px 0;") date_form.addWidget(meta_lbl) self.creation_datetime = QDateTimeEdit() self.creation_datetime.setCalendarPopup(True) self.creation_datetime.setDisplayFormat("yyyy-MM-dd HH:mm:ss") self.creation_datetime.setDateTime(QDateTime.currentDateTime()) date_form.addLayout(field_row("CreationDate:", self.creation_datetime)) left_layout.addWidget(date_box) # ── Summary figures (auto or override) ─────────────────────────────── summary_box = QGroupBox("Statement Summary (auto-calculated)") summary_layout = QVBoxLayout(summary_box) summary_layout.setSpacing(8) self.lbl_total_balance = QLabel("Total Balance (incl. pots): £0.00") self.lbl_total_balance.setFont(QFont("", 11, QFont.Weight.Bold)) self.lbl_outgoings = QLabel("Total Outgoings: £0.00") self.lbl_incomes = QLabel("Total Incomes: £0.00") summary_layout.addWidget(self.lbl_total_balance) summary_layout.addWidget(self.lbl_outgoings) summary_layout.addWidget(self.lbl_incomes) note = QLabel("ℹ Balance is calculated by replaying transactions from opening balance.") note.setObjectName("sectionLabel") note.setWordWrap(True) summary_layout.addWidget(note) self.opening_balance = QDoubleSpinBox() self.opening_balance.setRange(-999999, 999999) self.opening_balance.setDecimals(2) self.opening_balance.setPrefix("£") self.opening_balance.valueChanged.connect(self._recalculate) summary_layout.addLayout(field_row("Opening Bal.:", self.opening_balance)) left_layout.addWidget(summary_box) left_layout.addStretch() # Right panel – Transactions right = QWidget() right_layout = QVBoxLayout(right) right_layout.setContentsMargins(8, 0, 0, 0) right_layout.setSpacing(8) splitter.addWidget(right) txn_label = QLabel("Transactions") txn_label.setFont(QFont("", 13, QFont.Weight.Bold)) right_layout.addWidget(txn_label) # Toolbar toolbar = QHBoxLayout() add_btn = QPushButton("+ Add Row") add_btn.setObjectName("secondary") add_btn.clicked.connect(self._add_row) del_btn = QPushButton("✕ Delete Selected") del_btn.setObjectName("danger") del_btn.clicked.connect(self._delete_selected) up_btn = QPushButton("▲") up_btn.setObjectName("secondary") up_btn.setFixedWidth(36) up_btn.clicked.connect(self._move_up) dn_btn = QPushButton("▼") dn_btn.setObjectName("secondary") dn_btn.setFixedWidth(36) dn_btn.clicked.connect(self._move_down) toolbar.addWidget(add_btn) toolbar.addWidget(del_btn) toolbar.addWidget(up_btn) toolbar.addWidget(dn_btn) toolbar.addStretch() right_layout.addLayout(toolbar) # Table self.table = QTableWidget(0, 3) self.table.setHorizontalHeaderLabels(["Date", "Description", "Amount (£)"]) self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self.table.setAlternatingRowColors(True) self.table.itemChanged.connect(self._recalculate) right_layout.addWidget(self.table) hint = QLabel("Amount: positive = income (e.g. 71.70), negative = outgoing (e.g. -134.03)") hint.setObjectName("sectionLabel") right_layout.addWidget(hint) splitter.setSizes([340, 580]) def _load_defaults(self): self.name_edit.setText("John Smith") self.address_edit.setPlainText("2 Upper Gilmore Terrace\nEdinburgh\nEH3 9NN\nUnited Kingdom") self.date_from.setDate(QDate(2026, 5, 1)) self.date_to.setDate(QDate(2026, 5, 31)) self.issued_on.setDate(QDate(2026, 5, 31)) self.creation_datetime.setDateTime(QDateTime(QDate(2026, 5, 31), self.creation_datetime.time())) self.opening_balance.setValue(488.41) default_txns = [ ("16/05/2026", "Transfer to Savings Pot", -134.03), ("19/05/2026", "Transfer from Savings Pot", 71.70), ("21/05/2026", "Transfer from Savings Pot", 134.39), ("24/05/2026", "Transfer to Savings Pot", -139.72), ] for d, desc, amt in default_txns: self._add_row(d, desc, str(amt)) self._recalculate() def _add_row(self, date="", desc="", amount=""): row = self.table.rowCount() self.table.insertRow(row) self.table.setItem(row, 0, QTableWidgetItem(date or "01/06/2026")) self.table.setItem(row, 1, QTableWidgetItem(desc or "Transfer")) self.table.setItem(row, 2, QTableWidgetItem(amount or "0.00")) self.table.scrollToBottom() def _delete_selected(self): rows = sorted(set(i.row() for i in self.table.selectedItems()), reverse=True) for row in rows: self.table.removeRow(row) self._recalculate() def _move_up(self): row = self.table.currentRow() if row > 0: self._swap_rows(row, row - 1) self.table.selectRow(row - 1) def _move_down(self): row = self.table.currentRow() if row < self.table.rowCount() - 1: self._swap_rows(row, row + 1) self.table.selectRow(row + 1) def _swap_rows(self, r1, r2): for col in range(3): a = self.table.item(r1, col) b = self.table.item(r2, col) ta = a.text() if a else "" tb = b.text() if b else "" self.table.setItem(r1, col, QTableWidgetItem(tb)) self.table.setItem(r2, col, QTableWidgetItem(ta)) self._recalculate() def _recalculate(self): opening = self.opening_balance.value() balance = opening total_in = 0.0 total_out = 0.0 for row in range(self.table.rowCount()): item = self.table.item(row, 2) try: amt = float(item.text()) if item else 0.0 except ValueError: amt = 0.0 balance += amt if amt >= 0: total_in += amt else: total_out += abs(amt) self.lbl_total_balance.setText(f"Total Balance (incl. pots): £{balance:.2f}") self.lbl_outgoings.setText(f"Total Outgoings: £{total_out:.2f}") self.lbl_incomes.setText(f"Total Incomes: £{total_in:.2f}") def _get_data(self): opening = self.opening_balance.value() balance = opening total_in = 0.0 total_out = 0.0 transactions = [] for row in range(self.table.rowCount()): date_item = self.table.item(row, 0) desc_item = self.table.item(row, 1) amt_item = self.table.item(row, 2) date_str = date_item.text() if date_item else "" desc_str = desc_item.text() if desc_item else "" try: amt = float(amt_item.text()) if amt_item else 0.0 except ValueError: amt = 0.0 balance += amt if amt >= 0: total_in += amt else: total_out += abs(amt) transactions.append({"date": date_str, "description": desc_str, "amount": amt, "balance": round(balance, 2)}) def fmt_date_numeric(qd: QDate): # DD/MM/YYYY for the statement date range header d = qd.toPyDate() return d.strftime("%d/%m/%Y") def fmt_date_text(qd: QDate): # "Jul 31, 2026" for "Issued on:" line d = qd.toPyDate() return d.strftime("%b ") + str(d.day) + d.strftime(", %Y") address = self.address_edit.toPlainText().strip() return { "name": self.name_edit.text().strip() or "UNKNOWN", "address": address, "date_from": fmt_date_numeric(self.date_from.date()), "date_to": fmt_date_numeric(self.date_to.date()), "issued_on": fmt_date_text(self.issued_on.date()), "total_balance": round(balance, 2), "total_outgoings": round(total_out, 2), "total_incomes": round(total_in, 2), "transactions": transactions, "creation_datetime": self.creation_datetime.dateTime().toPyDateTime(), } def _generate(self): if self.table.rowCount() == 0: QMessageBox.warning(self, "No Transactions", "Please add at least one transaction.") return # Default save location: user's Desktop (works on Windows/Linux/macOS) import pathlib default_dir = str(pathlib.Path.home() / "Desktop") default_path = os.path.join(default_dir, "Monzo_Statement.pdf") path, _ = QFileDialog.getSaveFileName( self, "Save PDF", default_path, "PDF Files (*.pdf)" ) if not path: return try: data = self._get_data() generate_pdf(data, path) QMessageBox.information(self, "Success", f"Statement saved to:\n{path}") except PermissionError: QMessageBox.critical(self, "Permission Denied", f"Cannot write to:\n{path}\n\n" f"Please choose a different location (e.g. Desktop or Documents).") except Exception as e: QMessageBox.critical(self, "Error", f"Failed to generate PDF:\n{e}") raise if __name__ == "__main__": app = QApplication(sys.argv) app.setStyle("Fusion") win = MonzoGenerator() win.show() sys.exit(app.exec())