mirror of
https://github.com/etianwang/bank-statement-generator.git
synced 2026-06-27 18:31:43 +08:00
new app
This commit is contained in:
BIN
Monzo/Carlito-Bold.ttf
Normal file
BIN
Monzo/Carlito-Bold.ttf
Normal file
Binary file not shown.
BIN
Monzo/Carlito-Regular.ttf
Normal file
BIN
Monzo/Carlito-Regular.ttf
Normal file
Binary file not shown.
BIN
Monzo/LiberationSans-Bold.ttf
Normal file
BIN
Monzo/LiberationSans-Bold.ttf
Normal file
Binary file not shown.
BIN
Monzo/LiberationSans-Regular.ttf
Normal file
BIN
Monzo/LiberationSans-Regular.ttf
Normal file
Binary file not shown.
BIN
Monzo/Monzo logo.png
Normal file
BIN
Monzo/Monzo logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
749
Monzo/main.py
Normal file
749
Monzo/main.py
Normal 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
BIN
Monzo/monzo_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Reference in New Issue
Block a user