#!/usr/bin/env python3 """Generate a KBR4 invoice PDF for Audact AI Ltd.""" from __future__ import annotations import os 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") @dataclass class InvoiceInputs: number: str period_start: date period_end: date total_amount: float @property def issue_date(self) -> date: return date.today() @property def due_date(self) -> date: return self.issue_date + timedelta(days=5) def prompt_str(label: str, validator=None) -> str: while True: value = input(f"{label}: ").strip() 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) -> date: while True: raw = input(f"{label}: ").strip() try: return parse_date(raw) except ValueError as e: print(f" {e}") def prompt_amount(label: str) -> float: while True: raw = input(f"{label}: ").strip().replace(",", ".").replace("€", "").replace(" ", "") 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 gather_inputs() -> InvoiceInputs: print("KBR4 invoice generator — Audact AI Ltd") print("-" * 40) number = prompt_str("Invoice number (e.g. 1/2026)") print("Invoice period (two-week range, in arrears):") start = prompt_date(" Period start (YYYY-MM-DD)") while True: end = prompt_date(" Period end (YYYY-MM-DD)") if end < start: print(" End must be on or after start.") continue break total = prompt_amount("Total amount in EUR") return InvoiceInputs(number=number, period_start=start, period_end=end, total_amount=total) 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.total_amount)) 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 = 2 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} weeks") 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) 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)) 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(amount)) 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)}", ) 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: try: inv = gather_inputs() except (KeyboardInterrupt, EOFError): print() return 130 output_path = Path.cwd() / safe_filename(inv.number) 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" Total: {fmt_money(inv.total_amount)}") return 0 if __name__ == "__main__": sys.exit(main())