diff --git a/invoicer.py b/invoicer.py index ba4a4e2..ce09c97 100644 --- a/invoicer.py +++ b/invoicer.py @@ -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 = /; 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