382 lines
11 KiB
Python
382 lines
11 KiB
Python
#!/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())
|