Parse existing Invoice_*.pdf files (PyMuPDF) for the invoice number and "Work performed" period, then default the next period to the 14 days after the latest one, the number to <month><letter>/<year> with the letter incrementing within the month, and the amount to 7250 EUR. Also add an optional days-off deduction at the derived daily rate and ask before overwriting an existing PDF. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
577 lines
17 KiB
Python
577 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate a KBR4 invoice PDF for Audact AI Ltd."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from datetime import date, datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
from reportlab.lib.colors import HexColor, black, white
|
|
from reportlab.lib.pagesizes import A4
|
|
from reportlab.lib.utils import ImageReader
|
|
from reportlab.pdfgen import canvas
|
|
|
|
|
|
SUPPLIER = {
|
|
"name": "Kompanija Broj 4 D.O.O.",
|
|
"address_lines": ["Hakije Turajlica 2", "71000 Sarajevo", "Bosnia and Herzegovina"],
|
|
"tax_id": "4202962240000",
|
|
"vat_id": "202962240000",
|
|
}
|
|
|
|
CUSTOMER = {
|
|
"name": "Audact AI Ltd",
|
|
"address_lines": [
|
|
"71-75 Shelton Street",
|
|
"Covent Garden",
|
|
"London WC2H 9JQ",
|
|
"United Kingdom",
|
|
],
|
|
"company_no": "17059133",
|
|
}
|
|
|
|
PAYMENT = {
|
|
"iban": "BA391610000270960005",
|
|
"swift": "RZBABA2S",
|
|
"beneficiary_name": "KOMPANIJA BROJ 4 DOO SARAJEVO",
|
|
"beneficiary_address": [
|
|
"HAKIJE TURAJLICA BROJ 2",
|
|
"71000 Sarajevo",
|
|
"Bosnia and Herzegovina",
|
|
],
|
|
"bank_name": "RAIFFEISEN BANK DD BOSNA I HERCEGOVINA",
|
|
"bank_address": [
|
|
"ZMAJA OD BOSNE BB",
|
|
"71000 SARAJEVO",
|
|
"Bosnia and Herzegovina",
|
|
],
|
|
}
|
|
|
|
LINE_ITEM_DESCRIPTION_LINES = [
|
|
"Technical leadership and development services",
|
|
"for the Audact platform",
|
|
]
|
|
|
|
ASSETS_DIR = Path(__file__).resolve().parent / "assets"
|
|
LOGO_PATH = ASSETS_DIR / "logo.png"
|
|
|
|
DARK = HexColor("#3F3F3F")
|
|
GRAY = HexColor("#7A7A7A")
|
|
LIGHT_GRAY = HexColor("#EAEAEA")
|
|
TABLE_HEAD = HexColor("#4A4A4A")
|
|
|
|
|
|
WORKDAYS_PER_WEEK = 5
|
|
PERIOD_DAYS = 14
|
|
DEFAULT_AMOUNT = 7250.0
|
|
INVOICE_GLOB = "Invoice_*.pdf"
|
|
|
|
NUMBER_RE = re.compile(r"#\s*(\d+)([a-z])/(\d{4})")
|
|
PERIOD_RE = re.compile(
|
|
r"Work performed from\s+(\d{1,2}\.\d{1,2}\.\d{4})\.?\s*-\s*(\d{1,2}\.\d{1,2}\.\d{4})\.?"
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class PastInvoice:
|
|
path: Path
|
|
month: int
|
|
letter: str
|
|
year: int
|
|
period_start: date
|
|
period_end: date
|
|
|
|
@property
|
|
def number(self) -> str:
|
|
return f"{self.month}{self.letter}/{self.year}"
|
|
|
|
|
|
def read_pdf_text(path: Path) -> str:
|
|
import fitz # PyMuPDF
|
|
|
|
with fitz.open(str(path)) as doc:
|
|
return "\n".join(page.get_text() for page in doc)
|
|
|
|
|
|
def parse_invoice_pdf(path: Path) -> PastInvoice | None:
|
|
try:
|
|
text = read_pdf_text(path)
|
|
except Exception:
|
|
return None
|
|
num = NUMBER_RE.search(text)
|
|
per = PERIOD_RE.search(text)
|
|
if not num or not per:
|
|
return None
|
|
try:
|
|
start = parse_date(per.group(1))
|
|
end = parse_date(per.group(2))
|
|
except ValueError:
|
|
return None
|
|
return PastInvoice(
|
|
path=path,
|
|
month=int(num.group(1)),
|
|
letter=num.group(2),
|
|
year=int(num.group(3)),
|
|
period_start=start,
|
|
period_end=end,
|
|
)
|
|
|
|
|
|
def load_past_invoices(folder: Path) -> list[PastInvoice]:
|
|
invoices = [parse_invoice_pdf(p) for p in folder.glob(INVOICE_GLOB)]
|
|
return sorted((i for i in invoices if i), key=lambda i: (i.period_end, i.path.stat().st_mtime))
|
|
|
|
|
|
def latest_invoice(invoices: list[PastInvoice]) -> PastInvoice | None:
|
|
return invoices[-1] if invoices else None
|
|
|
|
|
|
def next_invoice_number(invoices: list[PastInvoice], period_end: date) -> str:
|
|
"""Number = <month of period end><letter>/<year>; letter increments within the month."""
|
|
letters = [
|
|
i.letter
|
|
for i in invoices
|
|
if i.month == period_end.month and i.year == period_end.year
|
|
]
|
|
letter = chr(ord(max(letters)) + 1) if letters else "a"
|
|
return f"{period_end.month}{letter}/{period_end.year}"
|
|
|
|
|
|
@dataclass
|
|
class InvoiceInputs:
|
|
number: str
|
|
period_start: date
|
|
period_end: date
|
|
total_amount: float
|
|
days_off: int = 0
|
|
|
|
@property
|
|
def weeks(self) -> int:
|
|
days = (self.period_end - self.period_start).days + 1
|
|
return max(1, round(days / 7))
|
|
|
|
@property
|
|
def workdays(self) -> int:
|
|
return self.weeks * WORKDAYS_PER_WEEK
|
|
|
|
@property
|
|
def daily_rate(self) -> float:
|
|
return round(self.total_amount / self.workdays, 2)
|
|
|
|
@property
|
|
def days_off_amount(self) -> float:
|
|
return round(self.days_off * self.daily_rate, 2)
|
|
|
|
@property
|
|
def net_total(self) -> float:
|
|
return round(self.total_amount - self.days_off_amount, 2)
|
|
|
|
@property
|
|
def issue_date(self) -> date:
|
|
return date.today()
|
|
|
|
@property
|
|
def due_date(self) -> date:
|
|
d = self.issue_date
|
|
added = 0
|
|
while added < 5:
|
|
d += timedelta(days=1)
|
|
if d.weekday() < 5:
|
|
added += 1
|
|
return d
|
|
|
|
|
|
def with_default(label: str, default: str | None) -> str:
|
|
return f"{label} [{default}]: " if default is not None else f"{label}: "
|
|
|
|
|
|
def prompt_str(label: str, validator=None, default: str | None = None) -> str:
|
|
while True:
|
|
value = input(with_default(label, default)).strip()
|
|
if not value and default is not None:
|
|
return default
|
|
if not value:
|
|
print(" Value required.")
|
|
continue
|
|
if validator:
|
|
err = validator(value)
|
|
if err:
|
|
print(f" {err}")
|
|
continue
|
|
return value
|
|
|
|
|
|
def parse_date(s: str) -> date:
|
|
for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y"):
|
|
try:
|
|
return datetime.strptime(s, fmt).date()
|
|
except ValueError:
|
|
continue
|
|
raise ValueError("expected YYYY-MM-DD, DD.MM.YYYY, or DD/MM/YYYY")
|
|
|
|
|
|
def prompt_date(label: str, default: date | None = None) -> date:
|
|
while True:
|
|
raw = input(with_default(label, default.isoformat() if default else None)).strip()
|
|
if not raw and default is not None:
|
|
return default
|
|
try:
|
|
return parse_date(raw)
|
|
except ValueError as e:
|
|
print(f" {e}")
|
|
|
|
|
|
def prompt_amount(label: str, default: float | None = None) -> float:
|
|
while True:
|
|
shown = f"{default:g}" if default is not None else None
|
|
raw = input(with_default(label, shown)).strip().replace(",", ".").replace("€", "").replace(" ", "")
|
|
if not raw and default is not None:
|
|
return default
|
|
try:
|
|
value = float(raw)
|
|
if value <= 0:
|
|
raise ValueError
|
|
return value
|
|
except ValueError:
|
|
print(" Enter a positive number, e.g. 7250 or 7250.00")
|
|
|
|
|
|
def prompt_int(label: str, max_value: int | None = None, default: int | None = None) -> int:
|
|
while True:
|
|
raw = input(with_default(label, str(default) if default is not None else None)).strip()
|
|
if not raw and default is not None:
|
|
return default
|
|
try:
|
|
value = int(raw)
|
|
if value < 0:
|
|
raise ValueError
|
|
except ValueError:
|
|
print(" Enter a whole number, e.g. 0 or 2")
|
|
continue
|
|
if max_value is not None and value > max_value:
|
|
print(f" Must be between 0 and {max_value}.")
|
|
continue
|
|
return value
|
|
|
|
|
|
def gather_inputs(folder: Path) -> InvoiceInputs:
|
|
print("KBR4 invoice generator — Audact AI Ltd")
|
|
print("-" * 40)
|
|
past = load_past_invoices(folder)
|
|
last = latest_invoice(past)
|
|
default_start = default_end = None
|
|
if last:
|
|
print(
|
|
f"Last invoice: {last.number} ({last.path.name}), "
|
|
f"work performed {fmt_date_dotted(last.period_start)} - {fmt_date_dotted(last.period_end)}"
|
|
)
|
|
default_start = last.period_end + timedelta(days=1)
|
|
default_end = default_start + timedelta(days=PERIOD_DAYS - 1)
|
|
else:
|
|
print(f"No previous {INVOICE_GLOB} found in {folder}; no period/number defaults.")
|
|
print("Invoice period (two-week range, in arrears):")
|
|
start = prompt_date(" Period start (YYYY-MM-DD)", default=default_start)
|
|
if default_end is not None and start != default_start:
|
|
default_end = start + timedelta(days=PERIOD_DAYS - 1)
|
|
while True:
|
|
end = prompt_date(" Period end (YYYY-MM-DD)", default=default_end)
|
|
if end < start:
|
|
print(" End must be on or after start.")
|
|
continue
|
|
break
|
|
number = prompt_str("Invoice number (e.g. 1a/2026)", default=next_invoice_number(past, end))
|
|
total = prompt_amount("Total amount in EUR", default=DEFAULT_AMOUNT)
|
|
inv = InvoiceInputs(number=number, period_start=start, period_end=end, total_amount=total)
|
|
print(
|
|
f"Period spans {inv.weeks} week(s) = {inv.workdays} workdays "
|
|
f"at {fmt_money(inv.daily_rate)}/day."
|
|
)
|
|
inv.days_off = prompt_int(
|
|
f"Days off in this period (0-{inv.workdays})", max_value=inv.workdays, default=0
|
|
)
|
|
return inv
|
|
|
|
|
|
def fmt_money(amount: float) -> str:
|
|
return f"€{amount:,.2f}"
|
|
|
|
|
|
def fmt_date_long(d: date) -> str:
|
|
return d.strftime("%b ") + str(d.day) + d.strftime(", %Y")
|
|
|
|
|
|
def fmt_date_dotted(d: date) -> str:
|
|
return f"{d.day}.{d.month}.{d.year}."
|
|
|
|
|
|
def safe_filename(invoice_number: str) -> str:
|
|
cleaned = invoice_number.replace("/", "_").replace("\\", "_").replace(" ", "_")
|
|
return f"Invoice_{cleaned}.pdf"
|
|
|
|
|
|
def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
|
|
page_w, page_h = A4
|
|
c = canvas.Canvas(str(output_path), pagesize=A4)
|
|
c.setTitle(f"Invoice {inv.number}")
|
|
c.setAuthor(SUPPLIER["name"])
|
|
|
|
margin_l = 50
|
|
margin_r = page_w - 50
|
|
|
|
y_top = page_h - 60
|
|
|
|
if LOGO_PATH.exists():
|
|
logo = ImageReader(str(LOGO_PATH))
|
|
lw, lh = logo.getSize()
|
|
target_w = 110
|
|
target_h = target_w * lh / lw
|
|
c.drawImage(
|
|
logo,
|
|
margin_l,
|
|
y_top - target_h + 10,
|
|
width=target_w,
|
|
height=target_h,
|
|
mask="auto",
|
|
preserveAspectRatio=True,
|
|
)
|
|
|
|
c.setFillColor(DARK)
|
|
c.setFont("Helvetica", 30)
|
|
c.drawRightString(margin_r, y_top, "INVOICE")
|
|
c.setFont("Helvetica", 11)
|
|
c.setFillColor(GRAY)
|
|
c.drawRightString(margin_r, y_top - 18, f"# {inv.number}")
|
|
|
|
y = y_top - 35
|
|
c.setFillColor(black)
|
|
c.setFont("Helvetica-Bold", 10.5)
|
|
c.drawString(margin_l, y, SUPPLIER["name"])
|
|
c.setFont("Helvetica", 9.5)
|
|
y -= 13
|
|
for line in SUPPLIER["address_lines"]:
|
|
c.drawString(margin_l, y, line)
|
|
y -= 12
|
|
c.drawString(margin_l, y, f"TAX ID: {SUPPLIER['tax_id']}")
|
|
y -= 12
|
|
c.drawString(margin_l, y, f"VAT ID: {SUPPLIER['vat_id']}")
|
|
y -= 24
|
|
|
|
bill_top = y
|
|
c.setFillColor(GRAY)
|
|
c.setFont("Helvetica", 9)
|
|
c.drawString(margin_l, bill_top, "Bill To:")
|
|
bill_y = bill_top - 13
|
|
c.setFillColor(black)
|
|
c.setFont("Helvetica-Bold", 10.5)
|
|
c.drawString(margin_l, bill_y, CUSTOMER["name"])
|
|
c.setFont("Helvetica", 9.5)
|
|
bill_y -= 13
|
|
for line in CUSTOMER["address_lines"]:
|
|
c.drawString(margin_l, bill_y, line)
|
|
bill_y -= 12
|
|
c.drawString(margin_l, bill_y, f"Company No: {CUSTOMER['company_no']}")
|
|
bill_y -= 12
|
|
|
|
meta_label_x = margin_r - 110
|
|
meta_value_x = margin_r
|
|
meta_y = y_top - 60
|
|
|
|
c.setFillColor(GRAY)
|
|
c.setFont("Helvetica", 9.5)
|
|
c.drawRightString(meta_label_x, meta_y, "Date:")
|
|
c.setFillColor(black)
|
|
c.drawRightString(meta_value_x, meta_y, fmt_date_long(inv.issue_date))
|
|
|
|
meta_y -= 18
|
|
c.setFillColor(GRAY)
|
|
c.drawRightString(meta_label_x, meta_y, "Due Date:")
|
|
c.setFillColor(black)
|
|
c.drawRightString(meta_value_x, meta_y, fmt_date_long(inv.due_date))
|
|
|
|
meta_y -= 22
|
|
bal_box_h = 26
|
|
bal_box_y = meta_y - bal_box_h + 14
|
|
c.setFillColor(LIGHT_GRAY)
|
|
c.rect(margin_r - 230, bal_box_y, 230, bal_box_h, stroke=0, fill=1)
|
|
c.setFillColor(black)
|
|
c.setFont("Helvetica-Bold", 11)
|
|
c.drawRightString(meta_label_x, meta_y, "Balance Due:")
|
|
c.drawRightString(meta_value_x, meta_y, fmt_money(inv.net_total))
|
|
|
|
table_top = min(bill_y, bal_box_y) - 30
|
|
row_h = 22
|
|
|
|
header_y = table_top
|
|
c.setFillColor(TABLE_HEAD)
|
|
c.rect(margin_l, header_y - row_h + 6, margin_r - margin_l, row_h, stroke=0, fill=1)
|
|
c.setFillColor(white)
|
|
c.setFont("Helvetica", 10)
|
|
item_x = margin_l + 8
|
|
qty_right = margin_l + 320
|
|
rate_right = margin_l + 410
|
|
amount_right = margin_r - 8
|
|
text_y = header_y - row_h + 12
|
|
c.drawString(item_x, text_y, "Item")
|
|
c.drawRightString(qty_right, text_y, "Quantity")
|
|
c.drawRightString(rate_right, text_y, "Rate")
|
|
c.drawRightString(amount_right, text_y, "Amount")
|
|
|
|
weeks = inv.weeks
|
|
rate = round(inv.total_amount / weeks, 2)
|
|
amount = inv.total_amount
|
|
|
|
row_y = header_y - row_h - 8
|
|
c.setFillColor(black)
|
|
c.setFont("Helvetica-Bold", 10)
|
|
c.drawString(item_x, row_y, LINE_ITEM_DESCRIPTION_LINES[0])
|
|
c.setFont("Helvetica", 10)
|
|
c.drawRightString(qty_right, row_y, f"{weeks} week{'s' if weeks != 1 else ''}")
|
|
c.drawRightString(rate_right, row_y, fmt_money(rate))
|
|
c.drawRightString(amount_right, row_y, fmt_money(amount))
|
|
if len(LINE_ITEM_DESCRIPTION_LINES) > 1:
|
|
c.setFont("Helvetica-Bold", 10)
|
|
c.setFillColor(black)
|
|
for extra in LINE_ITEM_DESCRIPTION_LINES[1:]:
|
|
row_y -= 13
|
|
c.drawString(item_x, row_y, extra)
|
|
|
|
if inv.days_off > 0:
|
|
row_y -= 24
|
|
day_word = "day" if inv.days_off == 1 else "days"
|
|
c.setFillColor(black)
|
|
c.setFont("Helvetica-Bold", 10)
|
|
c.drawString(item_x, row_y, "Days off")
|
|
c.setFont("Helvetica", 10)
|
|
c.setFillColor(GRAY)
|
|
c.drawString(item_x, row_y - 13, "Deduction for time not worked")
|
|
c.setFillColor(black)
|
|
c.setFont("Helvetica", 10)
|
|
c.drawRightString(qty_right, row_y, f"{inv.days_off} {day_word}")
|
|
c.drawRightString(rate_right, row_y, fmt_money(inv.daily_rate))
|
|
c.drawRightString(amount_right, row_y, f"-{fmt_money(inv.days_off_amount)}")
|
|
row_y -= 13
|
|
|
|
totals_y = row_y - 50
|
|
label_x = margin_r - 110
|
|
value_x = margin_r
|
|
c.setFillColor(GRAY)
|
|
c.setFont("Helvetica", 10)
|
|
c.drawRightString(label_x, totals_y, "Subtotal:")
|
|
c.setFillColor(black)
|
|
c.drawRightString(value_x, totals_y, fmt_money(amount))
|
|
if inv.days_off > 0:
|
|
totals_y -= 16
|
|
c.setFillColor(GRAY)
|
|
c.drawRightString(label_x, totals_y, "Days off:")
|
|
c.setFillColor(black)
|
|
c.drawRightString(value_x, totals_y, f"-{fmt_money(inv.days_off_amount)}")
|
|
totals_y -= 16
|
|
c.setFillColor(GRAY)
|
|
c.drawRightString(label_x, totals_y, "Tax (0%):")
|
|
c.setFillColor(black)
|
|
c.drawRightString(value_x, totals_y, fmt_money(0))
|
|
totals_y -= 16
|
|
c.setFillColor(GRAY)
|
|
c.drawRightString(label_x, totals_y, "Total:")
|
|
c.setFillColor(black)
|
|
c.setFont("Helvetica-Bold", 10.5)
|
|
c.drawRightString(value_x, totals_y, fmt_money(inv.net_total))
|
|
|
|
notes_y = totals_y - 50
|
|
c.setFillColor(GRAY)
|
|
c.setFont("Helvetica", 9)
|
|
c.drawString(margin_l, notes_y, "Notes:")
|
|
notes_y -= 13
|
|
c.setFillColor(black)
|
|
c.setFont("Helvetica", 9.5)
|
|
c.drawString(
|
|
margin_l,
|
|
notes_y,
|
|
f"Work performed from {fmt_date_dotted(inv.period_start)} - {fmt_date_dotted(inv.period_end)}",
|
|
)
|
|
if inv.days_off > 0:
|
|
day_word = "day" if inv.days_off == 1 else "days"
|
|
notes_y -= 13
|
|
c.drawString(
|
|
margin_l,
|
|
notes_y,
|
|
f"{inv.days_off} {day_word} off deducted at {fmt_money(inv.daily_rate)}/day.",
|
|
)
|
|
notes_y -= 13
|
|
c.drawString(
|
|
margin_l,
|
|
notes_y,
|
|
"VAT reverse charge - Article 196 of Council Directive 2006/112/EC.",
|
|
)
|
|
|
|
terms_y = notes_y - 24
|
|
c.setFillColor(GRAY)
|
|
c.setFont("Helvetica", 9)
|
|
c.drawString(margin_l, terms_y, "Terms:")
|
|
terms_y -= 13
|
|
c.setFillColor(black)
|
|
c.setFont("Helvetica", 9.5)
|
|
c.drawString(margin_l, terms_y, "Pay by wire transfer:")
|
|
terms_y -= 18
|
|
c.drawString(margin_l, terms_y, f"IBAN CODE: {PAYMENT['iban']}")
|
|
terms_y -= 12
|
|
c.drawString(margin_l, terms_y, f"SWIFT CODE: {PAYMENT['swift']}")
|
|
terms_y -= 18
|
|
|
|
def draw_block(label: str, lines: list[str], y_start: int) -> int:
|
|
c.setFillColor(GRAY)
|
|
c.setFont("Helvetica", 9)
|
|
c.drawString(margin_l, y_start, label)
|
|
y_start -= 12
|
|
c.setFillColor(black)
|
|
c.setFont("Helvetica", 9.5)
|
|
for ln in lines:
|
|
c.drawString(margin_l, y_start, ln)
|
|
y_start -= 12
|
|
return y_start - 8
|
|
|
|
terms_y = draw_block("Beneficary Name:", [PAYMENT["beneficiary_name"]], terms_y)
|
|
terms_y = draw_block("Beneficary Address:", PAYMENT["beneficiary_address"], terms_y)
|
|
terms_y = draw_block(
|
|
"Beneficary Bank:",
|
|
[PAYMENT["bank_name"], *PAYMENT["bank_address"]],
|
|
terms_y,
|
|
)
|
|
|
|
c.showPage()
|
|
c.save()
|
|
|
|
|
|
def main() -> int:
|
|
folder = Path.cwd()
|
|
try:
|
|
inv = gather_inputs(folder)
|
|
except (KeyboardInterrupt, EOFError):
|
|
print()
|
|
return 130
|
|
|
|
output_path = folder / safe_filename(inv.number)
|
|
if output_path.exists():
|
|
answer = input(f"{output_path.name} already exists. Overwrite? [y/N]: ").strip().lower()
|
|
if answer not in ("y", "yes"):
|
|
print("Aborted.")
|
|
return 1
|
|
render_pdf(inv, output_path)
|
|
print()
|
|
print(f"Wrote {output_path}")
|
|
print(f" Issue date: {fmt_date_long(inv.issue_date)}")
|
|
print(f" Due date: {fmt_date_long(inv.due_date)}")
|
|
print(f" Subtotal: {fmt_money(inv.total_amount)}")
|
|
if inv.days_off > 0:
|
|
print(f" Days off: {inv.days_off} (-{fmt_money(inv.days_off_amount)})")
|
|
print(f" Total: {fmt_money(inv.net_total)}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|