This commit is contained in:
unknown
2026-06-24 16:08:20 +00:00
parent 5f6d681746
commit 68d4dbbe51
7 changed files with 749 additions and 0 deletions

BIN
Monzo/Carlito-Bold.ttf Normal file

Binary file not shown.

BIN
Monzo/Carlito-Regular.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
Monzo/Monzo logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

749
Monzo/main.py Normal file
View File

@@ -0,0 +1,749 @@
#!/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())

BIN
Monzo/monzo_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB