Compare commits

...

2 Commits

Author SHA1 Message Date
0541e0c6a7 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>
2026-09-24 18:21:52 +02:00
6ecfb442d4 Revert "Use 5 calendar days for invoice due date"
This reverts commit 9d5c877f54.
2026-05-21 19:04:50 +02:00

View File

@@ -4,6 +4,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import re
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
@@ -64,12 +65,110 @@ LIGHT_GRAY = HexColor("#EAEAEA")
TABLE_HEAD = HexColor("#4A4A4A") 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 @dataclass
class InvoiceInputs: class InvoiceInputs:
number: str number: str
period_start: date period_start: date
period_end: date period_end: date
total_amount: float 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 @property
def issue_date(self) -> date: def issue_date(self) -> date:
@@ -77,12 +176,24 @@ class InvoiceInputs:
@property @property
def due_date(self) -> date: def due_date(self) -> date:
return self.issue_date + timedelta(days=5) 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: 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: 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: if not value:
print(" Value required.") print(" Value required.")
continue continue
@@ -103,18 +214,23 @@ def parse_date(s: str) -> date:
raise ValueError("expected YYYY-MM-DD, DD.MM.YYYY, or DD/MM/YYYY") 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: 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: try:
return parse_date(raw) return parse_date(raw)
except ValueError as e: except ValueError as e:
print(f" {e}") print(f" {e}")
def prompt_amount(label: str) -> float: def prompt_amount(label: str, default: float | None = None) -> float:
while True: 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: try:
value = float(raw) value = float(raw)
if value <= 0: if value <= 0:
@@ -124,20 +240,60 @@ def prompt_amount(label: str) -> float:
print(" Enter a positive number, e.g. 7250 or 7250.00") 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("KBR4 invoice generator — Audact AI Ltd")
print("-" * 40) 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):") 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: while True:
end = prompt_date(" Period end (YYYY-MM-DD)") end = prompt_date(" Period end (YYYY-MM-DD)", default=default_end)
if end < start: if end < start:
print(" End must be on or after start.") print(" End must be on or after start.")
continue continue
break break
total = prompt_amount("Total amount in EUR") number = prompt_str("Invoice number (e.g. 1a/2026)", default=next_invoice_number(past, end))
return InvoiceInputs(number=number, period_start=start, period_end=end, total_amount=total) 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: def fmt_money(amount: float) -> str:
@@ -244,7 +400,7 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
c.setFillColor(black) c.setFillColor(black)
c.setFont("Helvetica-Bold", 11) c.setFont("Helvetica-Bold", 11)
c.drawRightString(meta_label_x, meta_y, "Balance Due:") 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 table_top = min(bill_y, bal_box_y) - 30
row_h = 22 row_h = 22
@@ -264,7 +420,7 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
c.drawRightString(rate_right, text_y, "Rate") c.drawRightString(rate_right, text_y, "Rate")
c.drawRightString(amount_right, text_y, "Amount") c.drawRightString(amount_right, text_y, "Amount")
weeks = 2 weeks = inv.weeks
rate = round(inv.total_amount / weeks, 2) rate = round(inv.total_amount / weeks, 2)
amount = inv.total_amount amount = inv.total_amount
@@ -273,7 +429,7 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
c.setFont("Helvetica-Bold", 10) c.setFont("Helvetica-Bold", 10)
c.drawString(item_x, row_y, LINE_ITEM_DESCRIPTION_LINES[0]) c.drawString(item_x, row_y, LINE_ITEM_DESCRIPTION_LINES[0])
c.setFont("Helvetica", 10) 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(rate_right, row_y, fmt_money(rate))
c.drawRightString(amount_right, row_y, fmt_money(amount)) c.drawRightString(amount_right, row_y, fmt_money(amount))
if len(LINE_ITEM_DESCRIPTION_LINES) > 1: if len(LINE_ITEM_DESCRIPTION_LINES) > 1:
@@ -283,6 +439,22 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
row_y -= 13 row_y -= 13
c.drawString(item_x, row_y, extra) 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 totals_y = row_y - 50
label_x = margin_r - 110 label_x = margin_r - 110
value_x = margin_r value_x = margin_r
@@ -291,6 +463,12 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
c.drawRightString(label_x, totals_y, "Subtotal:") c.drawRightString(label_x, totals_y, "Subtotal:")
c.setFillColor(black) c.setFillColor(black)
c.drawRightString(value_x, totals_y, fmt_money(amount)) 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 totals_y -= 16
c.setFillColor(GRAY) c.setFillColor(GRAY)
c.drawRightString(label_x, totals_y, "Tax (0%):") c.drawRightString(label_x, totals_y, "Tax (0%):")
@@ -301,7 +479,7 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
c.drawRightString(label_x, totals_y, "Total:") c.drawRightString(label_x, totals_y, "Total:")
c.setFillColor(black) c.setFillColor(black)
c.setFont("Helvetica-Bold", 10.5) 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 notes_y = totals_y - 50
c.setFillColor(GRAY) c.setFillColor(GRAY)
@@ -315,6 +493,14 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
notes_y, notes_y,
f"Work performed from {fmt_date_dotted(inv.period_start)} - {fmt_date_dotted(inv.period_end)}", 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 notes_y -= 13
c.drawString( c.drawString(
margin_l, margin_l,
@@ -361,19 +547,28 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
def main() -> int: def main() -> int:
folder = Path.cwd()
try: try:
inv = gather_inputs() inv = gather_inputs(folder)
except (KeyboardInterrupt, EOFError): except (KeyboardInterrupt, EOFError):
print() print()
return 130 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) render_pdf(inv, output_path)
print() print()
print(f"Wrote {output_path}") print(f"Wrote {output_path}")
print(f" Issue date: {fmt_date_long(inv.issue_date)}") print(f" Issue date: {fmt_date_long(inv.issue_date)}")
print(f" Due date: {fmt_date_long(inv.due_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 return 0