Offer defaults from the latest invoice PDF and add days-off deduction

Parse existing Invoice_*.pdf files (PyMuPDF) for the invoice number and
"Work performed" period, then default the next period to the 14 days
after the latest one, the number to <month><letter>/<year> with the
letter incrementing within the month, and the amount to 7250 EUR.
Also add an optional days-off deduction at the derived daily rate and
ask before overwriting an existing PDF.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 18:21:52 +02:00
parent 6ecfb442d4
commit 0541e0c6a7

View File

@@ -4,6 +4,7 @@
from __future__ import annotations
import os
import re
import sys
from dataclasses import dataclass
from datetime import date, datetime, timedelta
@@ -64,12 +65,110 @@ 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}"
@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)
@property
def issue_date(self) -> date:
@@ -86,9 +185,15 @@ class InvoiceInputs:
return d
def prompt_str(label: str, validator=None) -> str:
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:
while True:
value = input(f"{label}: ").strip()
value = input(with_default(label, default)).strip()
if not value and default is not None:
return default
if not value:
print(" Value required.")
continue
@@ -109,18 +214,23 @@ def parse_date(s: str) -> date:
raise ValueError("expected YYYY-MM-DD, DD.MM.YYYY, or DD/MM/YYYY")
def prompt_date(label: str) -> date:
def prompt_date(label: str, default: date | None = None) -> date:
while True:
raw = input(f"{label}: ").strip()
raw = input(with_default(label, default.isoformat() if default else None)).strip()
if not raw and default is not None:
return default
try:
return parse_date(raw)
except ValueError as e:
print(f" {e}")
def prompt_amount(label: str) -> float:
def prompt_amount(label: str, default: float | None = None) -> float:
while True:
raw = input(f"{label}: ").strip().replace(",", ".").replace("€", "").replace(" ", "")
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
try:
value = float(raw)
if value <= 0:
@@ -130,20 +240,60 @@ def prompt_amount(label: str) -> float:
print(" Enter a positive number, e.g. 7250 or 7250.00")
def gather_inputs() -> InvoiceInputs:
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:
print("KBR4 invoice generator — Audact AI Ltd")
print("-" * 40)
number = prompt_str("Invoice number (e.g. 1/2026)")
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.")
print("Invoice period (two-week range, in arrears):")
start = prompt_date(" Period start (YYYY-MM-DD)")
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)
while True:
end = prompt_date(" Period end (YYYY-MM-DD)")
end = prompt_date(" Period end (YYYY-MM-DD)", default=default_end)
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)
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
def fmt_money(amount: float) -> str:
@@ -250,7 +400,7 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
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))
c.drawRightString(meta_value_x, meta_y, fmt_money(inv.net_total))
table_top = min(bill_y, bal_box_y) - 30
row_h = 22
@@ -270,7 +420,7 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
c.drawRightString(rate_right, text_y, "Rate")
c.drawRightString(amount_right, text_y, "Amount")
weeks = 2
weeks = inv.weeks
rate = round(inv.total_amount / weeks, 2)
amount = inv.total_amount
@@ -279,7 +429,7 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
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(qty_right, row_y, f"{weeks} week{'s' if weeks != 1 else ''}")
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:
@@ -289,6 +439,22 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
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
totals_y = row_y - 50
label_x = margin_r - 110
value_x = margin_r
@@ -297,6 +463,12 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
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)}")
totals_y -= 16
c.setFillColor(GRAY)
c.drawRightString(label_x, totals_y, "Tax (0%):")
@@ -307,7 +479,7 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
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))
c.drawRightString(value_x, totals_y, fmt_money(inv.net_total))
notes_y = totals_y - 50
c.setFillColor(GRAY)
@@ -321,6 +493,14 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
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.",
)
notes_y -= 13
c.drawString(
margin_l,
@@ -367,19 +547,28 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
def main() -> int:
folder = Path.cwd()
try:
inv = gather_inputs()
inv = gather_inputs(folder)
except (KeyboardInterrupt, EOFError):
print()
return 130
output_path = Path.cwd() / safe_filename(inv.number)
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
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)}")
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)}")
return 0