first commit
This commit is contained in:
85
Invoice_4a_2026.pdf
Normal file
85
Invoice_4a_2026.pdf
Normal file
File diff suppressed because one or more lines are too long
BIN
assets/logo.png
Normal file
BIN
assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
4
invoice.sh
Executable file
4
invoice.sh
Executable 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
384
invoicer.py
Normal 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
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
reportlab==4.4.10
|
||||
PyMuPDF==1.27.2.3
|
||||
Reference in New Issue
Block a user