#!/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 = /; 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())