first commit

This commit is contained in:
2026-04-28 22:37:18 +02:00
commit 5ffbfb5bc0
5 changed files with 475 additions and 0 deletions

85
Invoice_4a_2026.pdf Normal file

File diff suppressed because one or more lines are too long

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

4
invoice.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "$HOME/venvovi/invoicer/bin/python" "$DIR/invoicer.py" "$@"

384
invoicer.py Normal file
View File

@@ -0,0 +1,384 @@
#!/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"],
"tax_id": "4202962240000",
}
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:
d = self.issue_date
added = 0
while added < 5:
d += timedelta(days=1)
if d.weekday() < 5:
added += 1
return d
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 -= 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())

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
reportlab==4.4.10
PyMuPDF==1.27.2.3