Files
invoicer/invoicer.py

577 lines
17 KiB
Python
Raw Normal View History

2026-04-28 22:37:18 +02:00
#!/usr/bin/env python3
"""Generate a KBR4 invoice PDF for Audact AI Ltd."""
from __future__ import annotations
import os
import re
2026-04-28 22:37:18 +02:00
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"],
2026-04-28 22:37:18 +02:00
"tax_id": "4202962240000",
"vat_id": "202962240000",
2026-04-28 22:37:18 +02:00
}
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}"
2026-04-28 22:37:18 +02:00
@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)
2026-04-28 22:37:18 +02:00
@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
2026-04-28 22:37:18 +02:00
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:
2026-04-28 22:37:18 +02:00
while True:
value = input(with_default(label, default)).strip()
if not value and default is not None:
return default
2026-04-28 22:37:18 +02:00
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:
2026-04-28 22:37:18 +02:00
while True:
raw = input(with_default(label, default.isoformat() if default else None)).strip()
if not raw and default is not None:
return default
2026-04-28 22:37:18 +02:00
try:
return parse_date(raw)
except ValueError as e:
print(f" {e}")
def prompt_amount(label: str, default: float | None = None) -> float:
2026-04-28 22:37:18 +02:00
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
2026-04-28 22:37:18 +02:00
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:
2026-04-28 22:37:18 +02:00
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.")
2026-04-28 22:37:18 +02:00
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)
2026-04-28 22:37:18 +02:00
while True:
end = prompt_date(" Period end (YYYY-MM-DD)", default=default_end)
2026-04-28 22:37:18 +02:00
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
2026-04-28 22:37:18 +02:00
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']}")
2026-04-28 22:37:18 +02:00
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))
2026-04-28 22:37:18 +02:00
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
2026-04-28 22:37:18 +02:00
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 ''}")
2026-04-28 22:37:18 +02:00
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
2026-04-28 22:37:18 +02:00
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)}")
2026-04-28 22:37:18 +02:00
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))
2026-04-28 22:37:18 +02:00
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.",
)
2026-04-28 22:37:18 +02:00
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()
2026-04-28 22:37:18 +02:00
try:
inv = gather_inputs(folder)
2026-04-28 22:37:18 +02:00
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
2026-04-28 22:37:18 +02:00
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)}")
2026-04-28 22:37:18 +02:00
return 0
if __name__ == "__main__":
sys.exit(main())